@protobase/ui 0.15.5 → 0.16.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 (113) 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/current-address.d.ts +5 -1
  6. package/dist/app/current-address.js +2 -1
  7. package/dist/app/displays/map-display.js +50 -34
  8. package/dist/app/displays/map-groups.js +12 -3
  9. package/dist/app/displays/measured-heights.js +23 -0
  10. package/dist/app/displays/record-card.js +29 -17
  11. package/dist/app/displays/virtual-cards.js +12 -14
  12. package/dist/app/embedded-list/embedded-display.js +7 -3
  13. package/dist/app/embedded-list/embedded-list.js +5 -4
  14. package/dist/app/field-editor.js +9 -1
  15. package/dist/app/list-page.js +5 -3
  16. package/dist/app/map/approximate-position.js +76 -0
  17. package/dist/app/map/area-features.js +80 -0
  18. package/dist/app/map/maplibre.js +11 -0
  19. package/dist/app/map/position-areas.js +78 -0
  20. package/dist/app/map/record-map.js +2 -7
  21. package/dist/app/map/record-position.js +5 -4
  22. package/dist/app/map/records-map.js +24 -7
  23. package/dist/app/map/theme-color.js +23 -0
  24. package/dist/app/nav-recent.js +1 -1
  25. package/dist/app/pages/blocks/card-frame.js +2 -2
  26. package/dist/app/pages/blocks/grid.js +10 -2
  27. package/dist/app/pages/blocks/link.js +1 -1
  28. package/dist/app/pages/blocks/record-row.js +51 -0
  29. package/dist/app/pages/blocks/stat.js +9 -5
  30. package/dist/app/pages/field-value.js +1 -1
  31. package/dist/app/pages/render-node.js +2 -0
  32. package/dist/app/record-page.js +5 -1
  33. package/dist/app/record-row/record-row-body.js +114 -0
  34. package/dist/app/record-row/record-row.js +93 -0
  35. package/dist/app/record-row/row-window.js +20 -0
  36. package/dist/app/record-row/saved-record-row.js +42 -0
  37. package/dist/app/record-row/use-row-scroll.js +51 -0
  38. package/dist/app/router.js +7 -8
  39. package/dist/app/saved/saved-records.js +1 -1
  40. package/dist/app/search/search-model.js +1 -1
  41. package/dist/app/searches/filter-chip-list.js +28 -0
  42. package/dist/app/searches/save-search-button.js +72 -8
  43. package/dist/app/searches/saved-search-rows.js +73 -0
  44. package/dist/app/searches/saved-search-section.js +91 -73
  45. package/dist/app/searches/saved-search.js +7 -3
  46. package/dist/app/searches/saved-searches.js +3 -5
  47. package/dist/app/searches/search-controls.js +13 -2
  48. package/dist/app/searches/search-diff.js +97 -0
  49. package/dist/app/searches/search-name-dialog.js +62 -0
  50. package/dist/app/searches/update-search-dialog.js +196 -0
  51. package/dist/app/searches/use-opened-search.js +15 -0
  52. package/dist/app/searches/use-save-search.js +68 -21
  53. package/dist/app/searches/use-search-row.js +56 -0
  54. package/dist/app/use-record-editor.js +5 -3
  55. package/dist/app-shell/app-shell.d.ts +1 -0
  56. package/dist/app-shell/app-shell.js +4 -3
  57. package/dist/app-shell/page-scroll.js +17 -1
  58. package/dist/app-shell/sidebar.d.ts +1 -0
  59. package/dist/app-shell/sidebar.js +4 -4
  60. package/dist/app-shell/top-bar.js +4 -4
  61. package/dist/auth/auth-gate.js +18 -9
  62. package/dist/auth/auth-provider.js +1 -0
  63. package/dist/auth/code-form.js +77 -0
  64. package/dist/auth/email-code-form.js +18 -77
  65. package/dist/auth/forgot-password-form.js +10 -10
  66. package/dist/auth/forgot-password-page.js +2 -2
  67. package/dist/auth/resend-code-button.js +29 -0
  68. package/dist/auth/sign-in-form.js +7 -8
  69. package/dist/auth/sign-in-page.js +3 -3
  70. package/dist/auth/sign-in-prompt.js +13 -9
  71. package/dist/auth/sign-up-code-form.js +22 -0
  72. package/dist/auth/sign-up-details-form.js +81 -111
  73. package/dist/auth/sign-up-page.js +17 -8
  74. package/dist/auth/two-factor-form.js +3 -3
  75. package/dist/auth/use-sign-in-address.js +21 -8
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.js +2 -1
  78. package/dist/lib/flip-guard.js +43 -0
  79. package/dist/lib/page-scroll-lock.js +20 -0
  80. package/dist/lib/use-focus-trap.js +4 -4
  81. package/dist/organizations/invitation-page.js +7 -6
  82. package/dist/package.js +1 -1
  83. package/dist/primitives/dialog.js +3 -2
  84. package/dist/primitives/otp-input.d.ts +21 -0
  85. package/dist/primitives/otp-input.js +117 -0
  86. package/dist/primitives/otp-value.js +23 -0
  87. package/dist/primitives/responsive-dialog.d.ts +3 -1
  88. package/dist/primitives/responsive-dialog.js +2 -1
  89. package/dist/primitives/route-link.d.ts +1 -0
  90. package/dist/primitives/route-link.js +20 -0
  91. package/dist/primitives/textarea.js +36 -0
  92. package/dist/record-view/field.js +56 -53
  93. package/dist/shell/assets/app-sDEk9LFu.js +66 -0
  94. package/dist/shell/assets/index-D0AzKE47.css +2 -0
  95. package/dist/shell/assets/{index-DxmdRnr8.js → index-L7nXyA_7.js} +1 -1
  96. package/dist/shell/assets/maplibre-AOh9A0NP.js +814 -0
  97. package/dist/shell/assets/record-map-DbwHSPf3.js +1 -0
  98. package/dist/shell/assets/records-map-2U_CQ4RG.js +1 -0
  99. package/dist/shell/assets/shared-protobase-ui-BMcStVNP.js +1 -0
  100. package/dist/shell/index.html +4 -4
  101. package/dist/shell/protobase.shell.json +1 -1
  102. package/dist/styles.css +15 -0
  103. package/dist/toasts/toast.js +22 -6
  104. package/package.json +4 -4
  105. package/dist/auth/code-input.js +0 -17
  106. package/dist/auth/sign-in-address.js +0 -47
  107. package/dist/shell/assets/app-BdEV1_xI.js +0 -66
  108. package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
  109. package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
  110. package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
  111. package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
  112. package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
  113. /package/dist/shell/assets/{record-map-DBE96hFo.css → maplibre-DBE96hFo.css} +0 -0
