@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
@@ -0,0 +1,80 @@
1
+ //#region src/app/map/area-features.ts
2
+ const worldPixels = 512;
3
+ const mercatorLimit = 85.051129;
4
+ const fadeFrom = 16;
5
+ const zooms = Array.from({ length: 23 }, (_, zoom) => zoom);
6
+ const positionsOf = (geometry) => {
7
+ if (geometry.type === "LineString") return geometry.coordinates;
8
+ if (geometry.type === "MultiPolygon") return geometry.coordinates.flat(2);
9
+ return geometry.coordinates.flat();
10
+ };
11
+ const mercatorY = (latitude) => {
12
+ const radians = Math.max(-85.051129, Math.min(mercatorLimit, latitude)) * Math.PI / 180;
13
+ return .5 - Math.log(Math.tan(Math.PI / 4 + radians / 2)) / (2 * Math.PI);
14
+ };
15
+ /** How many pixels across the area is drawn at zoom 0, the larger of its width and height; twice as many each zoom level in. */
16
+ const zoomZeroSpan = (geometry) => {
17
+ const positions = positionsOf(geometry);
18
+ const longitudes = positions.map(([longitude]) => longitude);
19
+ const ys = positions.map(([, latitude]) => mercatorY(latitude));
20
+ const width = (Math.max(...longitudes) - Math.min(...longitudes)) / 360 * worldPixels;
21
+ const height = (Math.max(...ys) - Math.min(...ys)) * worldPixels;
22
+ return Math.max(width, height);
23
+ };
24
+ /** The areas of the points that have one, those of the chosen records last so they are drawn on top. */
25
+ const areaFeatures = (points, selected) => {
26
+ const features = points.flatMap((point) => {
27
+ if (!point.area) return [];
28
+ const kind = point.area.type === "LineString" || point.area.type === "MultiLineString" ? "line" : "polygon";
29
+ return [{
30
+ type: "Feature",
31
+ geometry: point.area,
32
+ properties: {
33
+ key: point.key,
34
+ kind,
35
+ span: zoomZeroSpan(point.area),
36
+ chosen: selected?.includes(point.key) ?? false
37
+ }
38
+ }];
39
+ });
40
+ return {
41
+ type: "FeatureCollection",
42
+ features: [...features.filter((feature) => !feature.properties.chosen), ...features.filter((feature) => feature.properties.chosen)]
43
+ };
44
+ };
45
+ /**
46
+ * An opacity of `full` for an area drawn large enough to mean something at the zoom, fading in as it grows to that
47
+ * size: at each whole zoom level its share of `full` follows from its span, and MapLibre blends between the levels.
48
+ */
49
+ const fadeIn = (full) => [
50
+ "interpolate",
51
+ ["linear"],
52
+ ["zoom"],
53
+ ...zooms.flatMap((zoom) => [zoom, [
54
+ "*",
55
+ full,
56
+ [
57
+ "min",
58
+ 1,
59
+ [
60
+ "max",
61
+ 0,
62
+ [
63
+ "/",
64
+ [
65
+ "-",
66
+ [
67
+ "*",
68
+ ["get", "span"],
69
+ 2 ** zoom
70
+ ],
71
+ fadeFrom
72
+ ],
73
+ 32
74
+ ]
75
+ ]
76
+ ]
77
+ ]])
78
+ ];
79
+ //#endregion
80
+ export { areaFeatures, fadeIn, zoomZeroSpan };
@@ -0,0 +1,11 @@
1
+ import * as maplibre from "maplibre-gl";
2
+ import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
3
+ import "maplibre-gl/dist/maplibre-gl.css";
4
+ import { Protocol } from "pmtiles";
5
+ //#region src/app/map/maplibre.ts
6
+ maplibre.setWorkerUrl(workerUrl);
7
+ maplibre.addProtocol("pmtiles", new Protocol().tile);
8
+ /** MapLibre, set up to run its worker and read PMTiles archives: what a map draws with. */
9
+ const mapLib = maplibre;
10
+ //#endregion
11
+ export { mapLib };
@@ -0,0 +1,78 @@
1
+ import { areaFeatures, fadeIn } from "./area-features.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Layer, Source } from "react-map-gl/maplibre";
4
+ //#region src/app/map/position-areas.tsx
5
+ const polygons = [
6
+ "==",
7
+ ["get", "kind"],
8
+ "polygon"
9
+ ];
10
+ const lines = [
11
+ "==",
12
+ ["get", "kind"],
13
+ "line"
14
+ ];
15
+ const chosen = (yes, no) => [
16
+ "case",
17
+ ["get", "chosen"],
18
+ yes,
19
+ no
20
+ ];
21
+ /**
22
+ * The areas around the approximate positions among `points`, drawn under the markers in the markers' colour: a polygon
23
+ * filled faintly within a dashed outline, a line such as a street as a wide stroke. Each fades in as the map zooms close
24
+ * enough for it to be larger than a marker, and the chosen records' areas are drawn darker. Nothing without areas.
25
+ */
26
+ const PositionAreas = ({ points, selected, colors }) => {
27
+ const data = areaFeatures(points, selected);
28
+ if (data.features.length === 0) return null;
29
+ const color = [
30
+ "case",
31
+ ["get", "chosen"],
32
+ colors.chosen,
33
+ colors.area
34
+ ];
35
+ return /* @__PURE__ */ jsxs(Source, {
36
+ id: "position-areas",
37
+ type: "geojson",
38
+ data,
39
+ children: [
40
+ /* @__PURE__ */ jsx(Layer, {
41
+ id: "position-area-fills",
42
+ type: "fill",
43
+ filter: polygons,
44
+ paint: {
45
+ "fill-color": color,
46
+ "fill-opacity": fadeIn(chosen(.15, .1))
47
+ }
48
+ }),
49
+ /* @__PURE__ */ jsx(Layer, {
50
+ id: "position-area-outlines",
51
+ type: "line",
52
+ filter: polygons,
53
+ paint: {
54
+ "line-color": color,
55
+ "line-width": 1.5,
56
+ "line-dasharray": [3, 2],
57
+ "line-opacity": fadeIn(.8)
58
+ }
59
+ }),
60
+ /* @__PURE__ */ jsx(Layer, {
61
+ id: "position-area-lines",
62
+ type: "line",
63
+ filter: lines,
64
+ layout: {
65
+ "line-cap": "round",
66
+ "line-join": "round"
67
+ },
68
+ paint: {
69
+ "line-color": color,
70
+ "line-width": 6,
71
+ "line-opacity": fadeIn(chosen(.6, .35))
72
+ }
73
+ })
74
+ ]
75
+ });
76
+ };
77
+ //#endregion
78
+ export { PositionAreas };
@@ -1,19 +1,14 @@
1
+ import { mapLib } from "./maplibre.js";
1
2
  import { MapPin } from "lucide-react";
