@protobase/ui 0.12.22 → 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 (63) 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-layout.js +19 -0
  7. package/dist/app/displays/card-photos.js +6 -3
  8. package/dist/app/displays/cards-display.js +16 -11
  9. package/dist/app/displays/list-cards.js +50 -0
  10. package/dist/app/displays/list-displays.js +4 -3
  11. package/dist/app/displays/map-display.js +83 -23
  12. package/dist/app/displays/map-groups.js +1 -8
  13. package/dist/app/displays/record-card.js +8 -6
  14. package/dist/app/displays/use-map-records.js +44 -0
  15. package/dist/app/displays/virtual-cards.js +138 -0
  16. package/dist/app/embedded-list/embedded-columns.js +52 -0
  17. package/dist/app/embedded-list/embedded-display.js +26 -0
  18. package/dist/app/embedded-list/embedded-list.js +157 -0
  19. package/dist/app/embedded-list/use-embedded-display.js +23 -0
  20. package/dist/app/embedded-list/use-embedded-rows.js +33 -0
  21. package/dist/app/file-field.js +16 -13
  22. package/dist/app/list-page.js +19 -6
  23. package/dist/app/list-scroll.js +25 -0
  24. package/dist/app/map/map-area.js +53 -0
  25. package/dist/app/map/records-map.js +91 -38
  26. package/dist/app/pages/blocks/table.js +21 -41
  27. package/dist/app/pages/field-value.js +22 -9
  28. package/dist/app/record-page.js +2 -1
  29. package/dist/app/related-section.js +30 -52
  30. package/dist/app/router.js +1 -0
  31. package/dist/app/use-record-editor.js +2 -1
  32. package/dist/data/api-provider.d.ts +2 -0
  33. package/dist/data/query-keys.js +9 -0
  34. package/dist/data/use-update-record.js +1 -0
  35. package/dist/data-grid/cells.js +8 -5
  36. package/dist/data-grid/data-grid.js +19 -39
  37. package/dist/data-grid/geometry.js +7 -9
  38. package/dist/data-grid/use-grid-scroll.js +8 -1
  39. package/dist/data-grid/use-virtual-rows.js +31 -0
  40. package/dist/live/columns-from-model.js +6 -4
  41. package/dist/live/file-image.js +35 -0
  42. package/dist/live/file-values.js +17 -1
  43. package/dist/live/grid-loader.js +26 -10
  44. package/dist/package.js +1 -1
  45. package/dist/record-view/section.js +24 -17
  46. package/dist/shell/assets/app-CfuMqqAY.js +66 -0
  47. package/dist/shell/assets/index-CYWfU_c1.css +2 -0
  48. package/dist/shell/assets/{index-B8MhIaLr.js → index-DCTyUhH-.js} +3 -3
  49. package/dist/shell/assets/maplibre-gl-DP9hPAtI.js +1 -0
  50. package/dist/shell/assets/record-map-Dgn4pFlE.js +814 -0
  51. package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
  52. package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
  53. package/dist/shell/index.html +5 -5
  54. package/dist/shell/protobase.shell.json +1 -1
  55. package/package.json +5 -5
  56. package/dist/app/pages/record-table.js +0 -114
  57. package/dist/app/pages/use-relation-labels.js +0 -30
  58. package/dist/shell/assets/app-BYw-8qpE.js +0 -66
  59. package/dist/shell/assets/index-CdSIk0BQ.css +0 -2
  60. package/dist/shell/assets/navigation-control-CGS0pamh.js +0 -814
  61. package/dist/shell/assets/record-map-9Y3IL_dg.js +0 -1
  62. package/dist/shell/assets/records-map-DXIWowJ3.js +0 -1
  63. package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +0 -1