@@ -3,7 +3,7 @@ import { Input } from "../primitives/input.js";
3
3
  import { authMessage } from "../auth/auth-messages.js";
4
4
  import { Button } from "../primitives/button.js";
5
5
  import { BackupCodes } from "./backup-codes.js";
6
- import { CodeInput } from "../auth/code-input.js";
6
+ import { OtpInput } from "../primitives/otp-input.js";
7
7
  import { TotpQr, totpKey } from "./totp-qr.js";
8
8
  import { useState } from "react";
9
9
  import { AuthError } from "@protobase/client";
@@ -49,8 +49,8 @@ const TwoFactorSetup = ({ offerEmailedCodes, needsPassword, onStartApp, onConfir
49
49
  setup: await onStartApp(given)
50
50
  });
51
51
  });
52
- const confirm = (setup) => attempt(async () => {
53
- await onConfirmApp(code.trim());
52
+ const confirm = (setup, entered = code) => attempt(async () => {
53
+ await onConfirmApp(entered);
54
54
  setStep({
55
55
  kind: "backup-codes",
56
56
  codes: setup.backupCodes
@@ -103,13 +103,14 @@ const TwoFactorSetup = ({ offerEmailedCodes, needsPassword, onStartApp, onConfir
103
103
  }),
104
104
  /* @__PURE__ */ jsxs("label", {
105
105
  className: "flex flex-col gap-1.5 text-[13px] font-medium",
106
- children: ["Code from the app", /* @__PURE__ */ jsx(CodeInput, {
106
+ children: ["Code from the app", /* @__PURE__ */ jsx(OtpInput, {
107
107
  name: "code",
108
108
  required: true,
109
109
  autoFocus: true,
110
110
  value: code,
111
111
  invalid: Boolean(error),
112
- onChange: (event) => setCode(event.target.value)
112
+ onChange: setCode,
113
+ onComplete: (complete) => !busy && void confirm(step.setup, complete)
113
114
  })]
114
115
  }),
115
116
  errorLine,
package/dist/app/app.js CHANGED
@@ -175,7 +175,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
175
175
  breadcrumb,
176
176
  user: acting && userToShell(acting),
177
177
  onSignOut: user && (() => void signOut()),
178
- onSignIn: user ? void 0 : () => signIn.open(),
178
+ signIn: user ? void 0 : signIn.link(),
179
179
  workspace: current?.organization?.name ?? workspace,
180
180
  navGroups: groups,
181
181
  bottomBar: bottomBarFromMeta(meta, basePath, place),
@@ -117,6 +117,7 @@ const LoadedCreate = ({ resource, values }) => {
117
117
  help: view?.fields[name]?.help,
118
118
  required: isRequired(field),
119
119
  error: errors[name],
120
+ wide: Boolean(view?.fields[name]?.multiline),
120
121
  editor: /* @__PURE__ */ jsx(FieldEditor, {
121
122
  field,
122
123
  hints: view?.fields[name],
@@ -56,6 +56,7 @@ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmi
56
56
  required: field.required,
57
57
  help: field.help,
58
58
  error: field.error,
59
+ wide: field.wide,
59
60
  children: field.editor
60
61
  }, field.name))
61
62
  })
@@ -1,6 +1,9 @@
1
1
  import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
2
2
  //#region src/app/current-address.d.ts
3
- /** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
3
+ /**
4
+ * A list as the app shows it: every value there, the view's default where the address names none; `map` and
5
+ * `savedSearch`, the saved search it was opened from, have none.
6
+ */
4
7
  export type ListAddress = {
5
8
  kind: 'list';
6
9
  resource: string;
@@ -9,6 +12,7 @@ export type ListAddress = {
9
12
  orderBy: string;
10
13
  filterLayout: FilterLayout;
11
14
  map?: MapView;
15
+ savedSearch?: string;
12
16
  };
13
17
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
14
18
  export type CurrentAddress = ListAddress | {
@@ -54,7 +54,8 @@ const changedList = (read, change, defaults) => {
54
54
  ...differs("display") && { display: next.display },
55
55
  ...differs("orderBy") && { orderBy: next.orderBy },
56
56
  ...differs("filterLayout") && { filterLayout: next.filterLayout },
57
- ...next.map && { map: next.map }
57
+ ...next.map && { map: next.map },
58
+ ...next.savedSearch && { savedSearch: next.savedSearch }
58
59
  };
59
60
  };
60
61
  //#endregion
@@ -11,7 +11,7 @@ import { boundsAround, worldArea } from "../map/map-area.js";
11
11
  import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
12
12
  import { useMapRecords } from "./use-map-records.js";
13
13
  import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
14
- import { ChevronLeft, ChevronRight, X } from "lucide-react";
14
+ import { ChevronLeft, ChevronRight, CircleDashed, X } from "lucide-react";
15
15
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
16
16
  //#region src/app/displays/map-display.tsx
17
17
  const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
@@ -22,7 +22,7 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
22
22
  * in view or the map is close, else a count for each part of it, which zooms in when chosen. The map asks again as it
23
23
  * pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
24
24
  * its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
25
- * elsewhere clears it.
25
+ * elsewhere clears it. A marker and a card whose record's position is approximate say so.
26
26
  */
27
27
  const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true, flush = false }) => {
28
28
  const style = useAdminMeta().maps?.style;
@@ -48,7 +48,13 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
48
48
  const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
49
49
  const selection = keptSelection(chosen, rows);
50
50
  const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
51
- const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
51
+ const approximate = new Set(points.filter((point) => point.approximate).map((point) => point.key));
52
+ const markerLabel = (keys) => {
53
+ const near = keys.filter((key) => approximate.has(key)).length;
54
+ if (keys.length === 1) return near === 1 ? `Show the ${singular}, at an approximate position` : `Show the ${singular}`;
55
+ if (near === 0) return `Show ${keys.length} ${plural}`;
56
+ return near === keys.length ? `Show ${keys.length} ${plural}, at approximate positions` : `Show ${keys.length} ${plural}, ${near} at approximate positions`;
57
+ };
52
58
  const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
53
59
  const overlay = useRef(null);
54
60
  const [covered, setCovered] = useState(0);
@@ -112,46 +118,56 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
112
118
  "aria-label": `Chosen ${singular}`,
113
119
  children: [/* @__PURE__ */ jsxs("div", {
114
120
  className: "flex items-center justify-end gap-1.5",
115
- children: [selection.keys.length > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
116
- /* @__PURE__ */ jsx("button", {
117
- type: "button",
118
- "aria-label": "Previous",
119
- className: arrow,
120
- onClick: () => setChosen(stepSelection(selection, -1)),
121
- children: /* @__PURE__ */ jsx(ChevronLeft, {
122
- className: "size-4",
121
+ children: [
122
+ approximate.has(row.key) && /* @__PURE__ */ jsxs("span", {
123
+ className: "mr-auto flex items-center gap-1 rounded-full border border-border bg-background px-2 py-1 text-xs",
124
+ children: [/* @__PURE__ */ jsx(CircleDashed, {
125
+ className: "size-3.5",
123
126
  "aria-hidden": true
124
- })
125
- }),
126
- /* @__PURE__ */ jsxs("span", {
127
- className: "rounded-full border border-border bg-background px-2 py-1 text-xs tabular-nums",
128
- "aria-live": "polite",
129
- children: [
130
- selection.index + 1,
131
- " / ",
132
- selection.keys.length
133
- ]
127
+ }), "Approximate position"]
134
128
  }),
129
+ selection.keys.length > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
130
+ /* @__PURE__ */ jsx("button", {
131
+ type: "button",
132
+ "aria-label": "Previous",
133
+ className: arrow,
134
+ onClick: () => setChosen(stepSelection(selection, -1)),
135
+ children: /* @__PURE__ */ jsx(ChevronLeft, {
136
+ className: "size-4",
137
+ "aria-hidden": true
138
+ })
139
+ }),
140
+ /* @__PURE__ */ jsxs("span", {
141
+ className: "rounded-full border border-border bg-background px-2 py-1 text-xs tabular-nums",
142
+ "aria-live": "polite",
143
+ children: [
144
+ selection.index + 1,
145
+ " / ",
146
+ selection.keys.length
147
+ ]
148
+ }),
149
+ /* @__PURE__ */ jsx("button", {
150
+ type: "button",
151
+ "aria-label": "Next",
152
+ className: arrow,
153
+ onClick: () => setChosen(stepSelection(selection, 1)),
154
+ children: /* @__PURE__ */ jsx(ChevronRight, {
155
+ className: "size-4",
156
+ "aria-hidden": true
157
+ })
158
+ })
159
+ ] }),
135
160
  /* @__PURE__ */ jsx("button", {
136
161
  type: "button",
137
- "aria-label": "Next",
162
+ "aria-label": "Close",
138
163
  className: arrow,
139
- onClick: () => setChosen(stepSelection(selection, 1)),
140
- children: /* @__PURE__ */ jsx(ChevronRight, {
164
+ onClick: () => setChosen(void 0),
165
+ children: /* @__PURE__ */ jsx(X, {
141
166
  className: "size-4",
142
167
  "aria-hidden": true
143
168
  })
144
169
  })
145
- ] }), /* @__PURE__ */ jsx("button", {
146
- type: "button",
147
- "aria-label": "Close",
148
- className: arrow,
149
- onClick: () => setChosen(void 0),
150
- children: /* @__PURE__ */ jsx(X, {
151
- className: "size-4",
152
- "aria-hidden": true
153
- })
154
- })]
170
+ ]
155
171
  }), /* @__PURE__ */ jsx("div", {
156
172
  className: "shadow-lg",
157
173
  children: /* @__PURE__ */ jsx(RecordCard, {
@@ -1,12 +1,21 @@
1
1
  import { recordPosition } from "../map/record-position.js";
2
+ import { isApproximate, positionArea } from "../map/approximate-position.js";
2
3
  //#region src/app/displays/map-groups.ts
3
- /** The rows with a position, in their order; a row without one is not on the map. */
4
+ /** The rows with a position, in their order, approximate ones marked with their area; a row without one is not on the map. */
4
5
  const mapPoints = (rows, fields) => rows.flatMap((row) => {
5
6
  const position = recordPosition(row.record, fields);
6
- return position ? [{
7
+ if (!position) return [];
8
+ if (!isApproximate(row.record, fields)) return [{
7
9
  key: row.key,
8
10
  ...position
9
- }] : [];
11
+ }];
12
+ const area = positionArea(row.record, fields, position);
13
+ return [{
14
+ key: row.key,
15
+ ...position,
16
+ approximate: true,
17
+ ...area && { area }
18
+ }];
10
19
  });
11
20
  /**
12
21
  * The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
@@ -0,0 +1,23 @@
1
+ //#region src/app/displays/measured-heights.ts
2
+ /** How many card widths the list remembers a measured height for. */
3
+ const remembered = 8;
4
+ /**
5
+ * `heights` with a card `height` tall measured at `width`, kept at the taller of it and the height measured there
6
+ * before, and `heights` itself when that changes nothing. A width the list comes back to, as with and without a
7
+ * scrollbar, keeps its height instead of falling back to a guess: a guess shorter than the cards shrinks the list, which
8
+ * can take the scrollbar away, widen the cards back, and measure them taller again, every frame.
9
+ */
10
+ const withMeasuredHeight = (heights, width, height) => {
11
+ const current = heights.get(width);
12
+ if (current !== void 0 && current >= height) return heights;
13
+ const next = new Map(heights);
14
+ next.delete(width);
15
+ next.set(width, height);
16
+ for (const oldest of next.keys()) {
17
+ if (next.size <= remembered) break;
18
+ next.delete(oldest);
19
+ }
20
+ return next;
21
+ };
22
+ //#endregion
23
+ export { withMeasuredHeight };
@@ -32,7 +32,7 @@ const MetaValue = ({ icon, children }) => {
32
32
  * value, joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it
33
33
  * is missing or throws. With a favorite button, a heart in the top right corner saves the record.
34
34
  */
35
- const RecordCard = ({ model, view, display, row, swipe = true }) => {
35
+ const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
36
36
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
37
37
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
38
38
  const text = (name) => {
@@ -99,7 +99,7 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
99
99
  })]
100
100
  }),
101
101
  subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
102
- className: "truncate text-muted-foreground",
102
+ className: view?.fields[subtitle]?.multiline ? "line-clamp-3 whitespace-pre-line break-words text-muted-foreground" : "truncate text-muted-foreground",
103
103
  children: shownValue(subtitle)
104
104
  }),
105
105
  shownAmount !== void 0 && /* @__PURE__ */ jsx("span", {
@@ -135,32 +135,44 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
135
135
  }), shownValue(name)] }, name))
136
136
  })
