@protobase/ui 0.13.0 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/app/create/create-page.js +1 -0
  2. package/dist/app/current-address.d.ts +3 -2
  3. package/dist/app/current-address.js +2 -1
  4. package/dist/app/delete/use-delete-flow.js +2 -1
  5. package/dist/app/displays/card-carousel.js +4 -1
  6. package/dist/app/displays/card-photos.js +6 -3
  7. package/dist/app/displays/map-display.js +83 -23
  8. package/dist/app/displays/map-groups.js +1 -8
  9. package/dist/app/displays/record-card.js +8 -6
  10. package/dist/app/displays/use-map-records.js +44 -0
  11. package/dist/app/embedded-list/embedded-list.js +3 -1
  12. package/dist/app/file-field.js +16 -13
  13. package/dist/app/list-page.js +6 -2
  14. package/dist/app/map/map-area.js +53 -0
  15. package/dist/app/map/records-map.js +91 -38
  16. package/dist/app/pages/field-value.js +22 -9
  17. package/dist/app/use-record-editor.js +2 -1
  18. package/dist/data/api-provider.d.ts +2 -0
  19. package/dist/data/query-keys.js +9 -0
  20. package/dist/data/use-update-record.js +1 -0
  21. package/dist/data-grid/cells.js +8 -5
  22. package/dist/live/file-image.js +35 -0
  23. package/dist/live/file-values.js +17 -1
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-CfuMqqAY.js +66 -0
  26. package/dist/shell/assets/index-CYWfU_c1.css +2 -0
  27. package/dist/shell/assets/{index-D7IcSr6n.js → index-DCTyUhH-.js} +1 -1
  28. package/dist/shell/assets/{maplibre-gl-Bt-3iOM-.js → maplibre-gl-DP9hPAtI.js} +1 -1
  29. package/dist/shell/assets/{record-map-DL4b9dZO.js → record-map-Dgn4pFlE.js} +2 -2
  30. package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
  31. package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
  32. package/dist/shell/index.html +4 -4
  33. package/dist/shell/protobase.shell.json +1 -1
  34. package/package.json +5 -4
  35. package/dist/shell/assets/app-DLjaMVJD.js +0 -66
  36. package/dist/shell/assets/index-BYPphaEm.css +0 -2
  37. package/dist/shell/assets/records-map-D0EOO5kZ.js +0 -1
  38. package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { cn } from "../../lib/cn.js";
2
- import { groupPoints, pointBounds } from "../displays/map-groups.js";
2
+ import { groupPoints } from "../displays/map-groups.js";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { MapPin } from "lucide-react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -12,18 +12,29 @@ const fitOptions = {
12
12
  maxZoom: 15,
13
13
  duration: 0
14
14
  };
15
+ const bucketOptions = {
16
+ padding: 48,
17
+ maxZoom: 16,
18
+ duration: 300
19
+ };
15
20
  const markerHeight = 32;
21
+ const compact = new Intl.NumberFormat("en-IE", {
22
+ notation: "compact",
23
+ maximumFractionDigits: 1
24
+ });
25
+ const corners = (bounds) => [[bounds.west, bounds.south], [bounds.east, bounds.north]];
16
26
  /**
17
27
  * A map of `style` with a marker for each group of records that lie close together at the zoom, showing how many it
18
- * stands for. It fits the records when they change. Choosing a marker selects its records; choosing the map elsewhere
19
- * clears the selection. A chosen marker the covered bottom would hide moves up into the open part of the map.
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.
20
31
  */
21
- const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, markerLabel }) => {
32
+ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
22
33
  const ref = useRef(null);
23
34
  const [groups, setGroups] = useState([]);
24
35
  const byKey = new globalThis.Map(points.map((point) => [point.key, point]));
25
36
  const pointKeys = points.map((point) => point.key).join(",");
26
- const bounds = pointBounds(points);
37
+ const fitKey = fit && JSON.stringify(fit);
27
38
  const regroup = () => {
28
39
  const map = ref.current?.getMap();
29
40
  if (!map) return;
@@ -32,12 +43,34 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
32
43
  ...map.project([point.longitude, point.latitude])
33
44
  })), groupRadius));
34
45
  };
35
- useEffect(() => {
46
+ const report = () => {
36
47
  const map = ref.current?.getMap();
37
48
  if (!map) return;
38
- if (bounds) map.fitBounds(bounds, fitOptions);
39
- regroup();
40
- }, [pointKeys]);
49
+ const bounds = map.getBounds();
50
+ const center = map.getCenter();
51
+ onMove({
52
+ bounds: {
53
+ south: bounds.getSouth(),
54
+ west: bounds.getWest(),
55
+ north: bounds.getNorth(),
56
+ east: bounds.getEast()
57
+ },
58
+ zoom: map.getZoom()
59
+ }, {
60
+ latitude: center.lat,
61
+ longitude: center.lng,
62
+ zoom: map.getZoom()
63
+ });
64
+ };
65
+ const fitNow = () => {
66
+ const map = ref.current?.getMap();
67
+ if (!map || !fit) return;
68
+ map.fitBounds(corners(fit), fitOptions);
69
+ onFitted();
70
+ report();
71
+ };
72
+ useEffect(regroup, [pointKeys]);
73
+ useEffect(fitNow, [fitKey]);
41
74
  const chosenAt = groups.find((group) => selected !== void 0 && group.keys.some((key) => selected.includes(key)))?.at;
