@protobase/ui 0.14.0 → 0.14.2

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 (75) 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-cards.js +2 -2
  7. package/dist/app/displays/list-displays.d.ts +2 -0
  8. package/dist/app/displays/list-displays.js +9 -3
  9. package/dist/app/displays/map-display.js +5 -4
  10. package/dist/app/displays/record-card.js +28 -34
  11. package/dist/app/displays/use-map-records.js +8 -6
  12. package/dist/app/displays/virtual-cards.js +43 -65
  13. package/dist/app/embedded-list/embedded-columns.js +1 -1
  14. package/dist/app/embedded-list/use-embedded-rows.js +11 -6
  15. package/dist/app/list-page.js +53 -13
  16. package/dist/app/menu-icons.js +3 -1
  17. package/dist/app/named-icon.js +5 -3
  18. package/dist/app/nav-from-meta.js +21 -1
  19. package/dist/app/pages/blocks/card-row.js +1 -1
  20. package/dist/app/pages/blocks/modal-form.js +1 -1
  21. package/dist/app/pages/blocks/record-card.js +1 -1
  22. package/dist/app/pages/blocks/show.js +1 -1
  23. package/dist/app/pages/blocks/stat.js +1 -1
  24. package/dist/app/pages/use-run-action.js +1 -1
  25. package/dist/app/saved/favorite-button.js +49 -0
  26. package/dist/app/saved/saved-list.js +43 -0
  27. package/dist/app/saved/saved-records.js +38 -0
  28. package/dist/app/saved/use-favorite.js +62 -0
  29. package/dist/app/saved/use-saved-records.js +36 -0
  30. package/dist/app/use-app-address.js +5 -2
  31. package/dist/app-shell/app-shell.js +54 -38
  32. package/dist/app-shell/bottom-bar.d.ts +1 -0
  33. package/dist/app-shell/bottom-bar.js +3 -2
  34. package/dist/app-shell/index.d.ts +1 -0
  35. package/dist/app-shell/page-scroll.d.ts +1 -0
  36. package/dist/app-shell/page-scroll.js +56 -0
  37. package/dist/app-shell/sidebar.js +2 -2
  38. package/dist/app-shell/top-bar.js +3 -2
  39. package/dist/auth/sign-in-prompt.d.ts +22 -0
  40. package/dist/auth/sign-in-prompt.js +84 -0
  41. package/dist/data/api-provider.d.ts +2 -0
  42. package/dist/data/meta-data.d.ts +3 -1
  43. package/dist/data/query-keys.js +0 -5
  44. package/dist/data/use-meta.js +1 -0
  45. package/dist/data-grid/data-grid.js +92 -85
  46. package/dist/data-grid/geometry.js +26 -6
  47. package/dist/data-grid/grid-footer.js +5 -12
  48. package/dist/data-grid/scroll-parent.js +35 -0
  49. package/dist/data-grid/use-virtual-rows.js +123 -18
  50. package/dist/index.d.ts +3 -1
  51. package/dist/index.js +3 -1
  52. package/dist/live/grid-loader.js +8 -3
  53. package/dist/live/reference-options.js +2 -2
  54. package/dist/live/relation-labels.js +16 -18
  55. package/dist/live/row-photos.js +18 -0
  56. package/dist/live/use-filter-config.js +7 -2
  57. package/dist/package.js +1 -1
  58. package/dist/primitives/button.js +7 -2
  59. package/dist/primitives/dialog.js +1 -1
  60. package/dist/primitives/responsive-dialog.d.ts +17 -0
  61. package/dist/primitives/responsive-dialog.js +44 -0
  62. package/dist/shell/assets/app-B2GAOqtJ.js +66 -0
  63. package/dist/shell/assets/index-BXYHpFoe.css +2 -0
  64. package/dist/shell/assets/{index-CwUFCDf9.js → index-DTbA97zz.js} +1 -1
  65. package/dist/shell/assets/{maplibre-gl-QGc1hPKt.js → maplibre-gl-CSTJJJQT.js} +1 -1
  66. package/dist/shell/assets/{record-map-D5vqYcYw.js → record-map-CeTF-ArN.js} +2 -2
  67. package/dist/shell/assets/{records-map-DqqaK1rm.js → records-map-Cl0Ofb1s.js} +1 -1
  68. package/dist/shell/assets/shared-protobase-ui-4J-P80A-.js +1 -0
  69. package/dist/shell/index.html +4 -4
  70. package/dist/shell/protobase.shell.json +1 -1
  71. package/package.json +4 -4
  72. package/dist/data-grid/use-grid-scroll.js +0 -87
  73. package/dist/shell/assets/app-DtPlcCA3.js +0 -66
  74. package/dist/shell/assets/index-CYWfU_c1.css +0 -2
  75. package/dist/shell/assets/shared-protobase-ui-0ozDlcDr.js +0 -1
