@protobase/ui 0.15.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/account/two-factor-setup.js +6 -5
  2. package/dist/app/app.js +1 -1
  3. package/dist/app/create/create-page.js +1 -0
  4. package/dist/app/create/create-record-form.js +1 -0
  5. package/dist/app/displays/map-display.js +50 -34
  6. package/dist/app/displays/map-groups.js +12 -3
  7. package/dist/app/displays/measured-heights.js +23 -0
  8. package/dist/app/displays/record-card.js +2 -2
  9. package/dist/app/displays/virtual-cards.js +12 -14
  10. package/dist/app/field-editor.js +9 -1
  11. package/dist/app/list-page.js +1 -1
  12. package/dist/app/map/approximate-position.js +76 -0
  13. package/dist/app/map/area-features.js +80 -0
  14. package/dist/app/map/maplibre.js +11 -0
  15. package/dist/app/map/position-areas.js +78 -0
  16. package/dist/app/map/record-map.js +2 -7
  17. package/dist/app/map/record-position.js +5 -4
  18. package/dist/app/map/records-map.js +24 -7
  19. package/dist/app/map/theme-color.js +23 -0
  20. package/dist/app/nav-recent.js +1 -1
  21. package/dist/app/pages/blocks/card-frame.js +2 -2
  22. package/dist/app/pages/blocks/grid.js +10 -2
  23. package/dist/app/pages/blocks/link.js +1 -1
  24. package/dist/app/pages/blocks/stat.js +9 -5
  25. package/dist/app/pages/field-value.js +1 -1
  26. package/dist/app/record-page.js +5 -1
  27. package/dist/app/router.js +7 -8
  28. package/dist/app/saved/saved-records.js +1 -1
  29. package/dist/app/search/search-model.js +1 -1
  30. package/dist/app/searches/use-save-search.js +4 -3
  31. package/dist/app/use-record-editor.js +5 -3
  32. package/dist/app-shell/app-shell.d.ts +1 -0
  33. package/dist/app-shell/app-shell.js +4 -3
  34. package/dist/app-shell/page-scroll.js +17 -1
  35. package/dist/app-shell/sidebar.d.ts +1 -0
  36. package/dist/app-shell/sidebar.js +4 -4
  37. package/dist/app-shell/top-bar.js +4 -4
  38. package/dist/auth/auth-gate.js +18 -9
  39. package/dist/auth/auth-provider.js +1 -0
  40. package/dist/auth/code-form.js +77 -0
  41. package/dist/auth/email-code-form.js +18 -77
  42. package/dist/auth/forgot-password-form.js +10 -10
  43. package/dist/auth/forgot-password-page.js +2 -2
  44. package/dist/auth/resend-code-button.js +29 -0
  45. package/dist/auth/sign-in-form.js +7 -8
  46. package/dist/auth/sign-in-page.js +3 -3
  47. package/dist/auth/sign-in-prompt.js +13 -9
  48. package/dist/auth/sign-up-code-form.js +22 -0
  49. package/dist/auth/sign-up-details-form.js +81 -111
  50. package/dist/auth/sign-up-page.js +17 -8
  51. package/dist/auth/two-factor-form.js +3 -3
  52. package/dist/auth/use-sign-in-address.js +21 -8
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.js +2 -1
  55. package/dist/lib/flip-guard.js +43 -0
  56. package/dist/lib/page-scroll-lock.js +20 -0
  57. package/dist/lib/use-focus-trap.js +4 -4
  58. package/dist/organizations/invitation-page.js +7 -6
  59. package/dist/package.js +1 -1
  60. package/dist/primitives/otp-input.d.ts +21 -0
  61. package/dist/primitives/otp-input.js +117 -0
  62. package/dist/primitives/otp-value.js +23 -0
  63. package/dist/primitives/route-link.d.ts +1 -0
  64. package/dist/primitives/route-link.js +20 -0
  65. package/dist/primitives/textarea.js +36 -0
  66. package/dist/record-view/field.js +56 -53
  67. package/dist/shell/assets/app-DJAZP1yB.js +66 -0
  68. package/dist/shell/assets/index-C5pYkUfv.css +2 -0
  69. package/dist/shell/assets/{index-DxmdRnr8.js → index-LBhMBiK4.js} +1 -1
  70. package/dist/shell/assets/maplibre-Dy0ZTnFZ.js +814 -0
  71. package/dist/shell/assets/record-map-BiWSbqqT.js +1 -0
  72. package/dist/shell/assets/records-map-DabYc3ad.js +1 -0
  73. package/dist/shell/assets/shared-protobase-ui-BURelnPG.js +1 -0
  74. package/dist/shell/index.html +4 -4
  75. package/dist/shell/protobase.shell.json +1 -1
  76. package/dist/styles.css +15 -0
  77. package/dist/toasts/toast.js +22 -6
  78. package/package.json +4 -4
  79. package/dist/auth/code-input.js +0 -17
  80. package/dist/auth/sign-in-address.js +0 -47
  81. package/dist/shell/assets/app-BdEV1_xI.js +0 -66
  82. package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
  83. package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
  84. package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
  85. package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
  86. package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
  87. /package/dist/shell/assets/{record-map-DBE96hFo.css → maplibre-DBE96hFo.css} +0 -0