137
137
  ] });
138
- const card = (picture) => /* @__PURE__ */ jsx(AppLink, {
138
+ const card = (picture) => /* @__PURE__ */ jsxs(AppLink, {
139
139
  to: {
140
140
  kind: "record",
141
141
  resource: model.name,
142
142
  key: row.key
143
143
  },
144
144
  className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
145
- children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
145
+ children: [mark && !pictured && /* @__PURE__ */ jsx(Badge, {
146
+ tone: "blue",
147
+ className: "self-start",
148
+ children: mark
149
+ }), Card ? /* @__PURE__ */ jsx(CustomBoundary, {
146
150
  name: display.card,
147
151
  fallback: builtIn(picture),
148
152
  children: /* @__PURE__ */ jsx(Card, { row })
149
- }) : builtIn(picture)
153
+ }) : builtIn(picture)]
150
154
  });
151
155
  return /* @__PURE__ */ jsxs("div", {
152
- className: photos ? "group/card relative h-full" : favorite ? "relative h-full" : "h-full",
153
- children: [photos ? /* @__PURE__ */ jsx(CardCarousel, {
154
- photos: row.photos ?? [],
155
- cover,
156
- swipe,
157
- children: card
158
- }) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })), favorite && /* @__PURE__ */ jsx(FavoriteButton, {
159
- favorite,
160
- recordKey: row.key,
161
- target: recordKey(model, row.record),
162
- className: pictured ? void 0 : "right-1.5 top-1.5"
163
- })]
156
+ className: photos ? "group/card relative h-full" : favorite || mark ? "relative h-full" : "h-full",
157
+ children: [
158
+ photos ? /* @__PURE__ */ jsx(CardCarousel, {
159
+ photos: row.photos ?? [],
160
+ cover,
161
+ swipe,
162
+ children: card
163
+ }, row.key) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })),
164
+ mark && pictured && /* @__PURE__ */ jsx(Badge, {
165
+ tone: "blue",
166
+ className: "pointer-events-none absolute top-2 left-2 shadow-sm",
167
+ children: mark
168
+ }),
169
+ favorite && /* @__PURE__ */ jsx(FavoriteButton, {
170
+ favorite,
171
+ recordKey: row.key,
172
+ target: recordKey(model, row.record),
173
+ className: pictured ? void 0 : "right-1.5 top-1.5"
174
+ })
175
+ ]
164
176
  });