42
75
  useEffect(() => {
43
76
  const map = ref.current?.getMap();
@@ -56,10 +89,7 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
56
89
  ref,
57
90
  mapLib: maplibre,
58
91
  mapStyle: style,
59
- initialViewState: bounds ? {
60
- bounds,
61
- fitBoundsOptions: fitOptions
62
- } : {
92
+ initialViewState: initial ?? {
63
93
  latitude: 0,
64
94
  longitude: 0,
65
95
  zoom: 1
@@ -68,38 +98,61 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
68
98
  width: "100%",
69
99
  height: "100%"
70
100
  },
71
- onLoad: regroup,
101
+ onLoad: () => {
102
+ regroup();
103
+ if (fit) fitNow();
104
+ else report();
105
+ },
72
106
  onZoomEnd: regroup,
107
+ onMoveEnd: report,
73
108
  onClick: onClear,
74
- children: [/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }), groups.map((group) => {
75
- const at = byKey.get(group.at);
76
- if (!at) return null;
77
- const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
78
- return /* @__PURE__ */ jsx(Marker, {
79
- latitude: at.latitude,
80
- longitude: at.longitude,
81
- anchor: "bottom",
82
- style: { zIndex: chosen ? 1 : 0 },
83
- children: /* @__PURE__ */ jsxs("button", {
109
+ children: [
110
+ /* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
111
+ buckets.map((bucket) => /* @__PURE__ */ jsx(Marker, {
112
+ latitude: bucket.latitude,
113
+ longitude: bucket.longitude,
114
+ anchor: "center",
115
+ children: /* @__PURE__ */ jsx("button", {
84
116
  type: "button",
85
- "aria-label": markerLabel(group.keys),
86
- "aria-pressed": chosen,
87
- className: "relative block cursor-pointer",
117
+ "aria-label": bucketLabel(bucket.count),
118
+ className: "flex size-10 cursor-pointer items-center justify-center rounded-full border-2 border-background bg-primary text-xs font-semibold text-primary-foreground tabular-nums shadow",
88
119
  onClick: (event) => {
89
120
  event.stopPropagation();
90
- onSelect(group.keys);
121
+ ref.current?.getMap().fitBounds(corners(bucket.bounds), bucketOptions);
91
122
  },
92
- children: [/* @__PURE__ */ jsx(MapPin, {
93
- "aria-hidden": true,
94
- className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
95
- strokeWidth: 1.5
96
- }), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
97
- 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",
98
- children: group.keys.length
99
- })]
123
+ children: compact.format(bucket.count)
100
124
  })
101
- }, group.at);
102
- })]
125
+ }, `${bucket.latitude},${bucket.longitude}`)),
126
+ groups.map((group) => {
127
+ const at = byKey.get(group.at);
128
+ if (!at) return null;
129
+ const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
130
+ return /* @__PURE__ */ jsx(Marker, {
131
+ latitude: at.latitude,
132
+ longitude: at.longitude,
133
+ anchor: "bottom",
134
+ style: { zIndex: chosen ? 1 : 0 },
135
+ children: /* @__PURE__ */ jsxs("button", {
136
+ type: "button",
137
+ "aria-label": markerLabel(group.keys),
138
+ "aria-pressed": chosen,
139
+ className: "relative block cursor-pointer",
140
+ onClick: (event) => {
141
+ event.stopPropagation();
142
+ onSelect(group.keys);
143
+ },
144
+ children: [/* @__PURE__ */ jsx(MapPin, {
145
+ "aria-hidden": true,
146
+ className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
147
+ strokeWidth: 1.5
148
+ }), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
149
+ 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
+ children: group.keys.length
151
+ })]
152
+ })
153
+ }, group.at);
154
+ })
155
+ ]
103
156
  });
104
157
  };
105
158
  //#endregion
@@ -2,14 +2,15 @@ import { cn } from "../../lib/cn.js";
2
2
  import { keys } from "../../data/query-keys.js";
3
3
  import { useClient } from "../../data/api-provider.js";
4
4
  import { fetchLabels } from "../../live/relation-labels.js";
5
- import { fileShown } from "../../live/file-values.js";
5
+ import { fileShown, shownImage } from "../../live/file-values.js";
6
6
  import { displayValue } from "../../live/display-value.js";