2
3
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import * as maplibre from "maplibre-gl";
4
- import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
5
- import "maplibre-gl/dist/maplibre-gl.css";
6
- import { Protocol } from "pmtiles";
7
4
  import { Map as Map$1, Marker, NavigationControl } from "react-map-gl/maplibre";
8
5
  //#region src/app/map/record-map.tsx
9
- maplibre.setWorkerUrl(workerUrl);
10
- maplibre.addProtocol("pmtiles", new Protocol().tile);
11
6
  /**
12
7
  * A map of `style` centred on `position` at `zoom`, with a pin on it. It pans and zooms with two fingers on a phone and
13
8
  * with Ctrl or Cmd and the wheel, so scrolling the page past it does not move it.
14
9
  */
15
10
  const RecordMap = ({ style, position, zoom }) => /* @__PURE__ */ jsxs(Map$1, {
16
- mapLib: maplibre,
11
+ mapLib,
17
12
  mapStyle: style,
18
13
  initialViewState: {
19
14
  ...position,
@@ -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
  /**
@@ -0,0 +1,51 @@
1
+ import { useAdminMeta } from "../../meta-gate.js";
2
+ import { RecordRowBody } from "../../record-row/record-row-body.js";
3
+ import { propsOf } from "./block-props.js";
4
+ import { CardFrame } from "./card-frame.js";
5
+ import { SavedRecordRow } from "../../record-row/saved-record-row.js";
6
+ import { SavedSearchRows } from "../../searches/saved-search-rows.js";
7
+ import { jsx } from "react/jsx-runtime";
8
+ //#region src/app/pages/blocks/record-row.tsx
9
+ /**
10
+ * What a filter finds: its chips, the count, its records as a sideways row of their list's cards, and "View all". Of a
11
+ * resource whose view has `listOf`, the records the user's saved ones point at; of one with `searchesOf`, a row per
12
+ * saved search of the user's.
13
+ */
14
+ const RecordRowBlock = ({ node, slots }) => {
15
+ const { resource, filter = "", sort, limit, display, title, description, empty } = propsOf(node);
16
+ const { resources, views } = useAdminMeta();
17
+ const model = resources[resource];
18
+ if (!model) return null;
19
+ const view = views[model.name];
20
+ const body = () => {
21
+ if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearchRows, {
22
+ model,
23
+ view,
24
+ empty
25
+ });
26
+ if (view?.listOf !== void 0) return /* @__PURE__ */ jsx(SavedRecordRow, {
27
+ model,
28
+ view,
29
+ limit,
30
+ display,
31
+ empty
32
+ });
33
+ return /* @__PURE__ */ jsx(RecordRowBody, {
34
+ model,
35
+ view,
36
+ filter,
37
+ sort,
38
+ limit,
39
+ display,
40
+ empty
41
+ });
42
+ };
43
+ return /* @__PURE__ */ jsx(CardFrame, {
44
+ title,
45
+ description,
46
+ actions: slots.actions,
47
+ children: body()
48
+ });
49
+ };
50
+ //#endregion
51
+ export { RecordRowBlock };
@@ -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
  };
@@ -11,6 +11,7 @@ import { ModalFormBlock } from "./blocks/modal-form.js";
11
11
  import { PageBlock } from "./blocks/page-block.js";
12
12
  import { ProgressBlock } from "./blocks/progress.js";
13
13
  import { RecordCardBlock } from "./blocks/record-card.js";
14
+ import { RecordRowBlock } from "./blocks/record-row.js";
14
15
  import { ShowBlock } from "./blocks/show.js";
15
16
  import { StatBlock } from "./blocks/stat.js";
16
17
  import { TableBlock } from "./blocks/table.js";
@@ -28,6 +29,7 @@ const blocks = {
28
29
  Field: FieldBlock,
29
30
  Table: TableBlock,
30
31
  CardRow: CardRowBlock,
32
+ RecordRow: RecordRowBlock,
31
33
  Progress: ProgressBlock,
32
34
  ModalForm: ModalFormBlock,
33
35
  Action: ActionBlock,
@@ -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),
@@ -0,0 +1,114 @@
1
+ import { useIsXs } from "../../lib/use-media-query.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { displaysOf, loadedFields } from "../displays/list-displays.js";
5
+ import { defaultOrderBy } from "../list-order.js";
6
+ import { useAdminMeta } from "../meta-gate.js";
7
+ import { AppLink } from "../use-app-address.js";
8
+ import { ErrorBanner } from "../error-banner.js";
9
+ import { useLiveRows } from "../../live/use-live-rows.js";
10
+ import { FilterChipList } from "../searches/filter-chip-list.js";
11
+ import { viewAllAddress } from "../embedded-list/embedded-display.js";
12
+ import { useFilterChips } from "../searches/use-filter-chips.js";
13
+ import { RecordRow } from "./record-row.js";
14
+ import { useMemo } from "react";
15
+ import { ArrowRight } from "lucide-react";
16
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
+ import { createWhere, parseFilter, printFilter } from "@protobase/schema";
18
+ //#region src/app/record-row/record-row-body.tsx
19
+ const where = createWhere();
20
+ const scoped = (filter, scope) => {
21
+ if (!scope) return filter;
22
+ const parsed = parseFilter(filter);
23
+ if (!parsed.ok) throw new Error(`The filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
24
+ return printFilter(parsed.ast ? where.and(parsed.ast, scope) : scope);
25
+ };
26
+ /** The display a row draws its cards with: the one named, else the view's first cards display, else a card of the title. */
27
+ const rowDisplay = (view, name) => {
28
+ const cards = displaysOf(view).filter((display) => display.kind === "cards");
29
+ return cards.find((display) => display.name === name) ?? cards[0] ?? {
30
+ kind: "cards",
31
+ name: "cards"
32
+ };
33
+ };
34
+ const Loading = () => /* @__PURE__ */ jsx("div", {
35
+ className: "flex gap-3 overflow-hidden",
36
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-64 shrink-0" }, index))
37
+ });
38
+ /**
39
+ * What a filter finds, in a card's body: its filters as chips, how many records match, the records in a row of their
40
+ * list's cards that scrolls sideways (`RecordRow`), and "View all", which opens the list with the filter.
41
+ */
42
+ const RecordRowBody = ({ model, view, filter, sort = "", display: named, limit, chips: showChips = true, empty, note, isNew, viewAll, scope, list }) => {
43
+ const { resources, views } = useAdminMeta();
44
+ const xs = useIsXs();
45
+ const display = rowDisplay(view, named);
46
+ const orderBy = sort || defaultOrderBy(model, view);
47
+ const columns = useMemo(() => loadedFields(model, view, []), [model, view]);
48
+ const loadFilter = useMemo(() => scoped(filter, scope), [filter, scope]);
49
+ const rows = useLiveRows({
50
+ resources,
51
+ views,
52
+ model,
53
+ columns,
54
+ filter: loadFilter,
55
+ orderBy
56
+ });
57
+ const chips = useFilterChips(model, filter);
58
+ const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
59
+ const plural = view?.names?.plural ?? humanize(model.name);
60
+ const total = rows.source?.total;
61
+ const source = useMemo(() => rows.source && {
62
+ ...rows.source,
63
+ total: Math.min(rows.source.total, limit ?? Infinity)
64
+ }, [rows.source, limit]);
65
+ return /* @__PURE__ */ jsxs("div", {
66
+ className: "flex min-w-0 flex-col gap-3",
67
+ children: [
68
+ showChips && chips.length > 0 || total !== void 0 || note ? /* @__PURE__ */ jsxs("div", {
69
+ className: "flex flex-wrap items-center justify-between gap-2",
70
+ children: [showChips && chips.length > 0 ? /* @__PURE__ */ jsx(FilterChipList, { chips }) : /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsxs("p", {
71
+ className: "flex flex-wrap gap-x-3 text-[13px] text-muted-foreground",
72
+ children: [note && /* @__PURE__ */ jsx("span", { children: note }), total !== void 0 && /* @__PURE__ */ jsx("span", {
73
+ className: "tabular-nums",
74
+ children: `${total.toLocaleString("en-GB")} ${total === 1 ? singular : plural.toLowerCase()}`
75
+ })]
76
+ })]
77
+ }) : null,
78
+ rows.isLoading ? /* @__PURE__ */ jsx(Loading, {}) : rows.error ? /* @__PURE__ */ jsx(ErrorBanner, {
79
+ title: "Could not load",
80
+ error: rows.error
81
+ }) : !source || source.total === 0 ? /* @__PURE__ */ jsx("p", {
82
+ className: "text-[13px] text-muted-foreground",
83
+ children: empty ?? `No ${plural.toLowerCase()} match.`
84
+ }) : /* @__PURE__ */ jsx(RecordRow, {
85
+ model,
86
+ view,
87
+ display,
88
+ source,
89
+ label: plural,
90
+ isNew
91
+ }),
92
+ /* @__PURE__ */ jsxs(AppLink, {
93
+ to: viewAllAddress(model, view, xs, {
94
+ filter,
95
+ orderBy,
96
+ display: display.name,
97
+ savedSearch: viewAll?.savedSearch,
98
+ resource: list
99
+ }),
100
+ onClick: viewAll?.onOpen,
101
+ className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
102
+ children: [viewAll?.label ?? /* @__PURE__ */ jsxs(Fragment$1, { children: ["View all ", /* @__PURE__ */ jsx("span", {
103
+ className: "sr-only",
104
+ children: plural.toLowerCase()
105
+ })] }), /* @__PURE__ */ jsx(ArrowRight, {
106
+ className: "size-3.5",
107
+ "aria-hidden": true
108
+ })]
109
+ })
110
+ ]
111
+ });
112
+ };
113
+ //#endregion
114
+ export { RecordRowBody };