165
177
  };
166
178
  //#endregion
@@ -1,7 +1,9 @@
1
1
  import { Skeleton } from "../../primitives/skeleton.js";
2
2
  import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
3
  import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
+ import { createFlipGuard } from "../../lib/flip-guard.js";
4
5
  import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
6
+ import { withMeasuredHeight } from "./measured-heights.js";
5
7
  import { useCallback, useLayoutEffect, useRef, useState } from "react";
6
8
  import { jsx } from "react/jsx-runtime";
7
9
  //#region src/app/displays/virtual-cards.tsx
@@ -9,7 +11,7 @@ import { jsx } from "react/jsx-runtime";
9
11
  * Cards in rows as wide as the list, scrolled like the table by the page or the nearest scrolling ancestor: the list is
10
12
  * as tall as all its rows, only the rows in view are drawn, and the records are loaded in the table's pages, so a
11
13
  * change of width keeps the loaded pages and the first record in view. Every card in a width is as tall as the tallest
12
- * drawn so far.
14
+ * drawn so far at that width.
13
15
  */
14
16
  const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
15
17
  const { total } = source;
@@ -17,11 +19,8 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
17
19
  const [width, setWidth] = useState(0);
18
20
  const columns = cardColumns(width);
19
21
  const itemWidth = cardWidth(width, columns);
