@protobase/ui 0.15.5 → 0.16.1

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 (113) hide show
  1. package/dist/account/two-factor-setup.js +6 -5
  2. package/dist/app/app.js +1 -1
  3. package/dist/app/create/create-page.js +1 -0
  4. package/dist/app/create/create-record-form.js +1 -0
  5. package/dist/app/current-address.d.ts +5 -1
  6. package/dist/app/current-address.js +2 -1
  7. package/dist/app/displays/map-display.js +50 -34
  8. package/dist/app/displays/map-groups.js +12 -3
  9. package/dist/app/displays/measured-heights.js +23 -0
  10. package/dist/app/displays/record-card.js +29 -17
  11. package/dist/app/displays/virtual-cards.js +12 -14
  12. package/dist/app/embedded-list/embedded-display.js +7 -3
  13. package/dist/app/embedded-list/embedded-list.js +5 -4
  14. package/dist/app/field-editor.js +9 -1
  15. package/dist/app/list-page.js +5 -3
  16. package/dist/app/map/approximate-position.js +76 -0
  17. package/dist/app/map/area-features.js +80 -0
  18. package/dist/app/map/maplibre.js +11 -0
  19. package/dist/app/map/position-areas.js +78 -0
  20. package/dist/app/map/record-map.js +2 -7
  21. package/dist/app/map/record-position.js +5 -4
  22. package/dist/app/map/records-map.js +24 -7
  23. package/dist/app/map/theme-color.js +23 -0
  24. package/dist/app/nav-recent.js +1 -1
  25. package/dist/app/pages/blocks/card-frame.js +2 -2
  26. package/dist/app/pages/blocks/grid.js +10 -2
  27. package/dist/app/pages/blocks/link.js +1 -1
  28. package/dist/app/pages/blocks/record-row.js +51 -0
  29. package/dist/app/pages/blocks/stat.js +9 -5
  30. package/dist/app/pages/field-value.js +1 -1
  31. package/dist/app/pages/render-node.js +2 -0
  32. package/dist/app/record-page.js +5 -1
  33. package/dist/app/record-row/record-row-body.js +114 -0
  34. package/dist/app/record-row/record-row.js +93 -0
  35. package/dist/app/record-row/row-window.js +20 -0
  36. package/dist/app/record-row/saved-record-row.js +42 -0
  37. package/dist/app/record-row/use-row-scroll.js +51 -0
  38. package/dist/app/router.js +7 -8
  39. package/dist/app/saved/saved-records.js +1 -1
  40. package/dist/app/search/search-model.js +1 -1
  41. package/dist/app/searches/filter-chip-list.js +28 -0
  42. package/dist/app/searches/save-search-button.js +72 -8
  43. package/dist/app/searches/saved-search-rows.js +73 -0
  44. package/dist/app/searches/saved-search-section.js +91 -73
  45. package/dist/app/searches/saved-search.js +7 -3
  46. package/dist/app/searches/saved-searches.js +3 -5
  47. package/dist/app/searches/search-controls.js +13 -2
  48. package/dist/app/searches/search-diff.js +97 -0
  49. package/dist/app/searches/search-name-dialog.js +62 -0
  50. package/dist/app/searches/update-search-dialog.js +196 -0
  51. package/dist/app/searches/use-opened-search.js +15 -0
  52. package/dist/app/searches/use-save-search.js +68 -21
  53. package/dist/app/searches/use-search-row.js +56 -0
  54. package/dist/app/use-record-editor.js +5 -3
  55. package/dist/app-shell/app-shell.d.ts +1 -0
  56. package/dist/app-shell/app-shell.js +4 -3
  57. package/dist/app-shell/page-scroll.js +17 -1
  58. package/dist/app-shell/sidebar.d.ts +1 -0
  59. package/dist/app-shell/sidebar.js +4 -4
  60. package/dist/app-shell/top-bar.js +4 -4
  61. package/dist/auth/auth-gate.js +18 -9
  62. package/dist/auth/auth-provider.js +1 -0
  63. package/dist/auth/code-form.js +77 -0
  64. package/dist/auth/email-code-form.js +18 -77
  65. package/dist/auth/forgot-password-form.js +10 -10
  66. package/dist/auth/forgot-password-page.js +2 -2
  67. package/dist/auth/resend-code-button.js +29 -0
  68. package/dist/auth/sign-in-form.js +7 -8
  69. package/dist/auth/sign-in-page.js +3 -3
  70. package/dist/auth/sign-in-prompt.js +13 -9
  71. package/dist/auth/sign-up-code-form.js +22 -0
  72. package/dist/auth/sign-up-details-form.js +81 -111
  73. package/dist/auth/sign-up-page.js +17 -8
  74. package/dist/auth/two-factor-form.js +3 -3
  75. package/dist/auth/use-sign-in-address.js +21 -8
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.js +2 -1
  78. package/dist/lib/flip-guard.js +43 -0
  79. package/dist/lib/page-scroll-lock.js +20 -0
  80. package/dist/lib/use-focus-trap.js +4 -4
  81. package/dist/organizations/invitation-page.js +7 -6
  82. package/dist/package.js +1 -1
  83. package/dist/primitives/dialog.js +3 -2
  84. package/dist/primitives/otp-input.d.ts +21 -0
  85. package/dist/primitives/otp-input.js +117 -0
  86. package/dist/primitives/otp-value.js +23 -0
  87. package/dist/primitives/responsive-dialog.d.ts +3 -1
  88. package/dist/primitives/responsive-dialog.js +2 -1
  89. package/dist/primitives/route-link.d.ts +1 -0
  90. package/dist/primitives/route-link.js +20 -0
  91. package/dist/primitives/textarea.js +36 -0
  92. package/dist/record-view/field.js +56 -53
  93. package/dist/shell/assets/app-sDEk9LFu.js +66 -0
  94. package/dist/shell/assets/index-D0AzKE47.css +2 -0
  95. package/dist/shell/assets/{index-DxmdRnr8.js → index-L7nXyA_7.js} +1 -1
  96. package/dist/shell/assets/maplibre-AOh9A0NP.js +814 -0
  97. package/dist/shell/assets/record-map-DbwHSPf3.js +1 -0
  98. package/dist/shell/assets/records-map-2U_CQ4RG.js +1 -0
  99. package/dist/shell/assets/shared-protobase-ui-BMcStVNP.js +1 -0
  100. package/dist/shell/index.html +4 -4
  101. package/dist/shell/protobase.shell.json +1 -1
  102. package/dist/styles.css +15 -0
  103. package/dist/toasts/toast.js +22 -6
  104. package/package.json +4 -4
  105. package/dist/auth/code-input.js +0 -17
  106. package/dist/auth/sign-in-address.js +0 -47
  107. package/dist/shell/assets/app-BdEV1_xI.js +0 -66
  108. package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
  109. package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
  110. package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
  111. package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
  112. package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
  113. /package/dist/shell/assets/{record-map-DBE96hFo.css → maplibre-DBE96hFo.css} +0 -0
