@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.
- package/dist/account/two-factor-setup.js +6 -5
- package/dist/app/app.js +1 -1
- package/dist/app/create/create-page.js +1 -0
- package/dist/app/create/create-record-form.js +1 -0
- package/dist/app/current-address.d.ts +5 -1
- package/dist/app/current-address.js +2 -1
- package/dist/app/displays/map-display.js +50 -34
- package/dist/app/displays/map-groups.js +12 -3
- package/dist/app/displays/measured-heights.js +23 -0
- package/dist/app/displays/record-card.js +29 -17
- package/dist/app/displays/virtual-cards.js +12 -14
- package/dist/app/embedded-list/embedded-display.js +7 -3
- package/dist/app/embedded-list/embedded-list.js +5 -4
- package/dist/app/field-editor.js +9 -1
- package/dist/app/list-page.js +5 -3
- package/dist/app/map/approximate-position.js +76 -0
- package/dist/app/map/area-features.js +80 -0
- package/dist/app/map/maplibre.js +11 -0
- package/dist/app/map/position-areas.js +78 -0
- package/dist/app/map/record-map.js +2 -7
- package/dist/app/map/record-position.js +5 -4
- package/dist/app/map/records-map.js +24 -7
- package/dist/app/map/theme-color.js +23 -0
- package/dist/app/nav-recent.js +1 -1
- package/dist/app/pages/blocks/card-frame.js +2 -2
- package/dist/app/pages/blocks/grid.js +10 -2
- package/dist/app/pages/blocks/link.js +1 -1
- package/dist/app/pages/blocks/record-row.js +51 -0
- package/dist/app/pages/blocks/stat.js +9 -5
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/record-page.js +5 -1
- package/dist/app/record-row/record-row-body.js +114 -0
- package/dist/app/record-row/record-row.js +93 -0
- package/dist/app/record-row/row-window.js +20 -0
- package/dist/app/record-row/saved-record-row.js +42 -0
- package/dist/app/record-row/use-row-scroll.js +51 -0
- package/dist/app/router.js +7 -8
- package/dist/app/saved/saved-records.js +1 -1
- package/dist/app/search/search-model.js +1 -1
- package/dist/app/searches/filter-chip-list.js +28 -0
- package/dist/app/searches/save-search-button.js +72 -8
- package/dist/app/searches/saved-search-rows.js +73 -0
- package/dist/app/searches/saved-search-section.js +91 -73
- package/dist/app/searches/saved-search.js +7 -3
- package/dist/app/searches/saved-searches.js +3 -5
- package/dist/app/searches/search-controls.js +13 -2
- package/dist/app/searches/search-diff.js +97 -0
- package/dist/app/searches/search-name-dialog.js +62 -0
- package/dist/app/searches/update-search-dialog.js +196 -0
- package/dist/app/searches/use-opened-search.js +15 -0
- package/dist/app/searches/use-save-search.js +68 -21
- package/dist/app/searches/use-search-row.js +56 -0
- package/dist/app/use-record-editor.js +5 -3
- package/dist/app-shell/app-shell.d.ts +1 -0
- package/dist/app-shell/app-shell.js +4 -3
- package/dist/app-shell/page-scroll.js +17 -1
- package/dist/app-shell/sidebar.d.ts +1 -0
- package/dist/app-shell/sidebar.js +4 -4
- package/dist/app-shell/top-bar.js +4 -4
- package/dist/auth/auth-gate.js +18 -9
- package/dist/auth/auth-provider.js +1 -0
- package/dist/auth/code-form.js +77 -0
- package/dist/auth/email-code-form.js +18 -77
- package/dist/auth/forgot-password-form.js +10 -10
- package/dist/auth/forgot-password-page.js +2 -2
- package/dist/auth/resend-code-button.js +29 -0
- package/dist/auth/sign-in-form.js +7 -8
- package/dist/auth/sign-in-page.js +3 -3
- package/dist/auth/sign-in-prompt.js +13 -9
- package/dist/auth/sign-up-code-form.js +22 -0
- package/dist/auth/sign-up-details-form.js +81 -111
- package/dist/auth/sign-up-page.js +17 -8
- package/dist/auth/two-factor-form.js +3 -3
- package/dist/auth/use-sign-in-address.js +21 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/flip-guard.js +43 -0
- package/dist/lib/page-scroll-lock.js +20 -0
- package/dist/lib/use-focus-trap.js +4 -4
- package/dist/organizations/invitation-page.js +7 -6
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +3 -2
- package/dist/primitives/otp-input.d.ts +21 -0
- package/dist/primitives/otp-input.js +117 -0
- package/dist/primitives/otp-value.js +23 -0
- package/dist/primitives/responsive-dialog.d.ts +3 -1
- package/dist/primitives/responsive-dialog.js +2 -1
- package/dist/primitives/route-link.d.ts +1 -0
- package/dist/primitives/route-link.js +20 -0
- package/dist/primitives/textarea.js +36 -0
- package/dist/record-view/field.js +56 -53
- package/dist/shell/assets/app-sDEk9LFu.js +66 -0
- package/dist/shell/assets/index-D0AzKE47.css +2 -0
- package/dist/shell/assets/{index-DxmdRnr8.js → index-L7nXyA_7.js} +1 -1
- package/dist/shell/assets/maplibre-AOh9A0NP.js +814 -0
- package/dist/shell/assets/record-map-DbwHSPf3.js +1 -0
- package/dist/shell/assets/records-map-2U_CQ4RG.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-BMcStVNP.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/styles.css +15 -0
- package/dist/toasts/toast.js +22 -6
- package/package.json +4 -4
- package/dist/auth/code-input.js +0 -17
- package/dist/auth/sign-in-address.js +0 -47
- package/dist/shell/assets/app-BdEV1_xI.js +0 -66
- package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
- package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
- package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
- package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
- /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 };
|
package/dist/app/router.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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,
|
|
13
|
-
const state = useSaveSearch(search, filter
|
|
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.
|
|
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 {
|
|
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 {
|
|
8
|
-
import {
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
|
17
|
-
const defaultLimit = 12;
|
|
17
|
+
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
18
18
|
/**
|
|
19
|
-
* One saved search: its name
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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,
|
|
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
|
|
27
|
-
const chips =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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: [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|