20
- const [measured, setMeasured] = useState({
21
- width: 0,
22
- height: 0
23
- });
24
- const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
22
+ const [measured, setMeasured] = useState(() => /* @__PURE__ */ new Map());
23
+ const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.get(itemWidth) ?? 0));
25
24
  const rowHeight = itemHeight + 12;
26
25
  const layout = {
27
26
  columns,
@@ -33,7 +32,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
33
32
  });
34
33
  const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
35
34
  const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
36
- const ready = itemWidth > 0 && measured.width === itemWidth;
35
+ const ready = itemWidth > 0 && measured.has(itemWidth);
37
36
  const scroll = useVirtualRows({
38
37
  total: Math.ceil(total / columns),
39
38
  rowHeight,
@@ -56,23 +55,22 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
56
55
  loadRows: source.loadRows,
57
56
  latencyMs: 0
58
57
  }, range.start, range.end);
58
+ const [guard] = useState(() => createFlipGuard());
59
59
  useLayoutEffect(() => {
60
60
  const body = scroll.ref.current;
61
61
  if (!body) return;
62
- const observer = new ResizeObserver(() => setWidth(body.clientWidth));
62
+ const update = () => setWidth(guard(body.clientWidth, performance.now()));
63
+ const observer = new ResizeObserver(update);
63
64
  observer.observe(body);
64
- setWidth(body.clientWidth);
65
+ update();
65
66
  return () => observer.disconnect();
66
- }, []);
67
+ }, [guard]);
67
68
  const widthRef = useRef(itemWidth);