@@ -1,13 +1,14 @@
1
1
  //#region src/app/map/record-position.ts
2
- const degrees = (value) => {
2
+ /** A number field's value: it comes as a number, or as text for a decimal or bigint; anything else, or an empty value, is none. */
3
+ const numeric = (value) => {
3
4
  if (typeof value === "number") return value;
4
5
  if (typeof value !== "string" || value.trim() === "") return void 0;
5
6
  return Number(value);
6
7
  };
7
8
  /** The record's position from its two fields, when both hold degrees on the globe; none otherwise, so no map is drawn. */
8
9
  const recordPosition = (record, fields) => {
9
- const latitude = degrees(record[fields.latitude]);
10
- const longitude = degrees(record[fields.longitude]);
10
+ const latitude = numeric(record[fields.latitude]);
11
+ const longitude = numeric(record[fields.longitude]);
11
12
  if (latitude === void 0 || longitude === void 0) return void 0;
12
13
  if (!(Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180)) return void 0;
13
14
  return {
@@ -16,4 +17,4 @@ const recordPosition = (record, fields) => {
16
17
  };
17
18
  };
18
19
  //#endregion
19
- export { recordPosition };
20
+ export { numeric, recordPosition };
@@ -1,9 +1,11 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { groupPoints } from "../displays/map-groups.js";
3
+ import { mapLib } from "./maplibre.js";
4
+ import { PositionAreas } from "./position-areas.js";
5
+ import { themeColor } from "./theme-color.js";
3
6
  import { useEffect, useRef, useState } from "react";
4
7
  import { MapPin } from "lucide-react";
5
8
  import { jsx, jsxs } from "react/jsx-runtime";
6
- import * as maplibre from "maplibre-gl";
7
9
  import { Map as Map$1, Marker, NavigationControl } from "react-map-gl/maplibre";
8
10
  //#region src/app/map/records-map.tsx
9
11
  const groupRadius = 28;
@@ -25,13 +27,15 @@ const compact = new Intl.NumberFormat("en-IE", {
25
27
  const corners = (bounds) => [[bounds.west, bounds.south], [bounds.east, bounds.north]];
26
28
  /**
27
29
  * A map of `style` with a marker for each group of records that lie close together at the zoom, showing how many it
28
- * stands for, and a circle with the count of each bucket, which zooms in on its records when chosen. Choosing a marker
29
- * selects its records; choosing the map elsewhere clears the selection. A chosen marker the covered bottom would hide
30
- * moves up into the open part of the map.
30
+ * stands for, and a circle with the count of each bucket, which zooms in on its records when chosen. A marker whose
31
+ * records' positions are all approximate is drawn hollow with a dashed edge, over the areas around them. Choosing a
32
+ * marker selects its records; choosing the map elsewhere clears the selection. A chosen marker the covered bottom would
33
+ * hide moves up into the open part of the map.
31
34
  */
32
35
  const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
33
36
  const ref = useRef(null);
34
37
  const [groups, setGroups] = useState([]);
38
+ const [colors, setColors] = useState();
35
39
  const byKey = new globalThis.Map(points.map((point) => [point.key, point]));
36
40
  const pointKeys = points.map((point) => point.key).join(",");
37
41
  const fitKey = fit && JSON.stringify(fit);
@@ -87,7 +91,7 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
87
91
  }, [chosenAt, covered]);
88
92
  return /* @__PURE__ */ jsxs(Map$1, {
89
93
  ref,
90
- mapLib: maplibre,
94
+ mapLib,
91
95
  mapStyle: style,
92
96
  initialViewState: initial ?? {
93
97
  latitude: 0,
@@ -99,6 +103,11 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
99
103
  height: "100%"
100
104
  },
101
105
  onLoad: () => {
106
+ const container = ref.current.getMap().getContainer();
107
+ setColors({
108
+ area: themeColor(container, "--primary"),
109
+ chosen: themeColor(container, "--foreground")
110
+ });
102
111
  regroup();
103
112
  if (fit) fitNow();
104
113
  else report();
@@ -108,6 +117,11 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
108
117
  onClick: onClear,
109
118
  children: [
110
119
  /* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
120
+ colors && /* @__PURE__ */ jsx(PositionAreas, {
121
+ points,
122
+ selected,
123
+ colors
124
+ }),
111
125
  buckets.map((bucket) => /* @__PURE__ */ jsx(Marker, {
112
126
  latitude: bucket.latitude,
113
127
  longitude: bucket.longitude,
@@ -127,6 +141,7 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
127
141
  const at = byKey.get(group.at);
128
142
  if (!at) return null;
129
143
  const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
144
+ const approximate = group.keys.every((key) => byKey.get(key)?.approximate);
130
145
  return /* @__PURE__ */ jsx(Marker, {
131
146
  latitude: at.latitude,
132
147
  longitude: at.longitude,
@@ -143,8 +158,10 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
143
158
  },
144
159
  children: [/* @__PURE__ */ jsx(MapPin, {
145
160
  "aria-hidden": true,
146
- className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
147
- strokeWidth: 1.5
161
+ "data-approximate": approximate || void 0,
162
+ className: cn("size-8 drop-shadow", approximate ? chosen ? "fill-background text-foreground" : "fill-background text-primary" : chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
163
+ strokeWidth: approximate ? 2 : 1.5,
164
+ strokeDasharray: approximate ? "3 2" : void 0
148
165
  }), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
149
166
  className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
150
167
  children: group.keys.length
@@ -0,0 +1,23 @@
1
+ //#region src/app/map/theme-color.ts
2
+ /**
3
+ * The colour a theme token such as `--primary` holds where `element` is, as `rgba()`, which MapLibre's paint reads
4
+ * whatever the theme writes it in: the browser resolves the token, and a pixel painted with it gives its channels.
5
+ */
6
+ const themeColor = (element, token) => {
7
+ const probe = document.createElement("span");
8
+ probe.style.color = `var(${token})`;
9
+ element.append(probe);
10
+ const resolved = getComputedStyle(probe).color;
11
+ probe.remove();
12
+ const canvas = document.createElement("canvas");
13
+ canvas.width = 1;
14
+ canvas.height = 1;
15
+ const context = canvas.getContext("2d", { willReadFrequently: true });
16
+ if (!context) return resolved;
17
+ context.fillStyle = resolved;
18
+ context.fillRect(0, 0, 1, 1);
19
+ const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
20
+ return `rgba(${red}, ${green}, ${blue}, ${alpha / 255})`;
21
+ };
22
+ //#endregion
23
+ export { themeColor };
@@ -1,7 +1,7 @@
1
1
  import { recordKey, recordTitle, recordTitleField } from "../live/model-helpers.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
- import { defaultRecentLimit, encodeKey } from "@protobase/schema/internal";
4
3
  import { appPath, enumLabel } from "@protobase/schema";
4
+ import { defaultRecentLimit, encodeKey } from "@protobase/schema/internal";
5
5
  //#region src/app/nav-recent.ts
6
6
  /** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows. */
7
7
  const recentRequest = (model, view, recent) => ({
@@ -2,7 +2,7 @@ import { cn } from "../../../lib/cn.js";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  //#region src/app/pages/blocks/card-frame.tsx
4
4
  /** The card every block that shows a box uses: title, description and header actions over the body. */
5
- const CardFrame = ({ title, description, actions, className, children }) => /* @__PURE__ */ jsxs("section", {
5
+ const CardFrame = ({ title, description, actions, className, bodyClassName, children }) => /* @__PURE__ */ jsxs("section", {
6
6
  className: cn("flex min-w-0 flex-col rounded-lg border border-border bg-background", className),
7
7
  "aria-label": title,
8
8
  children: [(title || description || actions) && /* @__PURE__ */ jsxs("header", {
@@ -21,7 +21,7 @@ const CardFrame = ({ title, description, actions, className, children }) => /* @
21
21
  children: actions
22
22
  })]
23
23
  }), /* @__PURE__ */ jsx("div", {
24
- className: "min-w-0 flex-1 p-4 text-[13px]",
24
+ className: cn("min-w-0 flex-1 p-4 text-[13px]", bodyClassName),
25
25
  children
26
26
  })]
27
27
  });
@@ -8,18 +8,26 @@ const columns = {
8
8
  3: "md:grid-cols-2 xl:grid-cols-3",
9
9
  4: "sm:grid-cols-2 xl:grid-cols-4"
10
10
  };
11
+ const statColumns = {
12
+ 1: "",
13
+ 2: "grid-cols-2",
14
+ 3: "grid-cols-2 xl:grid-cols-3",
15
+ 4: "grid-cols-2 xl:grid-cols-4"
16
+ };
11
17
  const spans = {
12
18
  1: "",
13
19
  2: "md:col-span-2",
14
20
  3: "md:col-span-2 xl:col-span-3",
15
21
  4: "sm:col-span-2 md:col-span-2 xl:col-span-4"
16
22
  };
17
- /** Columns that collapse to one on a phone; a child's `span` makes it wider. */
23
+ const isStat = (child) => typeof child === "object" && !child.custom && child.type === "Stat";
24
+ /** Columns that collapse to one on a phone, or two when it holds only `Stat`s; a child's `span` makes it wider. */
18
25
  const GridBlock = ({ node, children }) => {
19
26
  const { columns: count = 2 } = propsOf(node);
20
27
  const childNodes = node.children;
28
+ const stats = childNodes.length > 0 && childNodes.every(isStat);
21
29
  return /* @__PURE__ */ jsx("div", {
22
- className: cn("grid grid-cols-1 gap-4", columns[count]),
30
+ className: cn("grid grid-cols-1 gap-4", stats ? statColumns[count] : columns[count]),
23
31
  children: children.map((child, index) => {
24
32
  const element = childNodes[index];
25
33
  const span = typeof element === "object" ? element.props.span : void 0;
@@ -2,8 +2,8 @@ import { AppLink } from "../../use-app-address.js";
2
2
  import { useLayoutRecord } from "../record-scope.js";
3
3
  import { propsOf } from "./block-props.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
- import { fillAddress } from "@protobase/schema/internal";
6
5
  import { appPath } from "@protobase/schema";
6
+ import { fillAddress } from "@protobase/schema/internal";
7
7
  //#region src/app/pages/blocks/link.tsx
8
8
  const style = "font-medium text-primary-text hover:underline";
9
9
  /**
@@ -23,7 +23,7 @@ const Count = ({ model, filter }) => {
23
23
  resource: model.name,
24
24
  filter
25
25
  },
26
- className: `${big} hover:text-primary-text`,
26
+ className: `${big} hover:text-primary-text after:absolute after:inset-0 after:content-['']`,
27
27
  children: count.data.toLocaleString("en-IE")
28
28
  });
29
29
  };
@@ -41,20 +41,24 @@ const RecordValue = ({ field, format }) => {
41
41
  })
42
42
  });
43
43
  };
44
- /** One number or value with a label: fixed, a count of matching records (linking to them), or a field of the record. */
44
+ /**
45
+ * One number or value with a label: fixed, a count of matching records (linking to them from anywhere on the card), or
46
+ * a field of the record. A label that wraps pushes nothing out of line: the value sits at the card's bottom.
47
+ */
45
48
  const StatBlock = ({ node }) => {
46
49
  const { label, value, resource, filter, field, format, description } = propsOf(node);
47
50
  const { resources } = useAdminMeta();
48
51
  const model = resource === void 0 ? void 0 : resources[resource];
49
52
  return /* @__PURE__ */ jsxs(CardFrame, {
50
- className: "h-full",
53
+ className: "relative h-full",
54
+ bodyClassName: "flex flex-col",
51
55
  children: [
52
56
  /* @__PURE__ */ jsx("p", {
53
- className: "text-[13px] text-muted-foreground",
57
+ className: "text-[13px] text-muted-foreground break-words",
54
58
  children: label
55
59
  }),
56
60
  /* @__PURE__ */ jsxs("div", {
57
- className: "mt-1",
61
+ className: "mt-auto pt-1",
58
62
  children: [
59
63
  value !== void 0 && /* @__PURE__ */ jsx("span", {
60
64
  className: big,
@@ -77,7 +77,7 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
77
77
  children: text
78
78
  });
79
79
  return /* @__PURE__ */ jsx("span", {
80
- className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums"),
80
+ className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums", hints.multiline && "whitespace-pre-line break-words"),
81
81
  children: text
82
82
  });
83
83
  };
@@ -144,6 +144,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
144
144
  label: hint(name)?.label ?? humanize(name),
145
145
  help: hint(name)?.help,
146
146
  error: editor.errors[name],
147
+ wide: Boolean(hint(name)?.multiline),
147
148
  conflict: conflict === void 0 ? void 0 : {
148
149
  user: {
149
150
  name: "Someone else",
@@ -181,7 +182,10 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
181
182
  value: stored.record[name],
182
183
  hints: hint(name),
183
184
  children: text
184
- }) : /* @__PURE__ */ jsx("strong", { children: text });
185
+ }) : /* @__PURE__ */ jsx("strong", {
186
+ className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
187
+ children: text
188
+ });
185
189
  const shown = shownWidget(fieldTypes, field, hint(name));
186
190
  return [{
187
191
  label: hint(name)?.label ?? humanize(name),
@@ -1,3 +1,4 @@
1
+ import { RouteLink } from "../primitives/route-link.js";
1
2
  import { Button } from "../primitives/button.js";
2
3
  import { Dialog } from "../primitives/dialog.js";
3
4
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@@ -112,15 +113,13 @@ const Router = ({ basePath = "", initialUrl, children }) => {
112
113
  })]
113
114
  });
114
115
  };
115
- const Link = ({ to, onClick, ...rest }) => {
116
+ /** A link to a path below the base path, as `appPath` writes it, that the router follows without reloading the page. */
117
+ const Link = ({ to, replace, ...rest }) => {
116
118
  const { navigate, basePath } = useRouter();
117
- return /* @__PURE__ */ jsx("a", {
118
- href: `${basePath}${to}`,
119
- onClick: (event) => {
120
- onClick?.(event);
121
- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.button !== 0) return;
122
- event.preventDefault();
123
- navigate(to);
119
+ return /* @__PURE__ */ jsx(RouteLink, {
120
+ to: {
121
+ href: `${basePath}${to}`,
122
+ onFollow: () => navigate(to, { replace })
124
123
  },
125
124
  ...rest
126
125
  });
@@ -1,6 +1,6 @@
1
1
  import { recordKey } from "../../live/model-helpers.js";
2
- import { encodeKey } from "@protobase/schema/internal";
3
2
  import { createWhere } from "@protobase/schema";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/saved/saved-records.ts
5
5
  const where = createWhere();
6
6
  const relationValue = (value) => {
@@ -1,7 +1,7 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
- import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
4
3
  import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
4
+ import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
5
5
  //#region src/app/search/search-model.ts
6
6
  const where = createWhere();
7
7
  const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
@@ -20,7 +20,7 @@ const useSaveSearch = (search, filter, name) => {
20
20
  const client = useClient();
21
21
  const toast = useToast();
22
22
  const refresh = useRefreshResource();
23
- const { go } = useAppAddress();
23
+ const { href, open } = useAppAddress();
24
24
  const { resource } = search;
25
25
  const model = meta.resources[resource];
26
26
  const of = meta.views[resource]?.searchesOf;
@@ -44,10 +44,11 @@ const useSaveSearch = (search, filter, name) => {
44
44
  title: "Search saved",
45
45
  action: {
46
46
  label: `Open ${plural.toLowerCase()}`,
47
- onClick: () => go({
47
+ href: href({
48
48
  kind: "list",
49
49
  resource
50
- })
50
+ }),
51
+ onNavigate: open
51
52
  }
52
53
  });
53
54
  },
@@ -27,7 +27,8 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
27
27
  const [linesConflict, setLinesConflict] = useState(false);
28
28
  const [conflicts, setConflicts] = useState({});
29
29
  const [errors, setErrors] = useState({});
30
- const fieldsDirty = Object.keys(draft).length > 0;
30
+ const changed = Object.entries(draft).filter(([name, entry]) => differs(model.fields[name], entry, stored.record[name]));
31
+ const fieldsDirty = changed.length > 0;
31
32
  const dirty = fieldsDirty || Boolean(lines?.dirty);
32
33
  useEffect(() => {
33
34
  if (restored?.order) lines?.setOrder(restored.order);
@@ -52,7 +53,8 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
52
53
  const value = (name) => name in draft ? draft[name] : ["boolean", "file"].includes(model.fields[name].type) ? stored.record[name] : toDraft(stored.record[name]);
53
54
  const change = (name, next) => {
54
55
  setErrors((current) => without(current, name));
55
- setDraft((current) => differs(model.fields[name], next, stored.record[name]) ? {
56
+ const typed = typeof next === "string" ? next !== toDraft(stored.record[name]) : differs(model.fields[name], next, stored.record[name]);
57
+ setDraft((current) => typed ? {
56
58
  ...current,
57
59
  [name]: next
58
60
  } : without(current, name));
@@ -71,7 +73,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
71
73
  setDraft(keep);
72
74
  setConflicts(theirs);
73
75
  };
74
- const patchOf = () => Object.fromEntries(Object.entries(draft).map(([name, entry]) => [name, toPatchValue(model.fields[name], entry)]));
76
+ const patchOf = () => Object.fromEntries(changed.map(([name, entry]) => [name, toPatchValue(model.fields[name], entry)]));
75
77
  const fieldErrors = (error) => isApiError(error) ? Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])) : {};
76
78
  const saveRecordOnly = async () => {
77
79
  try {
@@ -1,3 +1,4 @@
1
+ import "../primitives/route-link.js";
1
2
  import "./bottom-bar.js";
2
3
  import "./nav.js";
3
4
  import "./profile-menu.js";
@@ -2,14 +2,14 @@ import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
3
  import { BottomBar } from "./bottom-bar.js";
4
4
  import { Drawer } from "./drawer.js";
5
- import { PageScrollContext, usePageInsets } from "./page-scroll.js";
5
+ import { PageScrollContext, usePageGutter, usePageInsets } from "./page-scroll.js";
6
6
  import { Sidebar } from "./sidebar.js";
7
7
  import { TopBar } from "./top-bar.js";
8
8
  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, onSignIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
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 }) => {
13
13
  const desktop = useIsDesktop();
14
14
  const [drawerOpen, setDrawerOpen] = useState(false);
15
15
  useEffect(() => {
@@ -21,6 +21,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
21
21
  const topBarRef = useRef(null);
22
22
  const bottomBarRef = useRef(null);
23
23
  usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
24
+ usePageGutter(pageScroll);
24
25
  const sidebar = (mode, sticky = false) => /* @__PURE__ */ jsx(Sidebar, {
25
26
  mode,
26
27
  sticky,
@@ -33,7 +34,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
33
34
  userMenu,
34
35
  onNavigate,
35
36
  onSignOut,
36
- onSignIn,
37
+ signIn,
37
38
  organizations,
38
39
  roles,
39
40
  theme
@@ -16,6 +16,22 @@ const usePageScroll = (on) => {
16
16
  return () => set(void 0);
17
17
  }, [set, on]);
18
18
  };
19
+ /**
20
+ * While the page scrolls, keeps room for its scrollbar whether or not the page overflows, where scrollbars take room
21
+ * (Windows, Linux, or a Mac set to always show them). Otherwise a page whose height follows its width, such as the
22
+ * cards, can be just too tall with the width without a scrollbar and short enough with the width beside one: the
23
+ * scrollbar narrows the cards, they get shorter, the page fits, the scrollbar goes, they widen again, every frame.
24
+ */
25
+ const usePageGutter = (on) => {
26
+ useLayoutEffect(() => {
27
+ if (!on) return;
28
+ const root = document.documentElement;
29
+ root.style.setProperty("scrollbar-gutter", "stable");
30
+ return () => {
31
+ root.style.removeProperty("scrollbar-gutter");
32
+ };
33
+ }, [on]);
34
+ };
19
35
  const properties = [
20
36
  "--pb-sticky-top",
21
37
  "--pb-sticky-bottom",
@@ -54,4 +70,4 @@ const usePageInsets = (on, top, bottom, bottomShown) => {
54
70
  ]);
55
71
  };
56
72
  //#endregion
57
- export { PageScrollContext, usePageInsets, usePageScroll };
73
+ export { PageScrollContext, usePageGutter, usePageInsets, usePageScroll };
@@ -1,2 +1,3 @@
1
+ import "../primitives/route-link.js";
1
2
  import "./nav.js";
2
3
  import "./profile-menu.js";
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { RouteLink } from "../primitives/route-link.js";
2
3
  import { Logo } from "./logo.js";
3
4
  import { navGroups } from "./nav.js";
4
5
  import { ThemeSwitch } from "./theme-switch.js";
@@ -9,7 +10,7 @@ import { SidebarGroup } from "./sidebar-group.js";
9
10
  import { LogIn } from "lucide-react";
10
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
12
  //#region src/app-shell/sidebar.tsx
12
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles, theme, sticky }) => {
13
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky }) => {
13
14
  const expanding = mode === "icon-expand";
14
15
  const expanded = expanding && forceExpanded;
15
16
  const inline = isTextMode(mode) || expanding;
@@ -62,9 +63,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
62
63
  }) : /* @__PURE__ */ jsxs(Fragment, { children: [theme && inline && /* @__PURE__ */ jsx(ThemeSwitch, {
63
64
  theme,
64
65
  className: "flex min-h-11 w-full items-center gap-2.5 whitespace-nowrap p-1.5 text-[13px] text-muted-foreground md:min-h-0"
65
- }), onSignIn && /* @__PURE__ */ jsxs("button", {
66
- type: "button",
67
- onClick: onSignIn,
66
+ }), signIn && /* @__PURE__ */ jsxs(RouteLink, {
67
+ to: signIn,
68
68
  "aria-label": "Sign in",
69
69
  className: "flex min-h-11 w-full items-center gap-2.5 rounded-md p-1.5 text-left text-[13px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground md:min-h-0",
70
70
  children: [/* @__PURE__ */ jsx(LogIn, { className: "size-4 shrink-0" }), inline && /* @__PURE__ */ jsx("span", {
@@ -1,6 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { backTarget } from "./back-target.js";
3
2
  import { Breadcrumb, inApp } from "./breadcrumb.js";
3
+ import { backTarget } from "./back-target.js";
4
4
  import { GlobalSearch } from "./global-search.js";
5
5
  import { useEffect, useRef, useState } from "react";
6
6
  import { ChevronLeft, Menu, Search, X } from "lucide-react";
@@ -25,7 +25,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
25
25
  }, [search]);
26
26
  return /* @__PURE__ */ jsxs("header", {
27
27
  ref,
28
- className: cn(sticky ? "sticky top-0 z-30" : "relative", "flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5"),
28
+ className: cn(sticky ? "sticky top-0 z-30" : "relative", "flex h-12 shrink-0 items-center gap-1 border-b bg-background pl-4 pr-2 md:gap-4 md:px-5"),
29
29
  children: [
30
30
  onMenu && /* @__PURE__ */ jsx("button", {
31
31
  type: "button",
@@ -33,7 +33,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
33
33
  "aria-expanded": Boolean(menuOpen),
34
34
  "aria-controls": menuControls,
35
35
  onClick: onMenu,
36
- className: cn(iconButton, "-ml-0.5"),
36
+ className: cn(iconButton, "-ml-3"),
37
37
  children: /* @__PURE__ */ jsx(Menu, { className: "size-5" })
38
38
  }),
39
39
  back && /* @__PURE__ */ jsx("a", {
@@ -44,7 +44,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
44
44
  event.preventDefault();
45
45
  onNavigate(back.href);
46
46
  },
47
- className: cn(iconButton, !onMenu && "-ml-0.5"),
47
+ className: cn(iconButton, !onMenu && "-ml-3"),
48
48
  children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-5" })
49
49
  }),
50
50
  /* @__PURE__ */ jsx(Breadcrumb, {
@@ -11,7 +11,6 @@ import { ForgotPasswordPage } from "./forgot-password-page.js";
11
11
  import { ProviderSignInPage } from "./provider-sign-in-page.js";
12
12
  import { ResetPasswordPage } from "./reset-password-page.js";
13
13
  import { SetupRequiredPage } from "./setup-required-page.js";
14
- import { signInPath } from "./sign-in-address.js";
15
14
  import { SignInPage } from "./sign-in-page.js";
16
15
  import { SignUpPage } from "./sign-up-page.js";
17
16
  import { readStaffLink, withoutStaffLink } from "./staff-link.js";
@@ -22,6 +21,7 @@ import { readInvitationLink, withoutInvitationLink } from "../organizations/invi
22
21
  import { InvitationPage } from "../organizations/invitation-page.js";
23
22
  import { useCallback, useEffect, useState } from "react";
24
23
  import { jsx, jsxs } from "react/jsx-runtime";
24
+ import { signInPath } from "@protobase/schema";
25
25
  //#region src/auth/auth-gate.tsx
26
26
  const firstPage = () => {
27
27
  const href = window.location.href;
@@ -67,10 +67,11 @@ const Redirect = ({ to }) => {
67
67
  const AuthGate = ({ workspace, children }) => {
68
68
  const { state, recheck, passwordReset, signOut, signInProviders, canSignUp, browse } = useAuth();
69
69
  const { path, params, basePath, navigate } = useRouter();
70
- const { address, next, open, leave } = useSignInAddress();
70
+ const { address, next, open, link, leave } = useSignInAddress();
71
71
  const [page, setPage] = useState(firstPage);
72
72
  const [codeEmail, setCodeEmail] = useState();
73
73
  const [signUpEmail, setSignUpEmail] = useState();
74
+ const [signUpTicket, setSignUpTicket] = useState();
74
75
  const href = new URL(`${basePath}${path}${params.size > 0 ? `?${params}` : ""}`, window.location.origin).href;
75
76
  const forget = useCallback((without) => {
76
77
  const below = without(href).slice(basePath.length);
@@ -87,6 +88,7 @@ const AuthGate = ({ workspace, children }) => {
87
88
  if (page.kind === "provider-sign-in" || page.kind === "sign-in" && (page.notice || page.error || page.providerError)) setPage({ kind: "sign-in" });
88
89
  setCodeEmail(void 0);
89
90
  setSignUpEmail(void 0);
91
+ setSignUpTicket(void 0);
90
92
  }, [
91
93
  entered,
92
94
  page,
@@ -161,7 +163,7 @@ const AuthGate = ({ workspace, children }) => {
161
163
  forget(withoutInvitationLink);
162
164
  setPage({ kind: "sign-in" });
163
165
  },
164
- onSignIn: () => open()
166
+ signIn: link()
165
167
  });
166
168
  if (state.kind === "signed-in") return children;
167
169
  if (state.kind === "two-factor") return /* @__PURE__ */ jsx(TwoFactorPage, {
@@ -184,25 +186,32 @@ const AuthGate = ({ workspace, children }) => {
184
186
  "forgot-password": passwordReset,
185
187
  "email-code": codeEmail !== void 0,
186
188
  "sign-up": canSignUp,
187
- "sign-up-code": canSignUp && signUpEmail !== void 0
189
+ "sign-up-code": canSignUp && signUpEmail !== void 0,
190
+ "sign-up-details": canSignUp && signUpTicket !== void 0
188
191
  }[address.step] ? address.step : "sign-in";
189
192
  const backTo = (target) => () => open(target, { replace: true });
190
193
  const onBrowse = browse && (() => {
191
194
  browse();
192
195
  leave();
193
196
  });
194
- if (step === "forgot-password") return /* @__PURE__ */ jsx(ForgotPasswordPage, { onBack: backTo("sign-in") });
197
+ if (step === "forgot-password") return /* @__PURE__ */ jsx(ForgotPasswordPage, { back: link("sign-in", { replace: true }) });
195
198
  if (step === "email-code") return /* @__PURE__ */ jsx(EmailCodePage, {
196
199
  email: codeEmail,
197
200
  onBack: backTo("sign-in")
198
201
  });
199
- if (step === "sign-up" || step === "sign-up-code") return /* @__PURE__ */ jsx(SignUpPage, {
202
+ if (step === "sign-up" || step === "sign-up-code" || step === "sign-up-details") return /* @__PURE__ */ jsx(SignUpPage, {
200
203
  workspace,
201
- email: step === "sign-up-code" ? signUpEmail : void 0,
204
+ email: step === "sign-up" ? void 0 : signUpEmail,
205
+ ticket: step === "sign-up-details" ? signUpTicket : void 0,
202
206
  onCodeSent: (email) => {
203
207
  setSignUpEmail(email);
208
+ setSignUpTicket(void 0);
204
209
  open("sign-up-code");
205
210
  },
211
+ onVerified: (ticket) => {
212
+ setSignUpTicket(ticket);
213
+ open("sign-up-details", { replace: true });
214
+ },
206
215
  onBack: backTo("sign-up"),
207
216
  onSignIn: () => open("sign-in"),
208
217
  onBrowse
@@ -213,12 +222,12 @@ const AuthGate = ({ workspace, children }) => {
213
222
  workspace,
214
223
  notice: signIn.notice,
215
224
  error,
216
- onForgotPassword: passwordReset ? () => open("forgot-password") : void 0,
225
+ forgotPassword: passwordReset ? link("forgot-password") : void 0,
217
226
  onCodeSent: (email) => {
218
227
  setCodeEmail(email);
219
228
  open("email-code");
220
229
  },
221
- onSignUp: canSignUp ? () => open("sign-up") : void 0,
230
+ signUp: canSignUp ? link("sign-up") : void 0,
222
231
  onBrowse
223
232
  }, error);
224
233
  };
@@ -86,6 +86,7 @@ const AuthProvider = ({ session: given, children }) => {
86
86
  sendSignInCode: (email) => session.sendSignInCode(email),
87
87
  signInWithCode: async (email, code) => finish(await session.signInWithCode(email, code)),
88
88
  sendSignUpCode: (email) => session.sendSignUpCode(email),
89
+ verifySignUpCode: (email, code) => session.verifySignUpCode(email, code),
89
90
  signUp: async (input) => enter(await session.signUp(input)),
90
91
  signInWithPasskey: async (domain) => enter(await session.signInWithPasskey(domain)),
91
92
  verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),