7
7
  import { useAdminMeta } from "../meta-gate.js";
8
8
  import { AppLink } from "../use-app-address.js";
9
+ import { FileImage } from "../../live/file-image.js";
9
10
  import { useShownWidget } from "../field-types/shown-widget.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
11
12
  import { ValueBadge } from "../value-badge.js";
12
- import { jsx } from "react/jsx-runtime";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
13
14
  import { useQuery, useQueryClient } from "@tanstack/react-query";
14
15
  //#region src/app/pages/field-value.tsx
15
16
  const RelationValue = ({ field, value, known }) => {
@@ -50,13 +51,25 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
50
51
  known: relationLabel
51
52
  });
52
53
  const file = field.type === "file" ? fileShown(value) : void 0;
53
- if (file && "url" in file && file.url) return /* @__PURE__ */ jsx("a", {
54
- href: file.url,
55
- target: "_blank",
56
- rel: "noreferrer",
57
- className: "font-medium text-primary-text hover:underline",
58
- children: file.name
59
- });
54
+ if (file && "url" in file && file.url) {
55
+ const image = shownImage(value, {
56
+ width: 40,
57
+ height: 40
58
+ });
59
+ return /* @__PURE__ */ jsxs("span", {
60
+ className: "inline-flex min-w-0 items-center gap-2",
61
+ children: [image && /* @__PURE__ */ jsx(FileImage, {
62
+ image,
63
+ className: "size-10 shrink-0 rounded border border-border object-cover"
64
+ }), /* @__PURE__ */ jsx("a", {
65
+ href: file.url,
66
+ target: "_blank",
67
+ rel: "noreferrer",
68
+ className: "truncate font-medium text-primary-text hover:underline",
69
+ children: file.name
70
+ })]
71
+ });
72
+ }
60
73
  const text = displayValue(field, value, hints);
61
74
  if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
62
75
  value,
@@ -106,7 +106,8 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
106
106
  await Promise.all([
107
107
  queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) }),
108
108
  queryClient.invalidateQueries({ queryKey: ["list"] }),
109
- queryClient.invalidateQueries({ queryKey: ["page"] })
109
+ queryClient.invalidateQueries({ queryKey: ["page"] }),
110
+ queryClient.invalidateQueries({ queryKey: ["map"] })
110
111
  ]);
111
112
  lines?.reset();
112
113
  };
@@ -28,6 +28,7 @@ export declare const useClient: () => {
28
28
  filter?: import("@protobase/client").FilterInput;
29
29
  buckets?: number;
30
30
  }) => Promise<import("@protobase/client").Histogram>;
31
+ map: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").MapParams) => Promise<import("@protobase/client").MapResult<import("@protobase/client").RecordOf<R>>>;
31
32
  seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
32
33
  position: number;
33
34
  }) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
@@ -66,6 +67,7 @@ export declare const useResourceClient: (resource: string) => {
66
67
  filter?: import("@protobase/client").FilterInput;
67
68
  buckets?: number;
68
69
  }) => Promise<import("@protobase/client").Histogram>;
70
+ map: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").MapParams) => Promise<import("@protobase/client").MapResult<import("@protobase/client").RecordOf<R>>>;
69
71
  seek: <R extends import("@protobase/client").ResourceRef>(resource: R, params: import("@protobase/client").ListParams & {
70
72
  position: number;
71
73
  }) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
@@ -66,6 +66,15 @@ const keys = {
66
66
  buckets,
67
67
  filter
68
68
  ],
69
+ /** The records or buckets a map shows; `filter` holds its bounding box. */
70
+ map: (resource, position, zoom, filter, orderBy) => [
71
+ "map",
72
+ resource,
73
+ position,
74
+ zoom,
75
+ filter,
76
+ orderBy
77
+ ],
69
78
  relatedLabel: (resource, id) => [
70
79
  "related-label",
71
80
  resource,
@@ -18,6 +18,7 @@ const useUpdateRecord = (resource, key) => {
18
18
  queryClient.setQueryData(keys.record(resource, encoded), stored);
19
19
  queryClient.invalidateQueries({ queryKey: ["page", resource] });
20
20
  queryClient.invalidateQueries({ queryKey: ["list", resource] });
21
+ queryClient.invalidateQueries({ queryKey: ["map", resource] });
21
22
  }
22
23
  });
23
24
  };
@@ -4,7 +4,8 @@ import { Badge } from "../primitives/badge.js";
4
4
  import { formatMoney } from "../format/money.js";
5
5
  import { formatInt, formatPercent, formatSigned } from "../format/number.js";
6
6
  import { formatDate, formatDateTime } from "../format/date.js";