@@ -0,0 +1,93 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { displayRow } from "../displays/list-displays.js";
3
+ import { usePagedRows } from "../../data-grid/use-paged-rows.js";
4
+ import { RecordCard } from "../displays/record-card.js";
5
+ import { rowOffset } from "./row-window.js";
6
+ import { useRowScroll } from "./use-row-scroll.js";
7
+ import { ChevronLeft, ChevronRight } from "lucide-react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/app/record-row/record-row.tsx
10
+ const arrowClass = "absolute top-1/2 z-10 hidden size-8 -translate-y-1/2 items-center justify-center rounded-full border border-border-strong bg-background shadow-pop hover:bg-muted sm:flex";
11
+ /** Stands in for the cards before or after the ones drawn, as wide as they would be. */
12
+ const Spacer = ({ cards }) => cards > 0 ? /* @__PURE__ */ jsx("li", {
13
+ "aria-hidden": true,
14
+ className: "shrink-0",
15
+ style: { width: rowOffset(cards) - 12 }
16
+ }) : null;
17
+ /**
18
+ * Records as the cards of their list, in one row that scrolls sideways by touch, trackpad or its arrow buttons: only the
19
+ * cards near the view are drawn, and the records are loaded through the list API in pages as the row scrolls to them.
20
+ * Each card links to its record.
21
+ */
22
+ const RecordRow = ({ model, view, display, source, label, isNew }) => {
23
+ const { total } = source;
24
+ const scroll = useRowScroll(total);
25
+ const getRow = usePagedRows({
26
+ total,
27
+ rowAt: source.rowAt,
28
+ loadRows: source.loadRows,
29
+ latencyMs: 0
30
+ }, scroll.start, Math.max(scroll.start, scroll.end - 1));
31
+ const indexes = Array.from({ length: scroll.end - scroll.start }, (_, offset) => scroll.start + offset);
32
+ return /* @__PURE__ */ jsxs("div", {
33
+ className: "relative min-w-0",
34
+ children: [
35
+ /* @__PURE__ */ jsxs("ul", {
36
+ ref: scroll.ref,
37
+ "aria-label": label,
38
+ className: "flex snap-x gap-3 overflow-x-auto overflow-y-hidden pb-1",
39
+ style: { scrollbarWidth: "thin" },
40
+ children: [
41
+ /* @__PURE__ */ jsx(Spacer, { cards: scroll.start }),
42
+ indexes.map((index) => {
43
+ const loaded = getRow(index);
44
+ if (!loaded) return /* @__PURE__ */ jsx("li", {
45
+ "aria-hidden": true,
46
+ className: "shrink-0",
47
+ style: { width: 256 },
48
+ children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full min-h-24 rounded-lg" })
49
+ }, `skeleton-${index}`);
50
+ const row = displayRow(model, loaded);
51
+ return /* @__PURE__ */ jsx("li", {
52
+ "aria-posinset": index + 1,
53
+ "aria-setsize": total,
54
+ className: "grid shrink-0 snap-start grid-cols-[minmax(0,1fr)]",
55
+ style: { width: 256 },
56
+ children: /* @__PURE__ */ jsx(RecordCard, {
57
+ model,
58
+ view,
59
+ display,
60
+ row,
61
+ swipe: false,
62
+ mark: isNew?.(row) ? "New" : void 0
63
+ })
64
+ }, row.key);
65
+ }),
66
+ /* @__PURE__ */ jsx(Spacer, { cards: total - scroll.end })
67
+ ]
68
+ }),
69
+ scroll.back && /* @__PURE__ */ jsx("button", {
70
+ type: "button",
71
+ "aria-label": `Previous ${label.toLowerCase()}`,
72
+ onClick: () => scroll.page(-1),
73
+ className: `${arrowClass} -left-3`,
74
+ children: /* @__PURE__ */ jsx(ChevronLeft, {
75
+ className: "size-4",
76
+ "aria-hidden": true
77
+ })
78
+ }),
79
+ scroll.on && /* @__PURE__ */ jsx("button", {
80
+ type: "button",
81
+ "aria-label": `Next ${label.toLowerCase()}`,
82
+ onClick: () => scroll.page(1),
83
+ className: `${arrowClass} -right-3`,
84
+ children: /* @__PURE__ */ jsx(ChevronRight, {
85
+ className: "size-4",
86
+ "aria-hidden": true
87
+ })
88
+ })
89
+ ]
90
+ });
91
+ };
92
+ //#endregion
93
+ export { RecordRow };
@@ -0,0 +1,20 @@
1
+ //#region src/app/record-row/row-window.ts
2
+ const step = 268;
3
+ /** How many cards beyond each edge of the view are drawn, so a short scroll shows cards already there. */
4
+ const overscan = 3;
5
+ /**
6
+ * The cards of a row of `total` to draw when it is scrolled `scrollLeft` into a view `width` wide: from `start` up to
7
+ * `end` (not included), the ones in view and `overscan` more on each side, none past either end.
8
+ */
9
+ const rowWindow = (scrollLeft, width, total) => {
10
+ const first = Math.floor(Math.max(0, scrollLeft) / step);
11
+ const last = Math.ceil((Math.max(0, scrollLeft) + width) / step);
12
+ return {
13
+ start: Math.max(0, first - overscan),
14
+ end: Math.min(total, last + overscan)
15
+ };
16
+ };
17
+ /** The width of the cards before `index`, with their gaps: what stands in for the cards not drawn. */
18
+ const rowOffset = (index) => index * step;
19
+ //#endregion
20
+ export { rowOffset, rowWindow };
@@ -0,0 +1,42 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { listedResource } from "../current-address.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { ErrorBanner } from "../error-banner.js";
6
+ import { savedScope } from "../saved/saved-records.js";
7
+ import { useSavedRecords } from "../saved/use-saved-records.js";
8
+ import { RecordRowBody } from "./record-row-body.js";
9
+ import { jsx } from "react/jsx-runtime";
10
+ //#region src/app/record-row/saved-record-row.tsx
11
+ /**
12
+ * The records the user's saved ones of a view with `listOf` point at, such as the listings among their favorites, as a
13
+ * row of their own resource's cards, with "View all" to the saved records' list. `empty` when there are none.
14
+ */
15
+ const SavedRecordRow = ({ model, view, empty, ...props }) => {
16
+ const { resources, views } = useAdminMeta();
17
+ const listed = listedResource(resources, views, model.name);
18
+ const saved = useSavedRecords(model.name, view.listOf, true);
19
+ const plural = (view.names?.plural ?? humanize(model.name)).toLowerCase();
20
+ if (!listed.listOf) return null;
21
+ if (saved.error) return /* @__PURE__ */ jsx(ErrorBanner, {
22
+ title: `Could not load your ${plural}`,
23
+ error: saved.error
24
+ });
25
+ if (!saved.data) return /* @__PURE__ */ jsx(Skeleton, { className: "h-24" });
26
+ const scope = savedScope(listed.model, saved.data);
27
+ if (!scope) return /* @__PURE__ */ jsx("p", {
28
+ className: "text-[13px] text-muted-foreground",
29
+ children: empty ?? `No ${plural} yet.`
30
+ });
31
+ return /* @__PURE__ */ jsx(RecordRowBody, {
32
+ ...props,
33
+ model: listed.model,
34
+ view: listed.view,
35
+ filter: "",
36
+ scope,
37
+ list: model.name,
38
+ empty
39
+ });
40
+ };
41
+ //#endregion
42
+ export { SavedRecordRow };
@@ -0,0 +1,51 @@
1
+ import { rowOffset, rowWindow } from "./row-window.js";
2
+ import { useLayoutEffect, useRef, useState } from "react";
3
+ //#region src/app/record-row/use-row-scroll.ts
4
+ /**
5
+ * A record row's sideways scroll: `ref` goes on the scrolling list. Gives the cards to draw (`start` to `end`), whether
6
+ * it can scroll back or on, and `page`, which scrolls a view's width of cards either way.
7
+ */
8
+ const useRowScroll = (total) => {
9
+ const ref = useRef(null);
10
+ const [view, setView] = useState({
11
+ left: 0,
12
+ width: 0
13
+ });
14
+ useLayoutEffect(() => {
15
+ const list = ref.current;
16
+ if (!list) return;
17
+ const read = () => setView((current) => {
18
+ const next = {
19
+ left: list.scrollLeft,
20
+ width: list.clientWidth
21
+ };
22
+ const same = (a, b) => {
23
+ const [x, y] = [rowWindow(a.left, a.width, total), rowWindow(b.left, b.width, total)];
24
+ return x.start === y.start && x.end === y.end && a.width === b.width && a.left > 0 === b.left > 0 && Math.abs(a.left - b.left) < 1;
25
+ };
26
+ return same(current, next) ? current : next;
27
+ });
28
+ read();
29
+ const observer = new ResizeObserver(read);
30
+ observer.observe(list);
31
+ list.addEventListener("scroll", read, { passive: true });
32
+ return () => {
33
+ observer.disconnect();
34
+ list.removeEventListener("scroll", read);
35
+ };
36
+ }, [total]);
37
+ const contentWidth = Math.max(0, rowOffset(total) - 12);
38
+ const page = (direction) => ref.current?.scrollBy({
39
+ left: direction * Math.max(rowOffset(1), view.width - 12),
40
+ behavior: "smooth"
41
+ });
42
+ return {
43
+ ref,
44
+ ...rowWindow(view.left, view.width, total),
45
+ back: view.left > 0,
46
+ on: view.left + view.width < contentWidth - 1,
47
+ page
48
+ };
49
+ };
50
+ //#endregion
51
+ export { useRowScroll };
@@ -1,3 +1,4 @@
1
+ import { RouteLink } from "../primitives/route-link.js";
1
2
  import { Button } from "../primitives/button.js";