@@ -94,6 +94,7 @@ const LoadedCreate = ({ resource, values }) => {
94
94
  "page",
95
95
  "list",
96
96
  "first-page",
97
+ "map",
97
98
  "facets",
98
99
  "series"
99
100
  ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
@@ -1,6 +1,6 @@
1
- import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
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. */
3
+ /** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
4
4
  export type ListAddress = {
5
5
  kind: 'list';
6
6
  resource: string;
@@ -8,6 +8,7 @@ export type ListAddress = {
8
8
  filter: string;
9
9
  orderBy: string;
10
10
  filterLayout: FilterLayout;
11
+ map?: MapView;
11
12
  };
12
13
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
13
14
  export type CurrentAddress = ListAddress | {
@@ -35,7 +35,8 @@ const changedList = (read, change, defaults) => {
35
35
  ...next.filter && { filter: next.filter },
36
36
  ...differs("display") && { display: next.display },
37
37
  ...differs("orderBy") && { orderBy: next.orderBy },
38
- ...differs("filterLayout") && { filterLayout: next.filterLayout }
38
+ ...differs("filterLayout") && { filterLayout: next.filterLayout },
39
+ ...next.map && { map: next.map }
39
40
  };
40
41
  };
41
42
  //#endregion
@@ -25,7 +25,8 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
25
25
  for (const prefix of [
26
26
  "page",
27
27
  "list",
28
- "first-page"
28
+ "first-page",
29
+ "map"
29
30
  ]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
30
31
  for (const prefix of [
31
32
  "facets",
@@ -1,6 +1,7 @@
1
1
  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
+ import { useAdminMeta } from "../meta-gate.js";
4
5
  import { cardPhotoList, cardPhotosRequest, shownPhoto } from "./card-photos.js";
5
6
  import { useState } from "react";
6
7
  import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -17,7 +18,9 @@ const CardCarousel = ({ photos, recordKey, cover, children }) => {
17
18
  const client = useClient();
18
19
  const [opened, setOpened] = useState(false);
19
20
  const [step, setStep] = useState(0);
20
- const request = cardPhotosRequest(photos, recordKey);
21
+ const target = useAdminMeta().resources[photos.resource];
22
+ const placeholder = target?.fields[photos.image]?.file?.placeholder;
23
+ const request = cardPhotosRequest(photos, recordKey, placeholder !== void 0 && target?.fields[placeholder] ? placeholder : void 0);
21
24
  const loaded = useQuery({
22
25
  queryKey: keys.list(photos.resource, request.filter, request.orderBy ?? "", request.pageSize, `card-photos:${photos.image}`),
23
26
  enabled: opened,
@@ -0,0 +1,19 @@
1
+ /** How many cards fit across `width`, as a grid of `repeat(auto-fill, minmax(15rem, 1fr))` would place them. */
2
+ const cardColumns = (width) => Math.max(1, Math.floor((width + 12) / 252));
3
+ const cardWidth = (width, columns) => Math.max(0, (width - (columns - 1) * 12) / columns);
4
+ /** The records the card rows `first` to `last` hold, as a first and last record index. */
5
+ const recordRange = (first, last, columns, total) => ({
6
+ start: first * columns,
7
+ end: Math.max(0, Math.min(total, (last + 1) * columns) - 1)
8
+ });
9
+ /**
10
+ * The scroll offset after a layout change that keeps the same records in view: the same place in the same row when
11
+ * only the row height changed, else the row now holding the first record of the row that was at the top.
12
+ */
13
+ const anchoredTop = (top, before, after) => {
14
+ const row = top / before.rowHeight;
15
+ if (before.columns === after.columns) return row * after.rowHeight;
16
+ return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
17
+ };
18
+ //#endregion
19
+ export { anchoredTop, cardColumns, cardWidth, recordRange };
@@ -2,11 +2,14 @@ import { isStoredFile } from "../../live/file-values.js";
2
2
  import { createWhere, printFilter } from "@protobase/schema";
3
3
  //#region src/app/displays/card-photos.ts
4
4
  const where = createWhere();
5
- /** The list request for a card's photos: the records pointing at the card's, in the display's order, with only their image. */
6
- const cardPhotosRequest = (photos, recordKey) => ({
5
+ /**
6
+ * The list request for a card's photos: the records pointing at the card's, in the display's order, with only their
7
+ * image and its placeholder field, when it has one.
8
+ */
9
+ const cardPhotosRequest = (photos, recordKey, placeholder) => ({
7
10
  filter: printFilter(where.eq(photos.field, recordKey)),
8
11
  ...photos.sort !== void 0 && { orderBy: photos.sort },
9
- fields: [photos.image],
12
+ fields: placeholder === void 0 ? [photos.image] : [photos.image, placeholder],
10
13
  pageSize: 100
11
14
  });
12
15
  /** The files a card steps through: its own image first, then the loaded photos less that same file; empty values are left out. */
@@ -1,12 +1,14 @@
1
+ import { cn } from "../../lib/cn.js";
1
2
  import { Skeleton } from "../../primitives/skeleton.js";
2
3
  import { humanize } from "../../live/naming.js";
3
- import { DisplayFooter, DisplayState } from "./display-frame.js";
4
4
  import { RecordCard } from "./record-card.js";
5
+ import { DisplayFooter, DisplayState } from "./display-frame.js";
5
6
  import { jsx, jsxs } from "react/jsx-runtime";
6
7
  //#region src/app/displays/cards-display.tsx
7
8
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
8
- /** A card per record (see `RecordCard`), in a grid, with how many match and a button for more below. */
9
- const CardsDisplay = ({ model, view, display, list }) => {
9
+ const scroller = "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1";
10
+ /** A card per record (see `RecordCard`), in a grid or a row, with how many match and a button for more below. */
11
+ const CardsDisplay = ({ model, view, display, list, row = false, footer = true }) => {
10
12
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
11
13
  return /* @__PURE__ */ jsxs(DisplayState, {
12
14
  list,
@@ -16,15 +18,18 @@ const CardsDisplay = ({ model, view, display, list }) => {
16
18
  children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
17
19
  }),
18
20
  children: [/* @__PURE__ */ jsx("ul", {
19
- className: grid,
21
+ className: row ? scroller : grid,
20
22
  "aria-label": view?.names?.plural ?? humanize(model.name),
21
- children: list.rows.map((row) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(RecordCard, {
22
- model,
23
- view,
24
- display,
25
- row
26
- }) }, row.key))
27
- }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
23
+ children: list.rows.map((record) => /* @__PURE__ */ jsx("li", {
24
+ className: cn(row && "w-64 shrink-0 snap-start"),
25
+ children: /* @__PURE__ */ jsx(RecordCard, {
26
+ model,
27
+ view,
28
+ display,
29
+ row: record
30
+ })
31
+ }, record.key))
32
+ }), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
28
33
  });
29
34
  };
30
35
  //#endregion
@@ -0,0 +1,50 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { displayRow } from "./list-displays.js";
4
+ import { ErrorBanner } from "../error-banner.js";
5
+ import { RecordCard } from "./record-card.js";
6
+ import { VirtualCards } from "./virtual-cards.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/displays/list-cards.tsx
9
+ const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
10
+ const pictureHeight = (width) => width * .75;
11
+ const plainHeight = 44;
12
+ /**
13
+ * The list page's cards display: a card per record (see `RecordCard`), scrolled like the table, with which of the
14
+ * matching records are in view below. An embedded list, which shows a few cards whole, draws them with `CardsDisplay`.
15
+ */
16
+ const ListCards = ({ model, view, display, list }) => {
17
+ const plural = view?.names?.plural ?? humanize(model.name);
18
+ const pictured = display.card === void 0 && (display.image !== void 0 || display.photos !== void 0);
19
+ if (list.isLoading) return /* @__PURE__ */ jsx("div", {
20
+ className: `min-h-0 flex-1 overflow-hidden ${grid}`,
21
+ children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
22
+ });
23
+ if (list.error) return /* @__PURE__ */ jsx(ErrorBanner, {
24
+ title: "Could not load rows",
25
+ error: list.error
26
+ });
27
+ if (!list.source || list.source.total === 0) return /* @__PURE__ */ jsxs("p", {
28
+ className: "py-8 text-center text-[13px] text-muted-foreground",
29
+ children: [
30
+ "No ",
31
+ plural.toLowerCase(),
32
+ " match."
33
+ ]
34
+ });
35
+ return /* @__PURE__ */ jsx(VirtualCards, {
36
+ source: list.source,
37
+ label: plural,
38
+ initialIndex: list.initialIndex,
39
+ onScrollIndex: list.onScrollIndex,
40
+ minHeight: (width) => (pictured ? pictureHeight(width) : 0) + plainHeight,
41
+ children: (row) => /* @__PURE__ */ jsx(RecordCard, {
42
+ model,
43
+ view,
44
+ display,
45
+ row: displayRow(model, row)
46
+ })
47
+ });
48
+ };
49
+ //#endregion
50
+ export { ListCards };
@@ -6,10 +6,11 @@ import { encodeKey, tableDisplay } from "@protobase/schema";
6
6
  const displaysOf = (view) => view?.list?.displays ?? [];
7
7
  /**
8
8
  * The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
9
- * in one: the view's default when that is a cards display, else the first. Otherwise the view's default, or the table.
9
+ * in one: `preferred` or the view's default when that is a cards display, else the first. Otherwise `preferred`, the
10
+ * view's default, or the table.
10
11
  */
11
- const defaultDisplay = (view, xs) => {
12
- const fallback = view?.list?.default ?? tableDisplay;
12
+ const defaultDisplay = (view, xs, preferred) => {
13
+ const fallback = preferred ?? view?.list?.default ?? tableDisplay;
13
14
  if (!xs) return fallback;
14
15
  const cards = displaysOf(view).filter((display) => display.kind === "cards");
15
16
  return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
@@ -1,48 +1,77 @@
1
- import { Skeleton } from "../../primitives/skeleton.js";
1
+ import { Button } from "../../primitives/button.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { mapCards } from "./list-displays.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
+ import { ErrorBanner } from "../error-banner.js";
5
6
  import { Problem } from "../pages/problem.js";
6
- import { DisplayFooter, DisplayState } from "./display-frame.js";
7
7
  import { RecordCard } from "./record-card.js";
8
+ import { useDebounced } from "../../lib/use-debounced.js";
9
+ import { boundsAround, worldArea } from "../map/map-area.js";
8
10
  import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
11
+ import { useMapRecords } from "./use-map-records.js";
9
12
  import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
10
13
  import { ChevronLeft, ChevronRight, X } from "lucide-react";
11
14
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
12
15
  //#region src/app/displays/map-display.tsx
13
16
  const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
17
+ const settleMs = 250;
14
18
  const arrow = "flex size-8 items-center justify-center rounded-full border border-border bg-background hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30";
15
19
  /**
16
- * The list's loaded records on a map, drawn with the map style `/meta` names. Choosing a marker shows the card of its
17
- * record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
18
- * elsewhere clears it. Below the map, how many of the matching records are on it and a button for more.
20
+ * The matching records in the map's view, drawn with the map style `/meta` names: the records themselves when few are
21
+ * 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
22
+ * pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
23
+ * its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
24
+ * elsewhere clears it.
19
25
  */
20
- const MapDisplay = ({ model, view, display, list }) => {
26
+ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true }) => {
21
27
  const style = useAdminMeta().maps?.style;
22
28
  const [chosen, setChosen] = useState();
29
+ const [area, setArea] = useState();
30
+ const settled = useDebounced(area, settleMs);
31
+ const [fitting, setFitting] = useState(at === void 0);
32
+ const records = useMapRecords({
33
+ model,
34
+ display,
35
+ fields,
36
+ filter,
37
+ orderBy,
38
+ area: fitting ? worldArea : settled
39
+ });
40
+ const loaded = records.isPlaceholderData ? void 0 : records.data;
41
+ const rows = records.data?.rows ?? [];
42
+ const buckets = records.data?.buckets ?? [];
43
+ const points = mapPoints(rows, display);
44
+ const fit = fitting && loaded ? boundsAround(mapPoints(loaded.rows, display), loaded.buckets) : void 0;
23
45
  const cards = mapCards(view, display);
24
46
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
25
- const selection = keptSelection(chosen, list.rows);
26
- const row = selection && list.rows.find((candidate) => candidate.key === selection.keys[selection.index]);
27
47
  const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
48
+ const selection = keptSelection(chosen, rows);
49
+ const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
28
50
  const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
51
+ const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
29
52
  const overlay = useRef(null);
30
53
  const [covered, setCovered] = useState(0);
31
54
  useLayoutEffect(() => setCovered(overlay.current?.offsetHeight ?? 0), [row?.key]);
32
55
  if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
33
- return /* @__PURE__ */ jsx(DisplayState, {
34
- list,
35
- empty: `No ${plural} match.`,
36
- loading: /* @__PURE__ */ jsx(Skeleton, { className: "h-96" }),
37
- children: /* @__PURE__ */ jsxs("div", {
38
- className: "flex h-full min-h-[36rem] flex-col sm:min-h-[28rem]",
39
- children: [/* @__PURE__ */ jsxs("div", {
40
- className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
41
- children: [/* @__PURE__ */ jsx(Suspense, {
56
+ const empty = loaded?.total === 0;
57
+ return /* @__PURE__ */ jsxs("div", {
58
+ className: "flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]",
59
+ children: [/* @__PURE__ */ jsxs("div", {
60
+ className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
61
+ children: [
62
+ /* @__PURE__ */ jsx(Suspense, {
42
63
  fallback: null,
43
64
  children: /* @__PURE__ */ jsx(RecordsMap, {
44
65
  style,
45
- points: mapPoints(list.rows, display),
66
+ initial: at,
67
+ points,
68
+ buckets,
69
+ fit,
70
+ onFitted: () => setFitting(false),
71
+ onMove: (next, center) => {
72
+ setArea(next);
73
+ onMove?.(center);
74
+ },
46
75
  selected: selection?.keys,
47
76
  covered: selection ? covered : 0,
48
77
  onSelect: (keys) => setChosen({
@@ -50,9 +79,30 @@ const MapDisplay = ({ model, view, display, list }) => {
50
79
  index: 0
51
80
  }),
52
81
  onClear: () => setChosen(void 0),
53
- markerLabel
82
+ markerLabel,
83
+ bucketLabel
84
+ })
85
+ }),
86
+ records.error && /* @__PURE__ */ jsx("div", {
87
+ className: "absolute inset-x-3 top-3 z-10",
88
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
89
+ title: "Could not load rows",
90
+ error: records.error
54
91
  })
55
- }), selection && row && /* @__PURE__ */ jsx("div", {
92
+ }),
93
+ empty && /* @__PURE__ */ jsx("div", {
94
+ className: "pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center",
95
+ children: /* @__PURE__ */ jsxs("p", {
96
+ className: "pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background px-4 py-1.5 text-[13px] text-muted-foreground shadow",
97
+ role: "status",
98
+ children: [fitting ? `No ${plural} match.` : `No ${plural} in this part of the map.`, !fitting && /* @__PURE__ */ jsx(Button, {
99
+ size: "sm",
100
+ onClick: () => setFitting(true),
101
+ children: "Show all"
102
+ })]
103
+ })
104
+ }),
105
+ selection && row && /* @__PURE__ */ jsx("div", {
56
106
  ref: overlay,
57
107
  className: "pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center",
58
108
  children: /* @__PURE__ */ jsxs("div", {
@@ -111,10 +161,20 @@ const MapDisplay = ({ model, view, display, list }) => {
111
161
  })
112
162
  })]
113
163
  })
114
- })]
115
- }), /* @__PURE__ */ jsx(DisplayFooter, { list })]
116
- })
164
+ })
165
+ ]
166
+ }), footer && /* @__PURE__ */ jsx("p", {
167
+ className: "py-3 text-xs text-muted-foreground",
168
+ children: inView(records.data, plural)
169
+ })]
117
170
  });