7
- import { fileShown, isPreviewable } from "../live/file-values.js";
7
+ import { fileShown, shownImage } from "../live/file-values.js";
8
+ import { FileImage } from "../live/file-image.js";
8
9
  import { userById } from "../mocks/users.js";
9
10
  import { valueColor } from "../live/value-color.js";
10
11
  import { Check, Paperclip } from "lucide-react";
@@ -75,12 +76,14 @@ const renderCell = (spec, value) => {
75
76
  className: "truncate text-danger-text",
76
77
  children: file.name
77
78
  });
78
- const url = "url" in file ? file.url : void 0;
79
+ const image = shownImage(value, {
80
+ width: 20,
81
+ height: 20
82
+ });
79
83
  return /* @__PURE__ */ jsxs("span", {
80
84
  className: "inline-flex min-w-0 items-center gap-2",
81
- children: [url && isPreviewable(file.type) ? /* @__PURE__ */ jsx("img", {
82
- src: url,
83
- alt: "",
85
+ children: [image ? /* @__PURE__ */ jsx(FileImage, {
86
+ image,
84
87
  className: "size-5 shrink-0 rounded-sm object-cover"
85
88
  }) : /* @__PURE__ */ jsx(Paperclip, { className: "size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx("span", {
86
89
  className: "truncate",
@@ -0,0 +1,35 @@
1
+ import { useMemo, useState } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { thumbHashToDataURL } from "thumbhash";
4
+ //#region src/live/file-image.tsx
5
+ const placeholderImage = (hash) => {
6
+ let binary;
7
+ try {
8
+ binary = atob(hash);
9
+ } catch (error) {
10
+ if (error instanceof DOMException && error.name === "InvalidCharacterError") return void 0;
11
+ throw error;
12
+ }
13
+ return thumbHashToDataURL(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
14
+ };
15
+ /** An image of a file, its placeholder drawn blurred behind it until it has loaded. */
16
+ const FileImage = ({ image, className, loading }) => {
17
+ const [loaded, setLoaded] = useState();
18
+ const placeholder = useMemo(() => image.placeholder === void 0 ? void 0 : placeholderImage(image.placeholder), [image.placeholder]);
19
+ const waiting = placeholder !== void 0 && loaded !== image.url;
20
+ return /* @__PURE__ */ jsx("img", {
21
+ src: image.url,
22
+ alt: "",
23
+ loading,
24
+ decoding: "async",
25
+ onLoad: () => setLoaded(image.url),
26
+ className,
27
+ style: waiting ? {
28
+ backgroundImage: `url("${placeholder}")`,
29
+ backgroundSize: "cover",
30
+ backgroundPosition: "center"
31
+ } : void 0
32
+ });
33
+ };
34
+ //#endregion
35
+ export { FileImage };
@@ -34,6 +34,22 @@ const formatSize = (bytes) => {
34
34
  };
35
35
  /** Raster images the browser shows; SVG is never shown, since it can carry scripts. */
36
36
  const isPreviewable = (type) => type.startsWith("image/") && type !== "image/svg+xml";
37
+ const density = 2;
38
+ const unbounded = 1e5;
39
+ const covers = (variant, box) => (variant.width !== void 0 || variant.height !== void 0) && (variant.width ?? unbounded) >= box.width * density && (variant.height ?? unbounded) >= box.height * density;
40
+ const area = (variant) => (variant.width ?? unbounded) * (variant.height ?? unbounded);
41
+ /**
42
+ * The picture of a file for `box`: the link of its smallest variant large enough for it, else the file's own, with its
43
+ * placeholder; `undefined` for a file that is no raster image with a link.
44
+ */
45
+ const shownImage = (value, box) => {
46
+ const file = fileShown(value);
47
+ if (!file || "missing" in file || !file.url || !isPreviewable(file.type)) return void 0;
48
+ return {
49
+ url: ("variants" in file && file.variants ? Object.values(file.variants) : []).filter((variant) => covers(variant, box)).sort((a, b) => area(a) - area(b))[0]?.url ?? file.url,
50
+ ..."placeholder" in file && file.placeholder !== void 0 && { placeholder: file.placeholder }
51
+ };
52
+ };
37
53
  /** The file input's `accept`: the field's allowed types, or nothing when it takes any. */
38
54
  const acceptOf = (field) => {
39
55
  const accept = field.file?.accept ?? [];
@@ -52,4 +68,4 @@ const fileDiffers = (draft, stored) => {
52
68
  return uri(draft) !== uri(stored);
53
69
  };
54
70
  //#endregion
55
- export { acceptOf, fileDiffers, filePatchValue, fileShown, formatSize, isFileDraft, isPreviewable, isStoredFile };
71
+ export { acceptOf, fileDiffers, filePatchValue, fileShown, formatSize, isFileDraft, isPreviewable, isStoredFile, shownImage };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.13.0";
2
+ var version = "0.13.3";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };