@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
@@ -8,6 +8,7 @@ import { humanize } from "../live/naming.js";
8
8
  import { useProjectUi } from "./pages/project-ui.js";
9
9
  import { loadedFields, shownDisplay } from "./displays/list-displays.js";
10
10
  import { parseSort, sortToOrderBy } from "./list-order.js";
11
+ import { listedResource } from "./current-address.js";
11
12
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
12
13
  import { useAppAddress } from "./use-app-address.js";
13
14
  import { ErrorBanner } from "./error-banner.js";
@@ -27,6 +28,7 @@ import { useLiveRows } from "../live/use-live-rows.js";
27
28
  import { Sheet } from "../primitives/sheet.js";
28
29
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
29
30
  import { useRestore } from "./delete/use-restore.js";
31
+ import { SignInPrompt } from "../auth/sign-in-prompt.js";
30
32
  import { ListCards } from "./displays/list-cards.js";
31
33
  import { MapDisplay } from "./displays/map-display.js";
32
34
  import { CustomDisplay } from "./displays/custom-display.js";
@@ -36,6 +38,7 @@ import { useListScroll } from "./list-scroll.js";
36
38
  import { ListChart } from "./list-chart.js";
37
39
  import { ListSearch } from "./list-search.js";
38
40
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
41
+ import { SavedList } from "./saved/saved-list.js";
39
42
  import { useMemo, useState } from "react";
40
43
  import { Plus, SlidersHorizontal } from "lucide-react";
41
44
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -44,10 +47,18 @@ import { createWhere } from "@protobase/schema";
44
47
  //#region src/app/list-page.tsx
45
48
  const where = createWhere();