2
3
  import { Dialog } from "../primitives/dialog.js";
3
4
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@@ -112,15 +113,13 @@ const Router = ({ basePath = "", initialUrl, children }) => {
112
113
  })]
113
114
  });
114
115
  };
115
- const Link = ({ to, onClick, ...rest }) => {
116
+ /** A link to a path below the base path, as `appPath` writes it, that the router follows without reloading the page. */
117
+ const Link = ({ to, replace, ...rest }) => {
116
118
  const { navigate, basePath } = useRouter();
117
- return /* @__PURE__ */ jsx("a", {
118
- href: `${basePath}${to}`,
119
- onClick: (event) => {
120
- onClick?.(event);
121
- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.button !== 0) return;
122
- event.preventDefault();
123
- navigate(to);
119
+ return /* @__PURE__ */ jsx(RouteLink, {
120
+ to: {
121
+ href: `${basePath}${to}`,
122
+ onFollow: () => navigate(to, { replace })
124
123
  },
125
124
  ...rest
126
125
  });
@@ -1,6 +1,6 @@
1
1
  import { recordKey } from "../../live/model-helpers.js";
2
- import { encodeKey } from "@protobase/schema/internal";
3
2
  import { createWhere } from "@protobase/schema";
3
+ import { encodeKey } from "@protobase/schema/internal";
4
4
  //#region src/app/saved/saved-records.ts
5
5
  const where = createWhere();
6
6
  const relationValue = (value) => {
@@ -1,7 +1,7 @@
1
1
  import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
2
  import { humanize } from "../../live/naming.js";
3
- import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
4
3
  import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
4
+ import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
5
5
  //#region src/app/search/search-model.ts
6
6
  const where = createWhere();
7
7
  const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
@@ -0,0 +1,28 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app/searches/filter-chip-list.tsx
4
+ const chipClass = "inline-flex h-6 items-center rounded-full border border-primary-border bg-primary-soft px-2.5 text-xs font-medium text-primary-text";
5
+ /** What a filter filters on, concisely: its chips as a list labelled "Filters". */
6
+ const FilterChipList = ({ chips, className }) => /* @__PURE__ */ jsx("ul", {
7
+ className: cn("flex flex-wrap gap-1.5", className),
8
+ "aria-label": "Filters",
9
+ children: chips.map((chip) => /* @__PURE__ */ jsx("li", {
10
+ className: chipClass,
11
+ children: chip.label
12
+ }, chip.key))
13
+ });
14
+ /** A filter's chips inside a heading or a link, where a list cannot go: read out one after the other. */
15
+ const InlineFilterChips = ({ chips }) => /* @__PURE__ */ jsx("span", {
16
+ className: "flex flex-wrap gap-1.5",
17
+ children: chips.map((chip, index) => /* @__PURE__ */ jsxs("span", {
18
+ className: chipClass,
19
+ children: [index > 0 && /* @__PURE__ */ jsx("span", {
20
+ className: "sr-only",
21
+ children: ", "
22
+ }), chip.label]
23
+ }, chip.key))
24
+ });
25
+ /** A search without a name as words, for a label or a question: its chips one after the other. */
26
+ const chipsText = (chips) => chips.map((chip) => chip.label).join(", ");
27
+ //#endregion
28
+ export { FilterChipList, InlineFilterChips, chipsText };
@@ -1,17 +1,23 @@
1
1
  import { Button } from "../../primitives/button.js";
2
2
  import { SignInPrompt } from "../../auth/sign-in-prompt.js";
3
+ import { SearchNameDialog } from "./search-name-dialog.js";
4
+ import { UpdateSearchDialog } from "./update-search-dialog.js";
3
5
  import { useSaveSearch } from "./use-save-search.js";
4
6
  import { useState } from "react";
5
- import { Bookmark, BookmarkCheck } from "lucide-react";
7
+ import { Bookmark, BookmarkCheck, BookmarkPlus, RefreshCw } from "lucide-react";
6
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
9
  //#region src/app/searches/save-search-button.tsx
8
10
  /**
9
- * "Save search" on a filtered list: saves its filter as one of the signed-in user's searches, pressed while it is saved,
10
- * or, for a visitor, opens the sign-in prompt of the saved searches' resource.
11
+ * "Save search" on a filtered list: asks for an optional name and saves its filter as one of the signed-in user's
12
+ * searches, pressed while it is saved, or, for a visitor, opens the sign-in prompt of the saved searches' resource. On a
13
+ * list opened from a saved search whose filter it no longer has, "Update <search>" asks before giving the search the
14
+ * list's filter, beside "Save as new search".
11
15
  */
12
- const SaveSearchButton = ({ search, filter, name, className }) => {
13
- const state = useSaveSearch(search, filter, name);
16
+ const SaveSearchButton = ({ model, view, search, filter, chips, className }) => {
17
+ const state = useSaveSearch(search, filter);
14
18
  const [prompting, setPrompting] = useState(false);
19
+ const [confirming, setConfirming] = useState(false);
20
+ const [naming, setNaming] = useState();
15
21
  if (!state.mode) return null;
16
22
  const label = search.label ?? "Save search";
17
23
  if (state.mode === "sign-in") return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
@@ -28,17 +34,75 @@ const SaveSearchButton = ({ search, filter, name, className }) => {
28
34
  open: prompting,
29
35
  onClose: () => setPrompting(false)
30
36
  })] });
37
+ const saveNew = (title) => {
38
+ setConfirming(false);
39
+ if (state.naming) setNaming(title);
40
+ else state.saveNew(null);
41
+ };
42
+ const nameDialog = naming !== void 0 && state.naming && /* @__PURE__ */ jsx(SearchNameDialog, {
43
+ title: naming,
44
+ chips,
45
+ label: state.naming.label,
46
+ required: state.naming.required,
47
+ busy: state.creating,
48
+ onSave: (name) => state.saveNew(name, { onSuccess: () => setNaming(void 0) }),
49
+ onClose: () => setNaming(void 0)
50
+ });
51
+ const opened = state.opened;
52
+ if (opened) {
53
+ const saveNewLabel = `Save as new ${state.noun}`;
54
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
55
+ /* @__PURE__ */ jsxs(Button, {
56
+ className,
57
+ "aria-haspopup": "dialog",
58
+ "aria-disabled": !state.ready,
59
+ onClick: () => state.ready && setConfirming(true),
60
+ children: [/* @__PURE__ */ jsx(RefreshCw, {
61
+ className: "size-3.5",
62
+ "aria-hidden": true
63
+ }), /* @__PURE__ */ jsx("span", {
64
+ className: "max-w-60 truncate",
65
+ children: opened.name ? `Update ${opened.name}` : `Update ${state.noun}`
66
+ })]
67
+ }),
68
+ /* @__PURE__ */ jsxs(Button, {
69
+ className,
70
+ "aria-haspopup": state.naming ? "dialog" : void 0,
71
+ "aria-disabled": !state.ready,
72
+ onClick: () => state.ready && saveNew(saveNewLabel),
73
+ children: [/* @__PURE__ */ jsx(BookmarkPlus, {
74
+ className: "size-3.5",
75
+ "aria-hidden": true
76
+ }), saveNewLabel]
77
+ }),
78
+ confirming && /* @__PURE__ */ jsx(UpdateSearchDialog, {
79
+ model,
80
+ view,
81
+ title: opened.name ? `Update ${opened.name}?` : `Update this ${state.noun}?`,
82
+ before: opened.filter,
83
+ after: filter,
84
+ saveNewLabel,
85
+ updating: state.updating,
86
+ creating: state.creating,
87
+ onUpdate: () => state.update({ onSuccess: () => setConfirming(false) }),
88
+ onSaveNew: () => saveNew(saveNewLabel),
89
+ onCancel: () => setConfirming(false)
90
+ }),
91
+ nameDialog
92
+ ] });
93
+ }
31
94
  const Icon = state.saved ? BookmarkCheck : Bookmark;