68
69
  widthRef.current = itemWidth;
69
70
  const [observer] = useState(() => new ResizeObserver((entries) => {
70
71
  const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
71
72
  const at = widthRef.current;
72
- setMeasured((current) => current.width === at && current.height >= tallest ? current : {
73
- width: at,
74
- height: Math.max(tallest, current.width === at ? current.height : 0)
75
- });
73
+ setMeasured((current) => withMeasuredHeight(current, at, tallest));
76
74
  }));
77
75
  useLayoutEffect(() => () => observer.disconnect(), [observer]);
78
76
  const observe = useCallback((item) => {
@@ -17,10 +17,14 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
17
17
  if (picked !== void 0 && names.includes(picked)) return picked;
18
18
  return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
19
  };
20
- /** The resource's list with the embedded list's filter, order and display, leaving out what the list does by default. */
21
- const viewAllAddress = (model, view, xs, list) => changedList({
20
+ /**
21
+ * The resource's list with the embedded list's filter, order and display, leaving out what the list does by default, and
22
+ * the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
23
+ * favorites that list a user's saved listings.
24
+ */
25
+ const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
22
26
  kind: "list",
23
- resource: model.name
27
+ resource
24
28
  }, list, listDefaults(model, view, xs));
25
29
  //#endregion
26
30
  export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -36,7 +36,7 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
36
36
  * The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
37
37
  * to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
38
38
  */
39
- const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll }) => {
39
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true }) => {
40
40
  const { resources, views } = useAdminMeta();
41
41
  const { fields: fieldTypes } = useProjectUi();
42
42
  const { go } = useAppAddress();
@@ -74,11 +74,12 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
74
74
  }, [loaded]);
