@protobase/ui 0.14.1 → 0.14.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 (61) hide show
  1. package/dist/app/current-address.js +19 -1
  2. package/dist/app/displays/card-carousel.js +47 -39
  3. package/dist/app/displays/card-image.js +25 -0
  4. package/dist/app/displays/card-photos.js +11 -15
  5. package/dist/app/displays/cards-display.js +2 -1
  6. package/dist/app/displays/list-displays.d.ts +2 -0
  7. package/dist/app/displays/list-displays.js +9 -3
  8. package/dist/app/displays/map-display.js +5 -4
  9. package/dist/app/displays/record-card.js +28 -34
  10. package/dist/app/displays/use-map-records.js +8 -6
  11. package/dist/app/embedded-list/embedded-columns.js +1 -1
  12. package/dist/app/embedded-list/use-embedded-rows.js +11 -6
  13. package/dist/app/list-page.js +44 -11
  14. package/dist/app/menu-icons.js +3 -1
  15. package/dist/app/named-icon.js +5 -3
  16. package/dist/app/nav-from-meta.js +21 -1
  17. package/dist/app/pages/blocks/card-row.js +1 -1
  18. package/dist/app/pages/blocks/modal-form.js +1 -1
  19. package/dist/app/pages/blocks/record-card.js +1 -1
  20. package/dist/app/pages/blocks/show.js +1 -1
  21. package/dist/app/pages/blocks/stat.js +1 -1
  22. package/dist/app/pages/use-run-action.js +1 -1
  23. package/dist/app/saved/favorite-button.js +49 -0
  24. package/dist/app/saved/saved-list.js +43 -0
  25. package/dist/app/saved/saved-records.js +38 -0
  26. package/dist/app/saved/use-favorite.js +62 -0
  27. package/dist/app/saved/use-saved-records.js +36 -0
  28. package/dist/app/use-app-address.js +5 -2
  29. package/dist/auth/provider-icon.js +15 -0
  30. package/dist/auth/sign-in-form.js +6 -1
  31. package/dist/auth/sign-in-prompt.d.ts +22 -0
  32. package/dist/auth/sign-in-prompt.js +84 -0
  33. package/dist/data/api-provider.d.ts +2 -0
  34. package/dist/data/meta-data.d.ts +3 -1
  35. package/dist/data/query-keys.js +0 -5
  36. package/dist/data/use-meta.js +1 -0
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +3 -1
  39. package/dist/live/grid-loader.js +8 -3
  40. package/dist/live/reference-options.js +2 -2
  41. package/dist/live/relation-labels.js +16 -18
  42. package/dist/live/row-photos.js +18 -0
  43. package/dist/live/use-filter-config.js +7 -2
  44. package/dist/package.js +1 -1
  45. package/dist/primitives/button.js +7 -2
  46. package/dist/primitives/dialog.js +1 -1
  47. package/dist/primitives/responsive-dialog.d.ts +17 -0
  48. package/dist/primitives/responsive-dialog.js +44 -0
  49. package/dist/shell/assets/app-BkySvmpb.js +66 -0
  50. package/dist/shell/assets/index-BXYHpFoe.css +2 -0
  51. package/dist/shell/assets/{index-BCLK6g9a.js → index-Ddh359yv.js} +1 -1
  52. package/dist/shell/assets/{maplibre-gl-DWx1yzYO.js → maplibre-gl-Du00jL_c.js} +1 -1
  53. package/dist/shell/assets/{record-map-C2M8dEjy.js → record-map-82NQGiUy.js} +2 -2
  54. package/dist/shell/assets/{records-map-huS0J9xV.js → records-map-BC0FGfLG.js} +1 -1
  55. package/dist/shell/assets/shared-protobase-ui-CVi8JFzf.js +1 -0
  56. package/dist/shell/index.html +4 -4
  57. package/dist/shell/protobase.shell.json +1 -1
  58. package/package.json +4 -4
  59. package/dist/shell/assets/app-Dkz6z_t_.js +0 -66
  60. package/dist/shell/assets/index-BQ4FIU4i.css +0 -2
  61. package/dist/shell/assets/shared-protobase-ui-CsJxPCx6.js +0 -1
@@ -2,6 +2,24 @@ import { defaultDisplay } from "./displays/list-displays.js";
2
2
  import { defaultOrderBy } from "./list-order.js";
3
3
  import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/current-address.ts