@@ -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
  }));
@@ -1,4 +1,5 @@
1
1
  import { useIsDesktop } from "../lib/use-media-query.js";
2
+ import { usePageScroll } from "../app-shell/page-scroll.js";
2
3
  import { PageHeader } from "../app-shell/page-header.js";
3
4
  import { useAuth } from "../auth/auth-provider.js";
4
5
  import { Button } from "../primitives/button.js";
@@ -7,6 +8,7 @@ import { humanize } from "../live/naming.js";
7
8
  import { useProjectUi } from "./pages/project-ui.js";
8
9
  import { loadedFields, shownDisplay } from "./displays/list-displays.js";
9
10
  import { parseSort, sortToOrderBy } from "./list-order.js";
11
+ import { listedResource } from "./current-address.js";
10
12
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
11
13
  import { useAppAddress } from "./use-app-address.js";
12
14
  import { ErrorBanner } from "./error-banner.js";
@@ -26,6 +28,7 @@ import { useLiveRows } from "../live/use-live-rows.js";
26
28
  import { Sheet } from "../primitives/sheet.js";
27
29
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
28
30
  import { useRestore } from "./delete/use-restore.js";
31
+ import { SignInPrompt } from "../auth/sign-in-prompt.js";
29
32
  import { ListCards } from "./displays/list-cards.js";
30
33
  import { MapDisplay } from "./displays/map-display.js";
31
34
  import { CustomDisplay } from "./displays/custom-display.js";
@@ -35,6 +38,7 @@ import { useListScroll } from "./list-scroll.js";
35
38
  import { ListChart } from "./list-chart.js";
36
39
  import { ListSearch } from "./list-search.js";
37
40
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
41
+ import { SavedList } from "./saved/saved-list.js";
38
42
  import { useMemo, useState } from "react";
39
43
  import { Plus, SlidersHorizontal } from "lucide-react";
40
44
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -43,10 +47,18 @@ import { createWhere } from "@protobase/schema";
43
47
  //#region src/app/list-page.tsx
44
48
  const where = createWhere();