75
75
  const shown = shownDisplay(view, display);
76
76
  const plural = view?.names?.plural ?? humanize(model.name);
77
- const allLink = /* @__PURE__ */ jsxs(AppLink, {
77
+ const allLink = link && /* @__PURE__ */ jsxs(AppLink, {
78
78
  to: viewAllAddress(model, view, xs, viewAll ? {
79
79
  filter: viewAll.filter,
80
80
  orderBy: defaultOrderBy(model, view),
81
- display
81
+ display,
82
+ savedSearch: viewAll.savedSearch
82
83
  } : {
83
84
  filter,
84
85
  orderBy,
@@ -104,7 +105,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
104
105
  className: "text-[13px] text-muted-foreground",
105
106
  children: empty ?? "Nothing here yet."
106
107
  });
107
- return viewAll ? /* @__PURE__ */ jsxs("div", {
108
+ return viewAll && link ? /* @__PURE__ */ jsxs("div", {
108
109
  className: "flex flex-col gap-3",
109
110
  children: [nothing, allLink]
110
111
  }) : nothing;
@@ -1,6 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Input } from "../primitives/input.js";
3
3
  import { Switch } from "../primitives/switch.js";
4
+ import { Textarea } from "../primitives/textarea.js";
4
5
  import { displayValue } from "../live/display-value.js";
5
6
  import { AppLink } from "./use-app-address.js";
6
7
  import { RelationPicker } from "./relation-picker.js";
@@ -68,8 +69,9 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
68
69
  }) : label
69
70
  });
70
71
  }
72
+ const multiline = Boolean(hints?.multiline) && field.type === "text";
71
73
  if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
72
- className: cn("py-1.5 text-[13px] tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground"),
74
+ className: cn("py-1.5 text-[13px] tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
73
75
  children: displayValue(field, stored, hints)
74
76
  });
75
77
  if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
@@ -84,6 +86,12 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
84
86
  children: enumLabel(option, hints?.valueLabels)
85
87
  }, option))]
86
88
  });