118
171
  };
172
+ /** How many of the matching records are in view, and how many the map shows when it cannot show them all. */
173
+ const inView = (data, plural) => {
174
+ if (!data) return "\xA0";
175
+ const total = data.total.toLocaleString("en-IE");
176
+ if (data.buckets.length === 0 && data.rows.length < data.total) return `${data.rows.length.toLocaleString("en-IE")} of ${total} ${plural} in view`;
177
+ return `${total} ${plural} in view`;
178
+ };
119
179
  //#endregion
120
180
  export { MapDisplay };
@@ -8,13 +8,6 @@ const mapPoints = (rows, fields) => rows.flatMap((row) => {
8
8
  ...position
9
9
  }] : [];
10
10
  });
11
- /** The south-west and north-east corners around the points, as `[longitude, latitude]`, or none without points. */
12
- const pointBounds = (points) => {
13
- if (points.length === 0) return void 0;
14
- const longitudes = points.map((point) => point.longitude);
15
- const latitudes = points.map((point) => point.latitude);
16
- return [[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]];
17
- };
18
11
  /**
19
12
  * The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
20
13
  * of it, else starts its own. A marker is at its first point, so markers do not move as others join them.
@@ -52,4 +45,4 @@ const stepSelection = (selection, step) => ({
52
45
  index: ((selection.index + step) % selection.keys.length + selection.keys.length) % selection.keys.length
53
46
  });
54
47
  //#endregion
55
- export { groupPoints, keptSelection, mapPoints, pointBounds, stepSelection };
48
+ export { groupPoints, keptSelection, mapPoints, stepSelection };
@@ -1,10 +1,11 @@
1
1
  import { Badge } from "../../primitives/badge.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { useProjectUi } from "../pages/project-ui.js";
4
- import { fileShown, isPreviewable } from "../../live/file-values.js";
4
+ import { shownImage } from "../../live/file-values.js";
5
5
  import { displayValue } from "../../live/display-value.js";
6
6
  import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
7
7
  import { AppLink } from "../use-app-address.js";
8
+ import { FileImage } from "../../live/file-image.js";
8
9
  import { shownWidget } from "../field-types/shown-widget.js";
9
10
  import { CustomBoundary } from "../pages/custom-boundary.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
@@ -14,13 +15,14 @@ import { CardCarousel } from "./card-carousel.js";
14
15
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
15
16
  //#region src/app/displays/record-card.tsx
16
17
  const CardImage = ({ value }) => {
17
- const file = fileShown(value);
18
- const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
18
+ const image = shownImage(value, {
19
+ width: 320,
20
+ height: 240
21
+ });
19
22
  return /* @__PURE__ */ jsx("span", {
20
23
  className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
21
- children: url && /* @__PURE__ */ jsx("img", {
22
- src: url,
23
- alt: "",
24
+ children: image && /* @__PURE__ */ jsx(FileImage, {
25
+ image,
24
26
  loading: "lazy",
25
27
  className: "size-full object-cover"
26
28
  })
@@ -0,0 +1,44 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { withLabels } from "../../live/relation-labels.js";
4
+ import { displayRow } from "./list-displays.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { areaFilter, requestZoom } from "../map/map-area.js";
7
+ import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ //#region src/app/displays/use-map-records.ts
9
+ /**
10
+ * The records in the map's area through the list API's `:map`, labelled for the card, or buckets of them when it holds
11
+ * many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
12
+ * left is never shown.
13
+ */
14
+ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
15
+ const client = useClient();
16
+ const queryClient = useQueryClient();
17
+ const { resources, views } = useAdminMeta();
18
+ const position = {
19
+ latitude: display.latitude,
20
+ longitude: display.longitude
21
+ };
22
+ const inside = area ? areaFilter(filter, position, area.bounds) : "";
23
+ const zoom = area ? requestZoom(area.zoom) : 0;
24
+ return useQuery({
25
+ queryKey: keys.map(model.name, `${position.latitude},${position.longitude}`, zoom, inside, orderBy),
26
+ enabled: area !== void 0,
27
+ placeholderData: keepPreviousData,
28
+ queryFn: async () => {
29
+ const result = await client.map(model.name, {
30
+ ...position,
31
+ zoom,
32
+ filter: inside,
33
+ orderBy
34
+ });
35
+ return {
36
+ rows: (await withLabels(queryClient, client, resources, views, model, fields, result.items)).map((record) => displayRow(model, record)),
37
+ buckets: result.buckets,
38
+ total: result.totalSize
39
+ };
40
+ }
41
+ });
42
+ };
43
+ //#endregion
44
+ export { useMapRecords };
@@ -0,0 +1,138 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
3
+ import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
4
+ import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
5
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/displays/virtual-cards.tsx
8
+ /**
9
+ * Cards in rows as wide as the list, scrolled like the table: only the rows in view are drawn, over a scroller that
10
+ * holds any number of records, and the records are loaded in the table's pages, so a change of width keeps the loaded
11
+ * pages and the first record in view. Every card in a width is as tall as the tallest drawn so far.
12
+ */
13
+ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
14
+ const { total } = source;
15
+ const [width, setWidth] = useState(0);
16
+ const columns = cardColumns(width);
17
+ const itemWidth = cardWidth(width, columns);
18
+ const [measured, setMeasured] = useState({
19
+ width: 0,
20
+ height: 0
21
+ });
22
+ const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
23
+ const rowHeight = itemHeight + 12;
24
+ const layout = {
25
+ columns,
26
+ rowHeight
27
+ };
28
+ const anchor = useRef({
29
+ layout,
30
+ top: 0
31
+ });
32
+ const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
33
+ const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
34
+ const scroll = useVirtualRows({
35
+ total: Math.ceil(total / columns),
36
+ rowHeight,
37
+ initialIndex: Math.floor(initialIndex / columns),
38
+ settle
39
+ });
40
+ useLayoutEffect(() => {
41
+ if (changed) scroll.scrollTo(scroll.top);
42
+ anchor.current = {
43
+ layout,
44
+ top: scroll.top
45
+ };
46
+ if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
47
+ });
48
+ const range = recordRange(scroll.first, scroll.last, columns, total);
49
+ const getRow = usePagedRows({
50
+ total,
51
+ rowAt: source.rowAt,
52
+ loadRows: source.loadRows,
53
+ latencyMs: 0
54
+ }, range.start, range.end);
55
+ const bodyRef = useRef(null);
56
+ useLayoutEffect(() => {
57
+ const body = bodyRef.current;
58
+ if (!body) return;
59
+ const observer = new ResizeObserver(() => setWidth(body.clientWidth));
60
+ observer.observe(body);
61
+ setWidth(body.clientWidth);
62
+ return () => observer.disconnect();
63
+ }, []);
64
+ const widthRef = useRef(itemWidth);
65
+ widthRef.current = itemWidth;
66
+ const [observer] = useState(() => new ResizeObserver((entries) => {
67
+ const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
68
+ const at = widthRef.current;
69
+ setMeasured((current) => current.width === at && current.height >= tallest ? current : {
70
+ width: at,
71
+ height: Math.max(tallest, current.width === at ? current.height : 0)
72
+ });
73
+ }));
74
+ useLayoutEffect(() => () => observer.disconnect(), [observer]);
75
+ const observe = useCallback((item) => {
76
+ if (!item) return;
77
+ observer.observe(item);
78
+ return () => observer.unobserve(item);
79
+ }, [observer]);
80
+ const firstShown = Math.min(total, Math.floor(scroll.top / rowHeight) * columns + 1);
81
+ const lastShown = Math.min(total, (Math.floor((scroll.top + Math.max(0, scroll.viewport - 1)) / rowHeight) + 1) * columns);
82
+ return /* @__PURE__ */ jsxs("div", {
83
+ className: "flex min-h-0 flex-1 flex-col",
84
+ children: [/* @__PURE__ */ jsx("div", {
85
+ ref: scroll.ref,
86
+ onScroll: scroll.onScroll,
87
+ className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
88
+ children: /* @__PURE__ */ jsx("div", {
89
+ style: { height: scroll.geometry.contentHeight || void 0 },
90
+ children: /* @__PURE__ */ jsx("div", {
91
+ ref: bodyRef,
92
+ className: "sticky top-0 overflow-clip",
93
+ style: { height: scroll.viewport },
94
+ children: /* @__PURE__ */ jsx("ul", {
95
+ className: "relative",
96
+ "aria-label": label,
97
+ children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
98
+ const index = rowIndex * columns + column;
99
+ if (index >= total) return [];
100
+ const row = getRow(index);
101
+ const place = {
102
+ top: offset,
103
+ left: column * (itemWidth + 12),
104
+ width: itemWidth,
105
+ minHeight: itemHeight
106
+ };
107
+ if (!row) return /* @__PURE__ */ jsx("li", {
108
+ "aria-hidden": true,
109
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
110
+ style: place,
111
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
112
+ }, `skeleton-${index}`);
113
+ return /* @__PURE__ */ jsx("li", {
114
+ ref: observe,
115
+ "aria-posinset": index + 1,
116
+ "aria-setsize": total,
117
+ className: "absolute grid grid-cols-[minmax(0,1fr)]",
118
+ style: place,
119
+ children: children(row)
120
+ }, row.id);
121
+ }))
122
+ })
123
+ })
124
+ })
125
+ }), /* @__PURE__ */ jsxs("div", {
126
+ className: "py-3 text-xs tabular-nums text-muted-foreground",
127
+ children: [
128
+ firstShown.toLocaleString("en-IE"),
129
+ "–",
130
+ lastShown.toLocaleString("en-IE"),
131
+ " of ",
132
+ total.toLocaleString("en-IE")
133
+ ]
134
+ })]
135
+ });
136
+ };
137
+ //#endregion
138
+ export { VirtualCards };