32
- return /* @__PURE__ */ jsxs(Button, {
95
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
33
96
  className,
34
97
  "aria-pressed": state.saved,
98
+ "aria-haspopup": !state.saved && state.naming ? "dialog" : void 0,
35
99
  "aria-disabled": !state.ready,
36
- onClick: () => state.ready && state.toggle(),
100
+ onClick: () => state.ready && (state.saved ? state.remove() : saveNew(label)),
37
101
  children: [/* @__PURE__ */ jsx(Icon, {
38
102
  className: state.saved ? "size-3.5 text-primary" : "size-3.5",
39
103
  "aria-hidden": true
40
104
  }), label]
41
- });
105
+ }), nameDialog] });
42
106
  };
43
107
  //#endregion
44
108
  export { SaveSearchButton };
@@ -0,0 +1,73 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
+ import { AppLink } from "../use-app-address.js";
6
+ import { ErrorBanner } from "../error-banner.js";
7
+ import { InlineFilterChips } from "./filter-chip-list.js";
8
+ import { useSavedSearches } from "./use-saved-searches.js";
9
+ import { RecordRowBody } from "../record-row/record-row-body.js";
10
+ import { useSearchRow } from "./use-search-row.js";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ import { encodeKey } from "@protobase/schema/internal";
13
+ //#region src/app/searches/saved-search-rows.tsx
14
+ const SearchRowItem = ({ model, view, target, targetView, row }) => {
15
+ const key = encodeKey(recordKey(model, row));
16
+ const { name, chips, title, body } = useSearchRow({
17
+ model,
18
+ of: view.searchesOf,
19
+ target,
20
+ targetView,
21
+ row,
22
+ recordKey: key
23
+ });
24
+ return /* @__PURE__ */ jsxs("section", {
25
+ "aria-label": title,
26
+ className: "flex flex-col gap-3 py-4 first:pt-0 last:pb-0",
27
+ children: [/* @__PURE__ */ jsx("h3", {
28
+ className: "text-[14px] font-semibold",
29
+ children: /* @__PURE__ */ jsx(AppLink, {
30
+ to: {
31
+ kind: "record",
32
+ resource: model.name,
33
+ key
34
+ },
35
+ className: "hover:underline",
36
+ children: name ?? /* @__PURE__ */ jsx(InlineFilterChips, { chips })
37
+ })
38
+ }), /* @__PURE__ */ jsx(RecordRowBody, { ...body })]
39
+ });
40
+ };
41
+ /**
42
+ * The user's saved searches of a view with `searchesOf`, for a page: each with its name or filters, linking to it, over
43
+ * the row of what it finds (`useSearchRow`), in the order of their list. `empty` when there are none.
44
+ */
45
+ const SavedSearchRows = ({ model, view, empty }) => {
46
+ const { resources, views } = useAdminMeta();
47
+ const of = view.searchesOf;
48
+ const target = resources[of.resource];
49
+ const saved = useSavedSearches(model.name, true);
50
+ const plural = (view.names?.plural ?? humanize(model.name)).toLowerCase();
51
+ if (!target) return null;
52
+ if (saved.error) return /* @__PURE__ */ jsx(ErrorBanner, {
53
+ title: `Could not load your ${plural}`,
54
+ error: saved.error
55
+ });
56
+ if (!saved.data) return /* @__PURE__ */ jsx(Skeleton, { className: "h-40" });
57
+ if (saved.data.length === 0) return /* @__PURE__ */ jsx("p", {
58
+ className: "text-[13px] text-muted-foreground",
59
+ children: empty ?? `No ${plural} yet.`
60
+ });
61
+ return /* @__PURE__ */ jsx("div", {
62
+ className: "flex flex-col divide-y",
63
+ children: saved.data.map((row) => /* @__PURE__ */ jsx(SearchRowItem, {
64
+ model,
65
+ view,
66
+ target,
67
+ targetView: views[of.resource],
68
+ row
69
+ }, encodeKey(recordKey(model, row))))
70
+ });
71
+ };
72
+ //#endregion
73
+ export { SavedSearchRows };
@@ -1,96 +1,114 @@
1
1
  import { Button } from "../../primitives/button.js";