46
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
+ */
47
54
  const ListPage = ({ address }) => {
48
- const { resources } = useAdminMeta();
55
+ const { resources, views, signIn } = useAdminMeta();
49
56
  const model = resources[address.resource];
50
- 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, {
51
62
  title: "Not found",
52
63
  children: [
53
64
  "There is no resource called ",
@@ -55,12 +66,31 @@ const ListPage = ({ address }) => {
55
66
  "."
56
67
  ]
57
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
+ }
58
88
  return /* @__PURE__ */ jsx(LoadedList, {
59
89
  model,
60
90
  address
61
91
  });
62
92
  };
63
- const LoadedList = ({ model, address }) => {
93
+ const LoadedList = ({ model, address, scope, heading }) => {
64
94
  const meta = useAdminMeta();
65
95
  const { set, go } = useAppAddress();
66
96
  const resource = model.name;
@@ -79,14 +109,16 @@ const LoadedList = ({ model, address }) => {
79
109
  const { orderBy, filterLayout: layout } = address;
80
110
  const desktop = useIsDesktop();
81
111
  const [sheetOpen, setSheetOpen] = useState(false);
112
+ const scoped = scope ? [...extras, scope] : extras;
82
113
  const config = useFilterConfig({
83
114
  model,
84
115
  resources: meta.resources,
85
116
  views: meta.views,
86
117
  view,
87
118
  state,
88
- extras
119
+ extras: scoped
89
120
  });
121
+ const loadFilter = scope ? stateToText(fields, state, scoped) : parsed.ok ? filterText : "";
90
122
  const loaded = useMemo(() => loadedFields(model, view, columns.map((column) => column.id)), [
91
123
  model,
92
124
  view,
@@ -97,7 +129,7 @@ const LoadedList = ({ model, address }) => {
97
129
  views: meta.views,
98
130
  model,
99
131
  columns: loaded,
100
- filter: parsed.ok ? filterText : "",
132
+ filter: loadFilter,
101
133
  orderBy
102
134
  });
103
135
  const display = shownDisplay(view, address.display);
@@ -211,7 +243,7 @@ const LoadedList = ({ model, address }) => {
211
243
  })
212
244
  ]
213
245
  });
214
- const createButton = can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
246
+ const createButton = !scope && can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
215
247
  variant: "primary",
216
248
  onClick: () => go({
217
249
  kind: "create",
@@ -278,11 +310,12 @@ const LoadedList = ({ model, address }) => {
278
310
  model,
279
311
  view,
280
312
  display,
281
- filter: parsed.ok ? filterText : "",
313
+ filter: loadFilter,
282
314
  orderBy,
283
315
  fields: loaded,
284
316
  at: address.map,
285
- onMove: (map) => set({ map })
317
+ onMove: (map) => set({ map }),
318
+ flush: true
286
319
  });
287
320
  return /* @__PURE__ */ jsx(CustomDisplay, {
288
321
  display,
@@ -295,8 +328,8 @@ const LoadedList = ({ model, address }) => {
295
328
  children: [
296
329
  remove.dialogs,
297
330
  /* @__PURE__ */ jsx(PageHeader, {
298
- title: plural,
299
- subtitle: view?.help,
331
+ title: heading?.title ?? plural,
332
+ subtitle: heading ? heading.subtitle : view?.help,
300
333
  aside: !desktop && displaySwitch(true),
301
334
  actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
302
335
  }),
@@ -313,7 +346,7 @@ const LoadedList = ({ model, address }) => {
313
346
  chart: view.chart,
314
347
  title: `${plural} created`,
315
348
  noun: plural.toLowerCase(),
316
- filter: parsed.ok ? filterText : ""
349
+ filter: loadFilter
317
350
  }),
318
351
  desktop ? desktopToolbar : phoneFilters,
319
352
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
@@ -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
@@ -0,0 +1,15 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/auth/provider-icon.tsx
4
+ /** The GitHub mark, from GitHub's Octicons, in the text color so it follows the theme. */
5
+ const GitHubMark = ({ className }) => /* @__PURE__ */ jsx("svg", {
6
+ viewBox: "0 0 24 24",
7
+ fill: "currentColor",
8
+ className: cn("size-4 shrink-0", className),
9
+ "aria-hidden": true,
10
+ children: /* @__PURE__ */ jsx("path", { d: "M10.226 17.284c-2.965-.36-5.054-2.493-5.054-5.256 0-1.123.404-2.336 1.078-3.144-.292-.741-.247-2.314.09-2.965.898-.112 2.111.36 2.83 1.01.853-.269 1.752-.404 2.853-.404 1.1 0 1.999.135 2.807.382.696-.629 1.932-1.1 2.83-.988.315.606.36 2.179.067 2.942.72.854 1.101 2 1.101 3.167 0 2.763-2.089 4.852-5.098 5.234.763.494 1.28 1.572 1.28 2.807v2.336c0 .674.561 1.056 1.235.786 4.066-1.55 7.255-5.615 7.255-10.646C23.5 6.188 18.334 1 11.978 1 5.62 1 .5 6.188.5 12.545c0 4.986 3.167 9.12 7.435 10.669.606.225 1.19-.18 1.19-.786V20.63a2.9 2.9 0 0 1-1.078.224c-1.483 0-2.359-.808-2.987-2.313-.247-.607-.517-.966-1.034-1.033-.27-.023-.359-.135-.359-.27 0-.27.45-.471.898-.471.652 0 1.213.404 1.797 1.235.45.651.921.943 1.483.943.561 0 .92-.202 1.437-.719.382-.381.674-.718.944-.943" })
11
+ });
12
+ /** The mark of a sign-in provider, by its id; nothing for a provider without one. */
13
+ const ProviderIcon = ({ id, className }) => id === "github" ? /* @__PURE__ */ jsx(GitHubMark, { className }) : null;
14
+ //#endregion
15
+ export { ProviderIcon };
@@ -1,6 +1,7 @@
1
1
  import { Input } from "../primitives/input.js";
2
2
  import { Button } from "../primitives/button.js";
3
3
  import { AuthLayout } from "./auth-layout.js";
4
+ import { ProviderIcon } from "./provider-icon.js";
4
5
  import { useRef, useState } from "react";
5
6
  import { KeyRound, Mail } from "lucide-react";
6
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -41,7 +42,11 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["pass
41
42
  onClick: () => onContinueWith?.(provider.id),
42
43
  disabled: busy,
43
44
  className: "min-h-10 w-full",
44
- children: ["Continue with ", provider.name]
45
+ children: [
46
+ /* @__PURE__ */ jsx(ProviderIcon, { id: provider.id }),
47
+ "Continue with ",
48
+ provider.name
49
+ ]
45
50
  }, provider.id)),
46
51
  withPasskey && /* @__PURE__ */ jsxs(Button, {
47
52
  onClick: () => onPasskey?.(),
@@ -0,0 +1,22 @@
1
+ import { SignInPromptModel } from "@protobase/schema";
2
+ //#region src/auth/sign-in-prompt.d.ts
3
+ export type SignInPromptProps = {
4
+ prompt: SignInPromptModel;
5
+ } & (
6
+ /** A page section, in place of what the visitor cannot use yet. */
7
+ {
8
+ as: 'page';
9
+ } |
10
+ /** A modal on a desktop and a sheet on a phone (`ResponsiveDialog`), after the visitor tried what needs an account. */
11
+ {
12
+ as: 'dialog';
13
+ open: boolean;
14
+ onClose: () => void;
15
+ });
16
+ /**
17
+ * What a visitor who is not signed in is told about something they could use once signed in: the prompt's title, what
18
+ * signing in gives and its benefits, with "Sign in" and, when the prompt names one, a page to make an account. As a page
19
+ * it stands in for a list the visitor cannot see; as a dialog it answers a press on a button that needs an account.
20
+ */
21
+ export declare const SignInPrompt: (props: SignInPromptProps) => import("react").JSX.Element;
22
+ //#endregion