89
+ if (multiline) return /* @__PURE__ */ jsx(Textarea, {
90
+ invalid,
91
+ placeholder,
92
+ value: String(value),
93
+ onChange: (event) => onChange(event.target.value)
94
+ });
87
95
  return /* @__PURE__ */ jsx(Input, {
88
96
  invalid,
89
97
  leading: hints?.prefix ? /* @__PURE__ */ jsx("span", {
@@ -39,14 +39,14 @@ import { ListChart } from "./list-chart.js";
39
39
  import { ListSearch } from "./list-search.js";
40
40
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
41
41
  import { SavedList } from "./saved/saved-list.js";
42
- import { facetNames, filterChips, searchName } from "./searches/saved-search.js";
42
+ import { facetNames, filterChips } from "./searches/saved-search.js";
43
43
  import { SaveSearchButton } from "./searches/save-search-button.js";
44
44
  import { SavedSearches } from "./searches/saved-searches.js";
45
45
  import { useMemo, useState } from "react";
46
46
  import { Plus, SlidersHorizontal } from "lucide-react";
47
47
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
48
- import { tableDisplay } from "@protobase/schema/internal";
49
48
  import { createWhere } from "@protobase/schema";
49
+ import { tableDisplay } from "@protobase/schema/internal";
50
50
  //#region src/app/list-page.tsx
51
51
  const where = createWhere();
52
52
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -264,9 +264,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
264
264
  ]
265
265
  });
266
266
  const saveSearch = view?.saveSearch && !scope && parsed.ok && filterText && /* @__PURE__ */ jsx(SaveSearchButton, {
267
+ model,
268
+ view,
267
269
  search: view.saveSearch,
268
270
  filter: filterText,
269
- name: searchName(filterChips(model, view, filterText, facetNames(config)))
271
+ chips: filterChips(model, view, filterText, facetNames(config))
270
272
  });
271
273
  const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
272
274
  view,
@@ -0,0 +1,76 @@
1
+ import { numeric } from "./record-position.js";
2
+ //#region src/app/map/approximate-position.ts
3
+ const earthRadius = 6371008.8;
4
+ const circleCorners = 64;
5
+ /** Whether the record's position is approximate, as its field states; without one, or a value that says neither, it is not. */
6
+ const isApproximate = (record, fields) => {
7
+ if (fields.approximate !== void 0) return record[fields.approximate] === true;
8
+ if (fields.exact !== void 0) return record[fields.exact] === false;
9
+ return false;
10
+ };
11
+ /** The polygon of `metres` around `center`, its corners as far from it along the globe. */
12
+ const circleArea = (center, metres) => {
13
+ const distance = metres / earthRadius;
14
+ const latitude = center.latitude * Math.PI / 180;
15
+ const longitude = center.longitude * Math.PI / 180;
16
+ return {
17
+ type: "Polygon",
18
+ coordinates: [Array.from({ length: 65 }, (_, index) => {
19
+ const bearing = index % circleCorners / circleCorners * 2 * Math.PI;
20
+ const to = Math.asin(Math.sin(latitude) * Math.cos(distance) + Math.cos(latitude) * Math.sin(distance) * Math.cos(bearing));
21
+ return [(longitude + Math.atan2(Math.sin(bearing) * Math.sin(distance) * Math.cos(latitude), Math.cos(distance) - Math.sin(latitude) * Math.sin(to))) * 180 / Math.PI, to * 180 / Math.PI];
22
+ })]
23
+ };
24
+ };
25
+ const isPosition = (value) => Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" && Math.abs(value[0]) <= 180 && Math.abs(value[1]) <= 90;
26
+ const isLine = (value, least) => Array.isArray(value) && value.length >= least && value.every(isPosition);
27
+ const isPolygon = (value) => Array.isArray(value) && value.length > 0 && value.every((ring) => isLine(ring, 4));
28
+ const isLines = (value) => Array.isArray(value) && value.length > 0 && value.every((line) => isLine(line, 2));
29
+ const parsed = (value) => {
30
+ if (typeof value !== "string") return value;
31
+ try {
32
+ return JSON.parse(value);
33
+ } catch (error) {
34
+ if (error instanceof SyntaxError) return void 0;
35
+ throw error;
36
+ }
37
+ };
38
+ /** The GeoJSON polygon or line a value holds, as a geometry or a feature of one; none for anything else, such as a point or text that is no GeoJSON. */
39
+ const areaGeometry = (value) => {
40
+ const data = parsed(value);
41
+ if (typeof data !== "object" || data === null) return void 0;
42
+ const geometry = data.type === "Feature" ? data.geometry : data;
43
+ if (typeof geometry !== "object" || geometry === null) return void 0;
44
+ const { type, coordinates } = geometry;
45
+ if (type === "Polygon" && isPolygon(coordinates)) return {
46
+ type,
47
+ coordinates
48
+ };
49
+ if (type === "MultiPolygon" && Array.isArray(coordinates) && coordinates.length > 0 && coordinates.every(isPolygon)) return {
50
+ type,
51
+ coordinates
52
+ };
53
+ if (type === "LineString" && isLine(coordinates, 2)) return {
54
+ type,
55
+ coordinates
56
+ };
57
+ if (type === "MultiLineString" && isLines(coordinates)) return {
58
+ type,
59
+ coordinates
60
+ };
61
+ };
62
+ /**
63
+ * What the map draws around the record's approximate position: the area its field holds, else a circle of the radius
64
+ * its field holds, else one of the display's default radius. An exact position, or one whose record states none of
65
+ * them on a display without a default, has none.
66
+ */
67
+ const positionArea = (record, fields, position) => {
68
+ if (!isApproximate(record, fields)) return void 0;
69
+ const area = fields.area === void 0 ? void 0 : areaGeometry(record[fields.area]);
70
+ if (area) return area;
71
+ const radius = fields.radius === void 0 ? void 0 : numeric(record[fields.radius]);
72
+ if (radius !== void 0 && Number.isFinite(radius) && radius > 0) return circleArea(position, radius);
73
+ return fields.defaultRadius === void 0 ? void 0 : circleArea(position, fields.defaultRadius);
74
+ };
75
+ //#endregion
76
+ export { areaGeometry, circleArea, isApproximate, positionArea };