5
+ /**
6
+ * The resource whose records a list shows, with its view: the list's own, or, when its view has `listOf`, the resource
7
+ * that relation points at, if the caller can see it.
8
+ */
9
+ const listedResource = (resources, views, resource) => {
10
+ const model = resources[resource];
11
+ const listOf = views[resource]?.listOf;
12
+ const target = listOf === void 0 ? void 0 : model?.fields[listOf]?.relation?.resource;
13
+ return target !== void 0 && resources[target] ? {
14
+ model: resources[target],
15
+ view: views[target],
16
+ listOf: true
17
+ } : {
18
+ model,
19
+ view: views[resource],
20
+ listOf: false
21
+ };
22
+ };
5
23
  /** What a list shows when its address names nothing: its default display for the screen, the list's sort, the filters in a bar. */
6
24
  const listDefaults = (model, view, xs = false) => ({
7
25
  display: defaultDisplay(view, xs),
@@ -40,4 +58,4 @@ const changedList = (read, change, defaults) => {
40
58
  };
41
59
  };
42
60
  //#endregion
43
- export { changedList, currentAddress, listDefaults };
61
+ export { changedList, currentAddress, listDefaults, listedResource };
@@ -1,46 +1,58 @@
1
1
  import { cn } from "../../lib/cn.js";
2
- import { keys } from "../../data/query-keys.js";
3
- import { useClient } from "../../data/api-provider.js";
4
- import { useAdminMeta } from "../meta-gate.js";
5
- import { cardPhotoList, cardPhotosRequest, shownPhoto } from "./card-photos.js";
6
- import { useState } from "react";
2
+ import { CardImageFrame, CardPhoto } from "./card-image.js";
3
+ import { cardPhotoList, scrolledPhoto, shownPhoto } from "./card-photos.js";
4
+ import { useRef, useState } from "react";
7
5
  import { ChevronLeft, ChevronRight } from "lucide-react";
8
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
- import { useQuery } from "@tanstack/react-query";
10
7
  //#region src/app/displays/card-carousel.tsx
11
- const arrow = "pointer-events-auto absolute top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm opacity-0 transition-opacity group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 [@media(hover:none)]:opacity-100";
8
+ const arrow = "pointer-events-auto absolute top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm opacity-0 transition-opacity group-has-[[data-card-photos]:hover]/card:opacity-100 group-hover/arrows:opacity-100 group-has-[:focus-visible]/arrows:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30";
9
+ const touchArrow = "[@media(hover:none)]:opacity-100";
12
10
  /**
13
- * A card whose picture steps through its photos with arrows, the card's own image first. The photos load when an arrow
14
- * is first pressed, so the list loads as it does without them. The arrows sit over the card's link, beside it in the
15
- * document, so pressing one does not open the record; the card's list item must be `relative group/card`.
11
+ * A card whose picture steps through its photos, the card's own image first: a track that snaps photo by photo, swiped
12
+ * on a touch screen or moved by arrows, with dots marking the one in view. The photos come with the card's row, in the
13
+ * same answer as the list, so the dots mark its own image from the start. The track sits inside the card's link, so a
14
+ * tap opens the record and a swipe does not; the arrows sit over it, beside the link in the document, so pressing one
15
+ * does not open the record either. The card's list item must be `relative group/card`.
16
16
  */
17
- const CardCarousel = ({ photos, recordKey, cover, children }) => {
18
- const client = useClient();
19
- const [opened, setOpened] = useState(false);
20
- const [step, setStep] = useState(0);
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);
24
- const loaded = useQuery({
25
- queryKey: keys.list(photos.resource, request.filter, request.orderBy ?? "", request.pageSize, `card-photos:${photos.image}`),
26
- enabled: opened,
27
- queryFn: async () => (await client.list(photos.resource, request)).items.map((item) => item[photos.image])
28
- });
29
- const list = cardPhotoList(cover, loaded.data ?? []);
30
- const index = shownPhoto(step, list.length);
17
+ const CardCarousel = ({ photos, cover, swipe = true, children }) => {
18
+ const track = useRef(null);
19
+ const aim = useRef(void 0);
20
+ const [index, setIndex] = useState(0);
21
+ const list = cardPhotoList(cover, photos);
22
+ const show = (position) => {
23
+ const scroller = track.current;
24
+ if (scroller) scroller.scrollTo({ left: position * scroller.clientWidth });
25
+ };
31
26
  const press = (by) => {
32
- setOpened(true);
33
- setStep((current) => current + by);
27
+ aim.current = shownPhoto((aim.current ?? index) + by, list.length);
28
+ show(aim.current);
29
+ };
30
+ const touch = () => {
31
+ aim.current = void 0;
34
32
  };
35
- const single = loaded.data !== void 0 && list.length < 2;
36
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [children(list[index]), !single && /* @__PURE__ */ jsxs("div", {
37
- className: "pointer-events-none absolute inset-x-px top-px aspect-[4/3]",
38
- "aria-busy": loaded.isFetching,
33
+ const scrolled = (scroller) => {
34
+ setIndex(scrolledPhoto(scroller.scrollLeft, scroller.clientWidth, list.length));
35
+ if (aim.current !== void 0 && Math.abs(scroller.scrollLeft - aim.current * scroller.clientWidth) < 1) aim.current = void 0;
36
+ };
37
+ const picture = /* @__PURE__ */ jsx(CardImageFrame, { children: /* @__PURE__ */ jsx("span", {
38
+ ref: track,
39
+ tabIndex: -1,
40
+ "data-card-photos": true,
41
+ onPointerDown: touch,
42
+ onScroll: (event) => scrolled(event.currentTarget),
43
+ className: cn("flex size-full snap-x snap-mandatory outline-none [scrollbar-width:none] motion-safe:scroll-smooth [&::-webkit-scrollbar]:hidden", swipe ? "overflow-x-auto overscroll-x-contain" : "overflow-x-hidden"),
44
+ children: list.map((photo, position) => /* @__PURE__ */ jsx("span", {
45
+ className: "block size-full shrink-0 snap-start snap-always",
46
+ children: /* @__PURE__ */ jsx(CardPhoto, { value: photo })
47
+ }, position))
48
+ }) });
49
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [children(picture), list.length > 1 && /* @__PURE__ */ jsxs("div", {
50
+ className: "group/arrows pointer-events-none absolute inset-x-px top-px aspect-[4/3]",
39
51
  children: [
40
52
  /* @__PURE__ */ jsx("button", {
41
53
  type: "button",
42
54
  "aria-label": "Previous photo",
43
- className: cn(arrow, "left-2"),
55
+ className: cn(arrow, !swipe && touchArrow, "left-2"),
44
56
  onClick: () => press(-1),
45
57
  children: /* @__PURE__ */ jsx(ChevronLeft, {
46
58
  className: "size-4",
@@ -50,27 +62,23 @@ const CardCarousel = ({ photos, recordKey, cover, children }) => {
50
62
  /* @__PURE__ */ jsx("button", {
51
63
  type: "button",
52
64
  "aria-label": "Next photo",
53
- className: cn(arrow, "right-2"),
65
+ className: cn(arrow, !swipe && touchArrow, "right-2"),
54
66
  onClick: () => press(1),
55
67
  children: /* @__PURE__ */ jsx(ChevronRight, {
56
68
  className: "size-4",
57
69
  "aria-hidden": true
58
70
  })
59
71
  }),
60
- loaded.error && /* @__PURE__ */ jsx("span", {
61
- className: "absolute inset-x-2 top-2 rounded bg-background/85 px-1.5 py-0.5 text-center text-xs text-danger-text",
62
- children: "Could not load the photos"
63
- }),
64
- loaded.data !== void 0 && /* @__PURE__ */ jsxs("span", {
72
+ /* @__PURE__ */ jsxs("span", {
65
73
  className: "absolute inset-x-0 bottom-2 flex justify-center",
66
74
  "aria-live": "polite",
67
75
  children: [/* @__PURE__ */ jsx("span", {
68
76
  className: "sr-only",
69
77
  children: `Photo ${index + 1} of ${list.length}`
70
78
  }), /* @__PURE__ */ jsx("span", {
71
- className: "flex items-center gap-1 rounded-full bg-background/70 px-1.5 py-1 text-[11px] tabular-nums leading-none",
79
+ className: "flex items-center gap-1 rounded-full bg-background/70 px-1.5 py-1 leading-none",
72
80
  "aria-hidden": true,
73
- children: list.length > 10 ? `${index + 1} / ${list.length}` : list.map((_, dot) => /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full", dot === index ? "bg-foreground" : "bg-foreground/35") }, dot))
81
+ children: list.map((_, dot) => /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full", dot === index ? "bg-foreground" : "bg-foreground/35") }, dot))
74
82
  })]
75
83
  })
76
84
  ]
@@ -0,0 +1,25 @@
1
+ import { shownImage } from "../../live/file-values.js";
2
+ import { FileImage } from "../../live/file-image.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/app/displays/card-image.tsx
5
+ /** A raster image filling its frame; nothing for any other value. */
6
+ const CardPhoto = ({ value }) => {
7
+ const image = shownImage(value, {
8
+ width: 320,
9
+ height: 240
10
+ });
11
+ return image ? /* @__PURE__ */ jsx(FileImage, {
12
+ image,
13
+ loading: "lazy",
14
+ className: "size-full object-cover"
15
+ }) : null;
16
+ };
17
+ /** The frame across a card's top, from the edge of the card in, holding its picture. */
18
+ const CardImageFrame = ({ children }) => /* @__PURE__ */ jsx("span", {
19
+ className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
20
+ children
21
+ });
22
+ /** A picture across the card's top: a raster image only, an empty frame otherwise. */
23
+ const CardImage = ({ value }) => /* @__PURE__ */ jsx(CardImageFrame, { children: /* @__PURE__ */ jsx(CardPhoto, { value }) });
24
+ //#endregion
25
+ export { CardImage, CardImageFrame, CardPhoto };
@@ -1,24 +1,20 @@
1
1
  import { isStoredFile } from "../../live/file-values.js";
2
- import { createWhere, printFilter } from "@protobase/schema";
2
+ import { cardPhotoLimit } from "@protobase/schema/internal";
3
3
  //#region src/app/displays/card-photos.ts
4
- const where = createWhere();
4
+ /** The most photos a card steps through, its own image among them, each marked by a dot. */
5
+ const maxCardDots = cardPhotoLimit;
5
6
  /**
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.
7
+ * The files a card steps through: its own image first, then its photos less that same file, at most `maxCardDots`;
8
+ * empty values are left out.
8
9
  */
9
- const cardPhotosRequest = (photos, recordKey, placeholder) => ({
10
- filter: printFilter(where.eq(photos.field, recordKey)),
11
- ...photos.sort !== void 0 && { orderBy: photos.sort },
12
- fields: placeholder === void 0 ? [photos.image] : [photos.image, placeholder],
13
- pageSize: 100
14
- });
15
- /** The files a card steps through: its own image first, then the loaded photos less that same file; empty values are left out. */
16
- const cardPhotoList = (cover, loaded) => {
10
+ const cardPhotoList = (cover, photos) => {
17
11
  const coverUri = isStoredFile(cover) ? cover.uri : void 0;
18
- const rest = loaded.filter((photo) => isStoredFile(photo) && photo.uri !== coverUri);
19
- return coverUri === void 0 ? rest : [cover, ...rest];
12
+ const rest = photos.filter((photo) => isStoredFile(photo) && photo.uri !== coverUri);
13
+ return (coverUri === void 0 ? rest : [cover, ...rest]).slice(0, maxCardDots);
20
14
  };
21
15
  /** The position shown after `step` presses forward, or back when negative, wrapping around both ends. */
22
16
  const shownPhoto = (step, count) => count === 0 ? 0 : (step % count + count) % count;
17
+ /** The position a track of `count` photos, each `width` wide, shows when scrolled to `left`: the one mostly in view. */
18
+ const scrolledPhoto = (left, width, count) => width <= 0 || count === 0 ? 0 : Math.min(count - 1, Math.max(0, Math.round(left / width)));
23
19
  //#endregion
24
- export { cardPhotoList, cardPhotosRequest, shownPhoto };
20
+ export { cardPhotoList, maxCardDots, scrolledPhoto, shownPhoto };
@@ -26,7 +26,8 @@ const CardsDisplay = ({ model, view, display, list, row = false, footer = true }
26
26
  model,
27
27
  view,
28
28
  display,
29
- row: record
29
+ row: record,
30
+ swipe: !row
30
31
  })
31
32
  }, record.key))
32
33
  }), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
@@ -7,6 +7,8 @@ export type ListDisplayRow = {
7
7
  record: Record<string, unknown>;
8
8
  /** The name of the record a relation field points at, for the relations the list shows. */
9
9
  labels: Record<string, string>;
10
+ /** The files of its first photos, when the list loaded the photos its cards display names. */
11
+ photos?: unknown[];
10
12
  };
11
13
  /** What a card component of a cards display (`d.cards(..., { card })`) gets: the record it draws, of the display's fields. */
12
14
  export type RecordCardProps = {
@@ -1,6 +1,7 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { displayValue } from "../../live/display-value.js";
4
+ import { photosKey } from "../../live/row-photos.js";
4
5
  import { encodeKey, tableDisplay } from "@protobase/schema/internal";
5
6
  //#region src/app/displays/list-displays.ts
6
7
  const displaysOf = (view) => view?.list?.displays ?? [];
@@ -91,12 +92,16 @@ const loadedFields = (model, view, columns) => [.../* @__PURE__ */ new Set([...c
91
92
  /** The sortable fields a display shows, for the sort menu that stands in for column headers. */
92
93
  const sortFields = (model, view, display) => [...new Set(shownFields(model, view, display))].filter((name) => model.fields[name]?.sortable);
93
94
  const isLabelled = (value) => typeof value === "object" && value !== null && "id" in value && "name" in value;
94
- /** A loaded row, its relations as `{ id, name }`, split into the record as the API gives it and the relations' names. */
95
+ /**
96
+ * A loaded row, its relations as `{ id, name }`, split into the record as the API gives it, the relations' names and
97
+ * the photos it was loaded with.
98
+ */
95
99
  const displayRow = (model, row) => {
96
100
  const record = {};
97
101
  const labels = {};
102
+ const photos = Array.isArray(row["$photos"]) ? row[photosKey] : void 0;
98
103
  for (const [name, value] of Object.entries(row)) {
99
- if (name === "id" && !Object.hasOwn(model.fields, "id")) continue;
104
+ if ((name === "id" || name === "$photos") && !Object.hasOwn(model.fields, name)) continue;
100
105
  if (model.fields[name]?.type === "relation" && isLabelled(value)) {
101
106
  record[name] = value.id;
102
107
  labels[name] = value.name;
@@ -105,7 +110,8 @@ const displayRow = (model, row) => {
105
110
  return {
106
111
  key: encodeKey(recordKey(model, record)),
107
112
  record,
108
- labels
113
+ labels,
114
+ ...photos && { photos }
109
115
  };
110
116
  };
111
117
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { cn } from "../../lib/cn.js";
1
2
  import { Button } from "../../primitives/button.js";
2
3
  import { humanize } from "../../live/naming.js";
3
4
  import { mapCards } from "./list-displays.js";
@@ -23,7 +24,7 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
23
24
  * its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
24
25
  * elsewhere clears it.
25
26
  */
26
- const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true }) => {
27
+ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true, flush = false }) => {
27
28
  const style = useAdminMeta().maps?.style;
28
29
  const [chosen, setChosen] = useState();
29
30
  const [area, setArea] = useState();
@@ -55,9 +56,9 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
55
56
  if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
56
57
  const empty = loaded?.total === 0;
57
58
  return /* @__PURE__ */ jsxs("div", {
58
- className: "flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]",
59
+ className: cn("flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]", flush && "max-md:-mx-5 max-md:-mb-4 max-md:min-h-0"),
59
60
  children: [/* @__PURE__ */ jsxs("div", {
60
- className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
61
+ className: cn("relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted", flush && "max-md:rounded-none max-md:border-0"),
61
62
  children: [
62
63
  /* @__PURE__ */ jsx(Suspense, {
63
64
  fallback: null,
@@ -164,7 +165,7 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
164
165
  })
165
166
  ]
166
167
  }), footer && /* @__PURE__ */ jsx("p", {
167
- className: "py-3 text-xs text-muted-foreground",
168
+ className: "hidden py-3 text-xs text-muted-foreground md:block",
168
169
  children: inView(records.data, plural)
169
170
  })]
170
171
  });
@@ -1,33 +1,20 @@
1
1
  import { Badge } from "../../primitives/badge.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
2
3
  import { humanize } from "../../live/naming.js";
3
4
  import { useProjectUi } from "../pages/project-ui.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";
9
8
  import { shownWidget } from "../field-types/shown-widget.js";
10
9
  import { CustomBoundary } from "../pages/custom-boundary.js";
11
10
  import { WidgetPart } from "../field-types/widget-part.js";
12
11
  import { namedIcon } from "../named-icon.js";
12
+ import { FavoriteButton } from "../saved/favorite-button.js";
13
13
  import { ValueBadge } from "../value-badge.js";
14
+ import { CardImage } from "./card-image.js";
14
15
  import { CardCarousel } from "./card-carousel.js";
15
16
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
16
17
  //#region src/app/displays/record-card.tsx
17
- const CardImage = ({ value }) => {
18
- const image = shownImage(value, {
19
- width: 320,
20
- height: 240
21
- });
22
- return /* @__PURE__ */ jsx("span", {
23
- className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
24
- children: image && /* @__PURE__ */ jsx(FileImage, {
25
- image,
26
- loading: "lazy",
27
- className: "size-full object-cover"
28
- })
29
- });
30
- };
31
18
  const MetaValue = ({ icon, children }) => {
32
19
  const Icon = namedIcon(icon);
33
20
  return /* @__PURE__ */ jsxs("dd", {
@@ -39,13 +26,13 @@ const MetaValue = ({ icon, children }) => {
39
26
  });
40
27
  };
41
28
  /**
42
- * A record's card of a cards display, opening its page: its image across the top, with arrows through its photos when
43
- * the display names them, the title over the subtitle, the badge beside it, the first amount with a value in bold, small
44
- * meta fields below, those with a value, the labels of its flags that are true and the footer's fields with a value,
45
- * joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it is
46
- * missing or throws.
29
+ * A record's card of a cards display, opening its page: its image across the top, swiped or stepped through its photos
30
+ * when the display names them, the title over the subtitle, the badge beside it, the first amount with a value in bold,
31
+ * small meta fields below, those with a value, the labels of its flags that are true and the footer's fields with a
32
+ * value, joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it
33
+ * is missing or throws. With a favorite button, a heart in the top right corner saves the record.
47
34
  */
48
- const RecordCard = ({ model, view, display, row }) => {
35
+ const RecordCard = ({ model, view, display, row, swipe = true }) => {
49
36
  const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
50
37
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
51
38
  const text = (name) => {
@@ -96,10 +83,12 @@ const RecordCard = ({ model, view, display, row }) => {
96
83
  const cover = image === void 0 ? void 0 : row.record[image];
97
84
  const shownAmount = filledAmount(row, amount);
98
85
  const footing = filledMeta(row, footer);
99
- const builtIn = (photo) => /* @__PURE__ */ jsxs(Fragment, { children: [
100
- (image !== void 0 || photos !== void 0) && /* @__PURE__ */ jsx(CardImage, { value: photo }),
86
+ const pictured = image !== void 0 || photos !== void 0;
87
+ const favorite = display.favorite;
88
+ const builtIn = (picture) => /* @__PURE__ */ jsxs(Fragment, { children: [
89
+ pictured && picture,
101
90
  /* @__PURE__ */ jsxs("span", {
102
- className: "flex items-start justify-between gap-2",
91
+ className: `flex items-start justify-between gap-2 ${favorite && !pictured ? "pr-8" : ""}`,
103
92
  children: [/* @__PURE__ */ jsx("span", {
104
93
  className: "min-w-0 truncate font-medium",
105
94
  children: shownValue(title)
@@ -146,7 +135,7 @@ const RecordCard = ({ model, view, display, row }) => {
146
135
  }), shownValue(name)] }, name))
147
136
  })
148
137
  ] });
149
- const card = (photo) => /* @__PURE__ */ jsx(AppLink, {
138
+ const card = (picture) => /* @__PURE__ */ jsx(AppLink, {
150
139
  to: {
151
140
  kind: "record",
152
141
  resource: model.name,
@@ -155,18 +144,23 @@ const RecordCard = ({ model, view, display, row }) => {
155
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",
156
145
  children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
157
146
  name: display.card,
158
- fallback: builtIn(photo),
147
+ fallback: builtIn(picture),
159
148
  children: /* @__PURE__ */ jsx(Card, { row })
160
- }) : builtIn(photo)
149
+ }) : builtIn(picture)
161
150
  });
162
- return /* @__PURE__ */ jsx("div", {
163
- className: photos ? "group/card relative h-full" : "h-full",
164
- children: photos ? /* @__PURE__ */ jsx(CardCarousel, {
165
- photos,
166
- recordKey: row.key,
151
+ 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 ?? [],
167
155
  cover,
156
+ swipe,
168
157
  children: card
169
- }) : card(cover)
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
+ })]
170
164
  });
171
165
  };
172
166
  //#endregion
@@ -1,19 +1,19 @@
1
1
  import { keys } from "../../data/query-keys.js";
2
2
  import { useClient } from "../../data/api-provider.js";
3
- import { withLabels } from "../../live/relation-labels.js";
3
+ import { listLabels, withLabels } from "../../live/relation-labels.js";
4
+ import { listPhotos, withPhotos } from "../../live/row-photos.js";
4
5
  import { displayRow } from "./list-displays.js";
5
6
  import { useAdminMeta } from "../meta-gate.js";
6
7
  import { areaFilter, requestZoom } from "../map/map-area.js";
7
- import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
8
9
  //#region src/app/displays/use-map-records.ts
9
10
  /**
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
+ * The records in the map's area through the list API's `:map`, labelled for the card and with its photos, or buckets of them when it holds
11
12
  * many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
12
13
  * left is never shown.
13
14
  */
14
15
  const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
15
16
  const client = useClient();
16
- const queryClient = useQueryClient();
17
17
  const { resources, views } = useAdminMeta();
18
18
  const position = {
19
19
  latitude: display.latitude,
@@ -30,10 +30,12 @@ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
30
30
  ...position,
31
31
  zoom,
32
32
  filter: inside,
33
- orderBy
33
+ orderBy,
34
+ labels: listLabels(resources, views, model, fields),
35
+ photos: listPhotos(views[model.name])
34
36
  });
35
37
  return {
36
- rows: (await withLabels(queryClient, client, resources, views, model, fields, result.items)).map((record) => displayRow(model, record)),
38
+ rows: withLabels(resources, model, fields, withPhotos(model, result.items, result.photos), result.labels).map((record) => displayRow(model, record)),
37
39
  buckets: result.buckets,
38
40
  total: result.totalSize
39
41
  };
@@ -1,8 +1,8 @@
1
1
  import { humanize } from "../../live/naming.js";
2
2
  import { displayRow } from "../displays/list-displays.js";
3
3
  import { columnsFromModel } from "../../live/columns-from-model.js";
4
- import { withFieldTypeCells } from "../field-types/field-type-cells.js";
5
4
  import { scopeOf } from "../pages/use-layout-data.js";
5
+ import { withFieldTypeCells } from "../field-types/field-type-cells.js";
6
6
  import { FieldValue } from "../pages/field-value.js";
7
7
  import { usePathValue } from "../pages/use-path-value.js";
8
8
  import { jsx } from "react/jsx-runtime";
@@ -1,28 +1,33 @@
1
1
  import { keys } from "../../data/query-keys.js";
2
2
  import { useClient } from "../../data/api-provider.js";
3
3
  import { recordId } from "../../live/model-helpers.js";
4
- import { withLabels } from "../../live/relation-labels.js";
4
+ import { listLabels, withLabels } from "../../live/relation-labels.js";
5
+ import { listPhotos, withPhotos } from "../../live/row-photos.js";
5
6
  import { useAdminMeta } from "../meta-gate.js";
6
- import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
7
8
  //#region src/app/embedded-list/use-embedded-rows.ts
8
9
  /**
9
10
  * The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
10
- * `fields` as `{ id, name }`. A filter through a join resource may hold hundreds of keys, so it goes in a JSON body.
11
+ * `fields` as `{ id, name }`, named in the same call, with the photos of its cards. A filter through a join resource may hold hundreds of keys, so it
12
+ * goes in a JSON body.
11
13
  */
12
14
  const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
13
15
  const client = useClient();
14
- const queryClient = useQueryClient();
15
16
  const { resources, views } = useAdminMeta();
16
17
  return useQuery({
17
18
  queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
18
19
  placeholderData: keepPreviousData,
19
20
  queryFn: async () => {
21
+ const labels = listLabels(resources, views, model, fields);
22
+ const photos = listPhotos(views[model.name]);
20
23
  const page = await client.search(model.name, {
21
24
  filter: filter || void 0,
22
25
  orderBy: orderBy || void 0,
23
- pageSize: limit
26
+ pageSize: limit,
27
+ labels,
28
+ photos
24
29
  });
25
- return (await withLabels(queryClient, client, resources, views, model, fields, page.items)).map((row, index) => ({
30
+ return withLabels(resources, model, fields, withPhotos(model, page.items, page.photos), page.labels).map((row, index) => ({
26
31
  ...row,
27
32
  id: recordId(model, page.items[index])
28
33
  }));