45
49
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
50
+ /**
51
+ * A resource's list: its rows, or with `listOf` the records its relation points at (`SavedList`). A visitor who cannot
52
+ * see the resource is told what signing in gives, when its view says (`SignInPrompt`).
53
+ */
46
54
  const ListPage = ({ address }) => {
47
- const { resources } = useAdminMeta();
55
+ const { resources, views, signIn } = useAdminMeta();
48
56
  const model = resources[address.resource];
49
- if (!model) return /* @__PURE__ */ jsxs(Notice, {
57
+ const prompt = signIn?.[address.resource];
58
+ if (!model) return prompt ? /* @__PURE__ */ jsx(SignInPrompt, {
59
+ as: "page",
60
+ prompt: prompt.signIn
61
+ }) : /* @__PURE__ */ jsxs(Notice, {
50
62
  title: "Not found",
51
63
  children: [
52
64
  "There is no resource called ",
@@ -54,12 +66,31 @@ const ListPage = ({ address }) => {
54
66
  "."
55
67
  ]
56
68
  });
69
+ const view = views[address.resource];
70
+ const listed = listedResource(resources, views, address.resource);
71
+ if (view && listed.listOf) {
72
+ const target = listed.model;
73
+ return /* @__PURE__ */ jsx(SavedList, {
74
+ model,
75
+ view,
76
+ target,
77
+ children: (scope) => /* @__PURE__ */ jsx(LoadedList, {
78
+ model: target,
79
+ address,
80
+ scope,
81
+ heading: {
82
+ title: view.names?.plural ?? humanize(model.name),
83
+ subtitle: view.help
84
+ }
85
+ })
86
+ });
87
+ }
57
88
  return /* @__PURE__ */ jsx(LoadedList, {
58
89
  model,
59
90
  address
60
91
  });
61
92
  };
62
- const LoadedList = ({ model, address }) => {
93
+ const LoadedList = ({ model, address, scope, heading }) => {
63
94
  const meta = useAdminMeta();
64
95
  const { set, go } = useAppAddress();
65
96
  const resource = model.name;
@@ -78,14 +109,16 @@ const LoadedList = ({ model, address }) => {
78
109
  const { orderBy, filterLayout: layout } = address;
79
110
  const desktop = useIsDesktop();
80
111
  const [sheetOpen, setSheetOpen] = useState(false);
112
+ const scoped = scope ? [...extras, scope] : extras;
81
113
  const config = useFilterConfig({
82
114
  model,
83
115
  resources: meta.resources,
84
116
  views: meta.views,
85
117
  view,
86
118
  state,
87
- extras
119
+ extras: scoped
88
120
  });
121
+ const loadFilter = scope ? stateToText(fields, state, scoped) : parsed.ok ? filterText : "";
89
122
  const loaded = useMemo(() => loadedFields(model, view, columns.map((column) => column.id)), [
90
123
  model,
91
124
  view,
@@ -96,10 +129,12 @@ const LoadedList = ({ model, address }) => {
96
129
  views: meta.views,
97
130
  model,
98
131
  columns: loaded,
99
- filter: parsed.ok ? filterText : "",
132
+ filter: loadFilter,
100
133
  orderBy
101
134
  });
102
135
  const display = shownDisplay(view, address.display);
136
+ const pageScroll = !display || display.kind === "cards";
137
+ usePageScroll(pageScroll);
103
138
  const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
104
139
  const scroll = useListScroll();
105
140
  const titleField = recordTitleField(model, view);
@@ -208,7 +243,7 @@ const LoadedList = ({ model, address }) => {
208
243
  })
209
244
  ]
210
245
  });
211
- const createButton = can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
246
+ const createButton = !scope && can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
212
247
  variant: "primary",
213
248
  onClick: () => go({
214
249
  kind: "create",
@@ -255,7 +290,8 @@ const LoadedList = ({ model, address }) => {
255
290
  }] : [],
256
291
  rowNumbers: false,
257
292
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
258
- className: "min-h-0 flex-1"
293
+ scroll: "parent",
294
+ className: "flex-1"
259
295
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
260
296
  const cardsList = {
261
297
  ...rows,
@@ -274,11 +310,12 @@ const LoadedList = ({ model, address }) => {
274
310
  model,
275
311
  view,
276
312
  display,
277
- filter: parsed.ok ? filterText : "",
313
+ filter: loadFilter,
278
314
  orderBy,
279
315
  fields: loaded,
280
316
  at: address.map,
281
- onMove: (map) => set({ map })
317
+ onMove: (map) => set({ map }),
318
+ flush: true
282
319
  });
283
320
  return /* @__PURE__ */ jsx(CustomDisplay, {
284
321
  display,
@@ -291,8 +328,8 @@ const LoadedList = ({ model, address }) => {
291
328
  children: [
292
329
  remove.dialogs,
293
330
  /* @__PURE__ */ jsx(PageHeader, {
294
- title: plural,
295
- subtitle: view?.help,
331
+ title: heading?.title ?? plural,
332
+ subtitle: heading ? heading.subtitle : view?.help,
296
333
  aside: !desktop && displaySwitch(true),
297
334
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
298
335
  }),
@@ -309,12 +346,15 @@ const LoadedList = ({ model, address }) => {
309
346
  chart: view.chart,
310
347
  title: `${plural} created`,
311
348
  noun: plural.toLowerCase(),
312
- filter: parsed.ok ? filterText : ""
349
+ filter: loadFilter
313
350
  }),
314
351
  desktop ? desktopToolbar : phoneFilters,
315
352
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
316
353
  className: "flex min-h-0 flex-1 gap-4",
317
- children: [panel, shown]
354
+ children: [/* @__PURE__ */ jsx("div", {
355
+ className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
356
+ children: panel
357
+ }), shown]
318
358
  }) : shown
319
359
  ]
320
360
  });
@@ -1,11 +1,13 @@
1
- import { BookOpen, Building2, CreditCard, ExternalLink, FileText, KeyRound, LayoutDashboard, LifeBuoy, Mail, Receipt, Settings, ShieldCheck, Users } from "lucide-react";
1
+ import { Bell, BookOpen, Building2, CreditCard, ExternalLink, FileText, Heart, KeyRound, LayoutDashboard, LifeBuoy, Mail, Receipt, Settings, ShieldCheck, Users } from "lucide-react";
2
2
  //#region src/app/menu-icons.ts
3
3
  const byName = {
4
+ bell: Bell,
4
5
  "book-open": BookOpen,
5
6
  building: Building2,
6
7
  "credit-card": CreditCard,
7
8
  "external-link": ExternalLink,
8
9
  "file-text": FileText,
10
+ heart: Heart,
9
11
  key: KeyRound,
10
12
  "layout-dashboard": LayoutDashboard,
11
13
  "life-buoy": LifeBuoy,
@@ -1,12 +1,14 @@
1
- import { Bed, FileText, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bed, Bell, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
- /** The icons an action's or a field's `icon` may name. */
3
+ /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
5
5
  wallet: Wallet,
6
6
  "file-text": FileText,
7
7
  send: Send,
8
8
  ruler: RulerDimensionLine,
9
- bed: Bed
9
+ bed: Bed,
10
+ heart: Heart,
11
+ bell: Bell
10
12
  };
11
13
  /** The icon a name stands for, or nothing for a name the app does not know. */
12
14
  const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
@@ -17,7 +17,8 @@ const groupLabels = {
17
17
  * One sidebar group per `nav.group` (default: the database schema, and "Pages" for pages); `nav.hidden` and a place in
18
18
  * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
19
19
  * collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
20
- * read are listed under its label, each with the problem.
20
+ * read are listed under its label, each with the problem. A visitor also gets the resources whose views ask them to sign
21
+ * in, where those views place them, under "Account" without a group.
21
22
  */
22
23
  const navFromMeta = (meta, basePath = "", recent = {}) => {
23
24
  const groups = /* @__PURE__ */ new Map();
@@ -64,6 +65,25 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
64
65
  groups.set(groupName, group);
65
66
  orders.set(model.name, view.nav?.order ?? Number.MAX_SAFE_INTEGER);
66
67
  }
68
+ for (const entry of Object.values(meta.signIn ?? {})) {
69
+ if (entry.nav?.hidden || inMenu.has(entry.resource)) continue;
70
+ const groupName = entry.nav?.group ?? "Account";
71
+ const group = groups.get(groupName) ?? {
72
+ label: groupName,
73
+ items: []
74
+ };
75
+ group.items.push({
76
+ id: entry.resource,
77
+ label: entry.names?.plural ?? humanize(entry.resource),
78
+ icon: iconFor(entry.resource),
79
+ href: appPath({
80
+ kind: "list",
81
+ resource: entry.resource
82
+ }, basePath)
83
+ });
84
+ groups.set(groupName, group);
85
+ orders.set(entry.resource, entry.nav?.order ?? Number.MAX_SAFE_INTEGER);
86
+ }
67
87
  for (const status of meta.sources ?? []) {
68
88
  if (status.state !== "error") continue;
69
89
  const group = groups.get(status.source.label) ?? {
@@ -1,8 +1,8 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
3
  import { ErrorBanner } from "../../error-banner.js";
4
- import { RecordScope } from "../record-scope.js";
5
4
  import { scopeOf, useListPage } from "../use-layout-data.js";
5
+ import { RecordScope } from "../record-scope.js";
6
6
  import { propsOf } from "./block-props.js";
7
7
  import { CardFrame, Quiet } from "./card-frame.js";
8
8
  import { jsx } from "react/jsx-runtime";
@@ -10,8 +10,8 @@ import { FieldEditor } from "../../field-editor.js";
10
10
  import { can } from "../../permissions.js";
11
11
  import { Field } from "../../../record-view/field.js";
12
12
  import { createBody, isChosen } from "../../create/create-values.js";
13
- import { useLayoutRecord } from "../record-scope.js";
14
13
  import { useFirstRecord, useRefreshResource } from "../use-layout-data.js";
14
+ import { useLayoutRecord } from "../record-scope.js";
15
15
  import { propsOf } from "./block-props.js";
16
16
  import { useState } from "react";
17
17
  import { isApiError } from "@protobase/client";
@@ -1,8 +1,8 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
3
  import { ErrorBanner } from "../../error-banner.js";
4
- import { RecordScope } from "../record-scope.js";
5
4
  import { useFirstRecord } from "../use-layout-data.js";
5
+ import { RecordScope } from "../record-scope.js";
6
6
  import { propsOf } from "./block-props.js";
7
7
  import { CardFrame, Quiet } from "./card-frame.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,7 +1,7 @@
1
1
  import { useClient } from "../../../data/api-provider.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
- import { useLayoutRecord } from "../record-scope.js";
4
3
  import { countQuery } from "../use-layout-data.js";
4
+ import { useLayoutRecord } from "../record-scope.js";
5
5
  import { propsOf } from "./block-props.js";
6
6
  import { usePathValues } from "../use-path-value.js";
7
7
  import { useMemo } from "react";
@@ -1,8 +1,8 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
3
  import { AppLink } from "../../use-app-address.js";
4
- import { useLayoutRecord } from "../record-scope.js";
5
4
  import { useCount } from "../use-layout-data.js";
5
+ import { useLayoutRecord } from "../record-scope.js";
6
6
  import { propsOf } from "./block-props.js";
7
7
  import { CardFrame } from "./card-frame.js";
8
8
  import { FieldValue } from "../field-value.js";
@@ -4,8 +4,8 @@ import { useProjectUi } from "./project-ui.js";
4
4
  import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
5
  import { useAppAddress } from "../use-app-address.js";
6
6
  import { can } from "../permissions.js";
7
- import { fillHref } from "./action-href.js";
8
7
  import { useRefreshResource } from "./use-layout-data.js";
8
+ import { fillHref } from "./action-href.js";
9
9
  import { useState } from "react";
10
10
  import { fillAddress } from "@protobase/schema/internal";
11
11
  //#region src/app/pages/use-run-action.ts
@@ -0,0 +1,49 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { SignInPrompt } from "../../auth/sign-in-prompt.js";
3
+ import { useFavorite } from "./use-favorite.js";
4
+ import { useState } from "react";
5
+ import { Heart } from "lucide-react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/saved/favorite-button.tsx
8
+ /**
9
+ * A heart that saves the card's record for the signed-in user, pressed while it is saved, or, for a visitor, opens the
10
+ * sign-in prompt of the saved records' resource. It sits over the card's link, beside it in the document, so pressing
11
+ * it does not open the record; the card's list item must be `relative`.
12
+ */
13
+ const FavoriteButton = ({ favorite, recordKey, target, className }) => {
14
+ const state = useFavorite(favorite, recordKey, target);
15
+ const [prompting, setPrompting] = useState(false);
16
+ if (!state.mode) return null;
17
+ const label = favorite.label ?? "Favorite";
18
+ const base = cn("absolute right-2 top-2 flex size-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30", className);
19
+ if (state.mode === "sign-in") return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
20
+ type: "button",
21
+ "aria-label": label,
22
+ "aria-haspopup": "dialog",
23
+ className: base,
24
+ onClick: () => setPrompting(true),
25
+ children: /* @__PURE__ */ jsx(Heart, {
26
+ className: "size-4",
27
+ "aria-hidden": true
28
+ })
29
+ }), /* @__PURE__ */ jsx(SignInPrompt, {
30
+ as: "dialog",
31
+ prompt: state.prompt,
32
+ open: prompting,
33
+ onClose: () => setPrompting(false)
34
+ })] });
35
+ return /* @__PURE__ */ jsx("button", {
36
+ type: "button",
37
+ "aria-label": label,
38
+ "aria-pressed": state.saved,
39
+ "aria-disabled": !state.ready,
40
+ className: base,
41
+ onClick: () => state.ready && state.toggle(),
42
+ children: /* @__PURE__ */ jsx(Heart, {
43
+ className: cn("size-4", state.saved && "fill-current text-danger"),
44
+ "aria-hidden": true
45
+ })
46
+ });
47
+ };
48
+ //#endregion
49
+ export { FavoriteButton };
@@ -0,0 +1,43 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { PageHeader } from "../../app-shell/page-header.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { ErrorBanner } from "../error-banner.js";
5
+ import { savedScope } from "./saved-records.js";
6
+ import { useSavedRecords } from "./use-saved-records.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/saved/saved-list.tsx
9
+ /**
10
+ * The list page of a view with `listOf`: the records the user's saved ones point at, in their own resource's list, once
11
+ * the saved ones have loaded; until then, or when there are none, the page says so under the view's title.
12
+ */
13
+ const SavedList = ({ model, view, target, children }) => {
14
+ const saved = useSavedRecords(model.name, view.listOf, true);
15
+ const plural = view.names?.plural ?? humanize(model.name);
16
+ const frame = (body) => /* @__PURE__ */ jsxs("div", {
17
+ className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
18
+ children: [/* @__PURE__ */ jsx(PageHeader, {
19
+ title: plural,
20
+ subtitle: view.help
21
+ }), body]
22
+ });
23
+ if (saved.error) return frame(/* @__PURE__ */ jsx(ErrorBanner, {
24
+ title: `Could not load your ${plural.toLowerCase()}`,
25
+ error: saved.error
26
+ }));
27
+ if (!saved.data) return frame(/* @__PURE__ */ jsx("div", {
28
+ className: "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3",
29
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
30
+ }));
31
+ const scope = savedScope(target, saved.data);
32
+ if (!scope) return frame(/* @__PURE__ */ jsxs("p", {
33
+ className: "py-8 text-center text-[13px] text-muted-foreground",
34
+ children: [
35
+ "No ",
36
+ plural.toLowerCase(),
37
+ " yet."
38
+ ]
39
+ }));
40
+ return children(scope);
41
+ };
42
+ //#endregion
43
+ export { SavedList };
@@ -0,0 +1,38 @@
1
+ import { recordKey } from "../../live/model-helpers.js";
2
+ import { encodeKey } from "@protobase/schema/internal";
3
+ import { createWhere } from "@protobase/schema";
4
+ //#region src/app/saved/saved-records.ts
5
+ const where = createWhere();
6
+ const relationValue = (value) => {
7
+ if (typeof value === "string" || typeof value === "number") return value;
8
+ if (value && typeof value === "object" && "id" in value) return relationValue(value.id);
9
+ };
10
+ /** The saved records among listed rows of `model`, by the relation `field`; rows without a value are left out. */
11
+ const savedRecordsOf = (model, field, rows) => rows.flatMap((row) => {
12
+ const target = relationValue(row[field]);
13
+ return target === void 0 ? [] : [{
14
+ key: encodeKey(recordKey(model, row)),
15
+ target
16
+ }];
17
+ });
18
+ /** The saved record of the record with this key, the text form the record API takes, if it is saved. */
19
+ const savedFor = (saved, key) => saved.find((entry) => encodeKey(entry.target) === key);
20
+ /** The saved records with one more, until the server answers with its key. */
21
+ const withSaved = (saved, target) => [...saved, {
22
+ key: "",
23
+ target
24
+ }];
25
+ /** The saved records without the one of the record with this key. */
26
+ const withoutSaved = (saved, key) => saved.filter((entry) => encodeKey(entry.target) !== key);
27
+ /**
28
+ * The filter of the records the saved ones point at, on the target's single-column key; undefined when there are none,
29
+ * as `in()` needs at least one value.
30
+ */
31
+ const savedScope = (target, saved) => {
32
+ const [key] = target.primaryKey;
33
+ if (target.primaryKey.length !== 1 || key === void 0) throw new Error(`${target.name} has a composite key, so its saved records cannot be listed`);
34
+ const values = [...new Set(saved.map((entry) => entry.target))];
35
+ return values.length === 0 ? void 0 : where.in(key, values);
36
+ };
37
+ //#endregion
38
+ export { savedFor, savedRecordsOf, savedScope, withSaved, withoutSaved };
@@ -0,0 +1,62 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { useToast } from "../../toasts/toast-provider.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { can } from "../permissions.js";
5
+ import { useRefreshResource } from "../pages/use-layout-data.js";
6
+ import { savedFor, withSaved, withoutSaved } from "./saved-records.js";
7
+ import { savedRecordsKey, useSavedRecords } from "./use-saved-records.js";
8
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
9
+ //#region src/app/saved/use-favorite.ts
10
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
11
+ /**
12
+ * A card's favorite button: `save` for someone who can list, create and delete the saved records, `sign-in` for a
13
+ * visitor the saved records' resource asks to sign in (`prompt`), nothing otherwise. A press saves the card's record,
14
+ * or takes it off, at once on screen; a refused write puts it back and says why.
15
+ */
16
+ const useFavorite = (favorite, key, target) => {
17
+ const meta = useAdminMeta();
18
+ const client = useClient();
19
+ const queryClient = useQueryClient();
20
+ const toast = useToast();
21
+ const refresh = useRefreshResource();
22
+ const { resource, field } = favorite;
23
+ const permissions = meta.permissions[resource];
24
+ const usable = meta.resources[resource] !== void 0 && permissions !== void 0 && can(permissions, "create") && can(permissions, "delete");
25
+ const prompt = meta.signIn?.[resource]?.signIn;
26
+ const saved = useSavedRecords(resource, field, usable);
27
+ const current = saved.data && savedFor(saved.data, key);
28
+ const cacheKey = savedRecordsKey(resource, field);
29
+ const toggle = useMutation({
30
+ mutationFn: async (existing) => {
31
+ if (existing) await client.remove(resource, existing.key);
32
+ else await client.create(resource, { [field]: target });
33
+ },
34
+ onMutate: async (existing) => {
35
+ await queryClient.cancelQueries({
36
+ queryKey: cacheKey,
37
+ exact: true
38
+ });
39
+ const before = queryClient.getQueryData(cacheKey);
40
+ queryClient.setQueryData(cacheKey, (list = []) => existing ? withoutSaved(list, key) : withSaved(list, target));
41
+ return { before };
42
+ },
43
+ onError: (error, existing, context) => {
44
+ queryClient.setQueryData(cacheKey, context?.before);
45
+ toast.show({
46
+ state: "error",
47
+ title: existing ? "Could not take it off" : "Could not save it",
48
+ description: message(error)
49
+ });
50
+ },
51
+ onSettled: () => refresh(resource)
52
+ });
53
+ return {
54
+ mode: usable ? "save" : prompt ? "sign-in" : void 0,
55
+ prompt,
56
+ saved: current !== void 0,
57
+ ready: saved.data !== void 0 && !toggle.isPending,
58
+ toggle: () => toggle.mutate(current)
59
+ };
60
+ };
61
+ //#endregion
62
+ export { useFavorite };
@@ -0,0 +1,36 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { savedRecordsOf } from "./saved-records.js";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ //#region src/app/saved/use-saved-records.ts
7
+ /** Where the user's saved records of `resource` by `field` are cached; a list key, so a write to the resource refreshes it. */
8
+ const savedRecordsKey = (resource, field) => keys.list(resource, "", "", 500, `saved:${field}`);
9
+ /**
10
+ * The signed-in user's saved records of `resource`, each with the key its relation `field` points at: every one, page
11
+ * by page, as the API lists only a user's own records of a resource with an owner. Loads only while `enabled`.
12
+ */
13
+ const useSavedRecords = (resource, field, enabled) => {
14
+ const client = useClient();
15
+ const model = useAdminMeta().resources[resource];
16
+ return useQuery({
17
+ queryKey: savedRecordsKey(resource, field),
18
+ enabled: enabled && model !== void 0,
19
+ queryFn: async () => {
20
+ const rows = [];
21
+ let pageToken;
22
+ do {
23
+ const page = await client.list(resource, {
24
+ pageSize: 500,
25
+ fields: [...model.primaryKey, field],
26
+ ...pageToken && { pageToken }
27
+ });
28
+ rows.push(...page.items);
29
+ pageToken = page.nextPageToken || void 0;
30
+ } while (pageToken);
31
+ return savedRecordsOf(model, field, rows);
32
+ }
33
+ });
34
+ };
35
+ //#endregion
36
+ export { savedRecordsKey, useSavedRecords };
@@ -1,5 +1,5 @@
1
1
  import { useIsXs } from "../lib/use-media-query.js";
2
- import { changedList, currentAddress, listDefaults } from "./current-address.js";
2
+ import { changedList, currentAddress, listDefaults, listedResource } from "./current-address.js";
3
3
  import { useAdminMeta } from "./meta-gate.js";
4
4
  import { Link, useRouter } from "./router.js";
5
5
  import { useCallback, useMemo } from "react";
@@ -22,7 +22,10 @@ const useAppAddress = () => {
22
22
  query,
23
23
  pageNames
24
24
  ]);
25
- const defaults = useCallback((resource) => listDefaults(resources[resource], views[resource], xs), [
25
+ const defaults = useCallback((resource) => {
26
+ const listed = listedResource(resources, views, resource);
27
+ return listDefaults(listed.model, listed.view, xs);
28
+ }, [
26
29
  resources,
27
30
  views,
28
31
  xs