2
2
  import { useClient } from "../../data/api-provider.js";
3
3
  import { humanize } from "../../live/naming.js";
4
- import { defaultOrderBy } from "../list-order.js";
4
+ import { useToast } from "../../toasts/toast-provider.js";
5
5
  import { AppLink } from "../use-app-address.js";
6
6
  import { useRefreshResource } from "../pages/use-layout-data.js";
7
- import { searchName, unseenFilter } from "./saved-search.js";
8
- import { EmbeddedList } from "../embedded-list/embedded-list.js";
7
+ import { InlineFilterChips } from "./filter-chip-list.js";
8
+ import { SearchNameDialog } from "./search-name-dialog.js";
9
+ import { RecordRowBody } from "../record-row/record-row-body.js";
9
10
  import { SearchControls } from "./search-controls.js";
10
- import { useFilterChips } from "./use-filter-chips.js";
11
- import { Trash2 } from "lucide-react";
11
+ import { useSearchRow } from "./use-search-row.js";
12
+ import { useState } from "react";
13
+ import { Pencil, Trash2 } from "lucide-react";
12
14
  import { jsx, jsxs } from "react/jsx-runtime";
13
15
  import { useMutation } from "@tanstack/react-query";
14
- import { tableDisplay } from "@protobase/schema/internal";
15
16
  //#region src/app/searches/saved-search-section.tsx
16
- const chipClass = "inline-flex h-6 items-center rounded-full border border-primary-border bg-primary-soft px-2.5 text-xs font-medium text-primary-text";
17
- const defaultLimit = 12;
17
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
18
18
  /**
19
- * One saved search: its name and filters, its controls and delete, and a row of the records it finds that are new to
20
- * the user (with `unseen`), newest first, with "View all results", which opens the list with its filter and counts as
21
- * having seen them.
19
+ * One saved search on its list page: its name when it has one, and what it filters on as chips, which are its title
20
+ * when it has none; its controls, rename and delete; and the records it finds in a row of their cards that scrolls
21
+ * sideways (`useSearchRow`).
22
22
  */
23
- const SavedSearchSection = ({ model, view, of, target, targetView, row, recordKey, title, onDelete }) => {
23
+ const SavedSearchSection = ({ model, view, of, target, targetView, row, recordKey, canRename, onDelete }) => {
24
24
  const client = useClient();
25
+ const toast = useToast();
25
26
  const refresh = useRefreshResource();
26
- const filter = String(row[of.filter] ?? "");
27
- const chips = useFilterChips(target, filter);
28
- const unseen = of.unseen;
29
- const markSeen = useMutation({
30
- mutationFn: (seenAt) => client.update(model.name, recordKey, { [unseen.seenAt]: seenAt }, row.etag ?? "*"),
27
+ const [renaming, setRenaming] = useState(false);
28
+ const { name, chips, title, body } = useSearchRow({
29
+ model,
30
+ of,
31
+ target,
32
+ targetView,
33
+ row,
34
+ recordKey
35
+ });
36
+ const rename = useMutation({
37
+ mutationFn: (next) => client.update(model.name, recordKey, { [of.name]: next }, row.etag ?? "*"),
38
+ onSuccess: () => setRenaming(false),
39
+ onError: (error) => toast.show({
40
+ state: "error",
41
+ title: "Could not rename the search",
42
+ description: message(error)
43
+ }),
31
44
  onSettled: () => refresh(model.name)
32
45
  });
33
- const cards = targetView?.list?.displays?.find((display) => display.kind === "cards");
34
- const plural = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
46
+ const nameField = of.name === void 0 ? void 0 : model.fields[of.name];
35
47
  return /* @__PURE__ */ jsxs("section", {
36
48
  "aria-label": title,
37
49
  className: "flex flex-col gap-3 rounded-lg border bg-surface p-4",
38
- children: [/* @__PURE__ */ jsxs("div", {
39
- className: "flex flex-wrap items-start justify-between gap-3",
40
- children: [/* @__PURE__ */ jsxs("div", {
41
- className: "flex min-w-0 flex-col gap-2",
42
- children: [/* @__PURE__ */ jsx("h2", {
43
- className: "text-[15px] font-semibold",
44
- children: /* @__PURE__ */ jsx(AppLink, {
45
- to: {
46
- kind: "record",
47
- resource: model.name,
48
- key: recordKey
49
- },
50
- className: "hover:underline",
51
- children: title
52
- })
53
- }), chips.length > 0 && searchName(chips) !== title && /* @__PURE__ */ jsx("ul", {
54
- className: "flex flex-wrap gap-1.5",
55
- "aria-label": "Filters",
56
- children: chips.map((chip) => /* @__PURE__ */ jsx("li", {
57
- className: chipClass,
58
- children: chip.label
59
- }, chip.key))
60
- })]
61
- }), /* @__PURE__ */ jsxs("div", {
62
- className: "flex flex-wrap items-center gap-2",
63
- children: [of.controls && /* @__PURE__ */ jsx(SearchControls, {
64
- model,
65
- view,
66
- recordKey,
67
- row,
68
- fields: of.controls
69
- }), onDelete && /* @__PURE__ */ jsx(Button, {
70
- variant: "ghost",
71
- "aria-label": `Delete ${title}`,
72
- onClick: onDelete,
73
- children: /* @__PURE__ */ jsx(Trash2, {
74
- className: "size-3.5",
75
- "aria-hidden": true
50
+ children: [
51
+ /* @__PURE__ */ jsxs("div", {
52
+ className: "flex flex-wrap items-start justify-between gap-3",
53
+ children: [/* @__PURE__ */ jsx("div", {
54
+ className: "flex min-w-0 flex-col gap-2",
55
+ children: /* @__PURE__ */ jsx("h2", {
56
+ className: "text-[15px] font-semibold",
57
+ children: /* @__PURE__ */ jsx(AppLink, {
58
+ to: {
59
+ kind: "record",
60
+ resource: model.name,
61
+ key: recordKey
62
+ },
63
+ className: "hover:underline",
64
+ children: name ?? /* @__PURE__ */ jsx(InlineFilterChips, { chips })
65
+ })
76
66
  })
67
+ }), /* @__PURE__ */ jsxs("div", {
68
+ className: "flex flex-wrap items-center gap-2",
69
+ children: [
70
+ of.controls && /* @__PURE__ */ jsx(SearchControls, {
71
+ model,
72
+ view,
73
+ recordKey,
74
+ title,
75
+ row,
76
+ fields: of.controls
77
+ }),
78
+ canRename && nameField && /* @__PURE__ */ jsx(Button, {
79
+ variant: "ghost",
80
+ "aria-label": `Rename ${title}`,
81
+ "aria-haspopup": "dialog",
82
+ onClick: () => setRenaming(true),
83
+ children: /* @__PURE__ */ jsx(Pencil, {
84
+ className: "size-3.5",
85
+ "aria-hidden": true
86
+ })
87
+ }),
88
+ onDelete && /* @__PURE__ */ jsx(Button, {
89
+ variant: "ghost",
90
+ "aria-label": `Delete ${title}`,
91
+ onClick: () => onDelete(title),
92
+ children: /* @__PURE__ */ jsx(Trash2, {
93
+ className: "size-3.5",
94
+ "aria-hidden": true
95
+ })
96
+ })
97
+ ]
77
98
  })]
78
- })]
79
- }), /* @__PURE__ */ jsx(EmbeddedList, {
80
- model: target,
81
- view: targetView,
82
- filter: unseen ? unseenFilter(filter, unseen.field, row[unseen.seenAt]) : filter,
83
- sort: unseen ? `${unseen.field} desc` : defaultOrderBy(target, targetView),
84
- limit: of.limit ?? defaultLimit,
85
- display: cards?.name ?? tableDisplay,
86
- row: true,
87
- empty: unseen ? `No new ${plural} since you last looked.` : `No ${plural} match.`,
88
- viewAll: {
89
- filter,
90
- label: "View all results",
91
- ...unseen && { onOpen: () => markSeen.mutate((/* @__PURE__ */ new Date()).toISOString()) }
92
- }
93
- })]
99
+ }),
100
+ renaming && nameField && /* @__PURE__ */ jsx(SearchNameDialog, {
101
+ title: `Rename ${title}`,
102
+ chips,
103
+ label: view.fields[nameField.name]?.label ?? humanize(nameField.name),
104
+ required: !nameField.nullable,
105
+ initial: name,
106
+ busy: rename.isPending,
107
+ onSave: (next) => rename.mutate(next),
108
+ onClose: () => setRenaming(false)
109
+ }),
110
+ /* @__PURE__ */ jsx(RecordRowBody, { ...body })
111
+ ]
94
112
  });
95
113
  };
96
114
  //#endregion