@protobase/ui 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/current-address.js +19 -1
- package/dist/app/displays/card-carousel.js +47 -39
- package/dist/app/displays/card-image.js +25 -0
- package/dist/app/displays/card-photos.js +11 -15
- package/dist/app/displays/cards-display.js +2 -1
- package/dist/app/displays/list-displays.d.ts +2 -0
- package/dist/app/displays/list-displays.js +9 -3
- package/dist/app/displays/map-display.js +5 -4
- package/dist/app/displays/record-card.js +28 -34
- package/dist/app/displays/use-map-records.js +8 -6
- package/dist/app/embedded-list/embedded-columns.js +1 -1
- package/dist/app/embedded-list/use-embedded-rows.js +11 -6
- package/dist/app/list-page.js +44 -11
- package/dist/app/menu-icons.js +3 -1
- package/dist/app/named-icon.js +5 -3
- package/dist/app/nav-from-meta.js +21 -1
- package/dist/app/pages/blocks/card-row.js +1 -1
- package/dist/app/pages/blocks/modal-form.js +1 -1
- package/dist/app/pages/blocks/record-card.js +1 -1
- package/dist/app/pages/blocks/show.js +1 -1
- package/dist/app/pages/blocks/stat.js +1 -1
- package/dist/app/pages/use-run-action.js +1 -1
- package/dist/app/saved/favorite-button.js +49 -0
- package/dist/app/saved/saved-list.js +43 -0
- package/dist/app/saved/saved-records.js +38 -0
- package/dist/app/saved/use-favorite.js +62 -0
- package/dist/app/saved/use-saved-records.js +36 -0
- package/dist/app/use-app-address.js +5 -2
- package/dist/auth/sign-in-prompt.d.ts +22 -0
- package/dist/auth/sign-in-prompt.js +84 -0
- package/dist/data/api-provider.d.ts +2 -0
- package/dist/data/meta-data.d.ts +3 -1
- package/dist/data/query-keys.js +0 -5
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/live/grid-loader.js +8 -3
- package/dist/live/reference-options.js +2 -2
- package/dist/live/relation-labels.js +16 -18
- package/dist/live/row-photos.js +18 -0
- package/dist/live/use-filter-config.js +7 -2
- package/dist/package.js +1 -1
- package/dist/primitives/button.js +7 -2
- package/dist/primitives/dialog.js +1 -1
- package/dist/primitives/responsive-dialog.d.ts +17 -0
- package/dist/primitives/responsive-dialog.js +44 -0
- package/dist/shell/assets/app-B2GAOqtJ.js +66 -0
- package/dist/shell/assets/index-BXYHpFoe.css +2 -0
- package/dist/shell/assets/{index-BCLK6g9a.js → index-DTbA97zz.js} +1 -1
- package/dist/shell/assets/{maplibre-gl-DWx1yzYO.js → maplibre-gl-CSTJJJQT.js} +1 -1
- package/dist/shell/assets/{record-map-C2M8dEjy.js → record-map-CeTF-ArN.js} +2 -2
- package/dist/shell/assets/{records-map-huS0J9xV.js → records-map-Cl0Ofb1s.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-4J-P80A-.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-Dkz6z_t_.js +0 -66
- package/dist/shell/assets/index-BQ4FIU4i.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-CsJxPCx6.js +0 -1
package/dist/app/list-page.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
349
|
+
filter: loadFilter
|
|
317
350
|
}),
|
|
318
351
|
desktop ? desktopToolbar : phoneFilters,
|
|
319
352
|
desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
|
package/dist/app/menu-icons.js
CHANGED
|
@@ -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,
|
package/dist/app/named-icon.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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,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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useAuth } from "./auth-provider.js";
|
|
2
|
+
import { Button, buttonClass } from "../primitives/button.js";
|
|
3
|
+
import { namedIcon } from "../app/named-icon.js";
|
|
4
|
+
import { ResponsiveDialog } from "../primitives/responsive-dialog.js";
|
|
5
|
+
import { useId } from "react";
|
|
6
|
+
import { Check, LogIn } from "lucide-react";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/auth/sign-in-prompt.tsx
|
|
9
|
+
const Benefits = ({ benefits }) => benefits && benefits.length > 0 ? /* @__PURE__ */ jsx("ul", {
|
|
10
|
+
className: "flex flex-col gap-2 text-left text-[13px]",
|
|
11
|
+
children: benefits.map((benefit) => /* @__PURE__ */ jsxs("li", {
|
|
12
|
+
className: "flex items-start gap-2",
|
|
13
|
+
children: [/* @__PURE__ */ jsx(Check, {
|
|
14
|
+
className: "mt-0.5 size-4 shrink-0 text-primary",
|
|
15
|
+
"aria-hidden": true
|
|
16
|
+
}), benefit]
|
|
17
|
+
}, benefit))
|
|
18
|
+
}) : null;
|
|
19
|
+
const Actions = ({ prompt }) => {
|
|
20
|
+
const { showSignIn } = useAuth();
|
|
21
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [prompt.signUp && /* @__PURE__ */ jsx("a", {
|
|
22
|
+
href: prompt.signUp.href,
|
|
23
|
+
target: "_blank",
|
|
24
|
+
rel: "noreferrer",
|
|
25
|
+
className: buttonClass(),
|
|
26
|
+
children: prompt.signUp.label
|
|
27
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
28
|
+
variant: "primary",
|
|
29
|
+
onClick: showSignIn,
|
|
30
|
+
children: "Sign in"
|
|
31
|
+
})] });
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* What a visitor who is not signed in is told about something they could use once signed in: the prompt's title, what
|
|
35
|
+
* signing in gives and its benefits, with "Sign in" and, when the prompt names one, a page to make an account. As a page
|
|
36
|
+
* it stands in for a list the visitor cannot see; as a dialog it answers a press on a button that needs an account.
|
|
37
|
+
*/
|
|
38
|
+
const SignInPrompt = (props) => {
|
|
39
|
+
const { prompt } = props;
|
|
40
|
+
const heading = useId();
|
|
41
|
+
if (props.as === "dialog") return /* @__PURE__ */ jsx(ResponsiveDialog, {
|
|
42
|
+
title: prompt.title,
|
|
43
|
+
description: prompt.description,
|
|
44
|
+
open: props.open,
|
|
45
|
+
onClose: props.onClose,
|
|
46
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
47
|
+
onClick: props.onClose,
|
|
48
|
+
children: "Not now"
|
|
49
|
+
}), /* @__PURE__ */ jsx(Actions, { prompt })] }),
|
|
50
|
+
children: /* @__PURE__ */ jsx(Benefits, { benefits: prompt.benefits })
|
|
51
|
+
});
|
|
52
|
+
const Icon = namedIcon(prompt.icon) ?? LogIn;
|
|
53
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
54
|
+
"aria-labelledby": heading,
|
|
55
|
+
className: "mx-auto flex w-full max-w-md flex-col items-center gap-4 px-5 py-10 text-center sm:py-16",
|
|
56
|
+
children: [
|
|
57
|
+
/* @__PURE__ */ jsx("span", {
|
|
58
|
+
className: "flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary",
|
|
59
|
+
children: /* @__PURE__ */ jsx(Icon, {
|
|
60
|
+
className: "size-6",
|
|
61
|
+
"aria-hidden": true
|
|
62
|
+
})
|
|
63
|
+
}),
|
|
64
|
+
/* @__PURE__ */ jsxs("div", {
|
|
65
|
+
className: "flex flex-col gap-1.5",
|
|
66
|
+
children: [/* @__PURE__ */ jsx("h1", {
|
|
67
|
+
id: heading,
|
|
68
|
+
className: "text-lg font-semibold",
|
|
69
|
+
children: prompt.title
|
|
70
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
71
|
+
className: "text-[13px] text-muted-foreground",
|
|
72
|
+
children: prompt.description
|
|
73
|
+
})]
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ jsx(Benefits, { benefits: prompt.benefits }),
|
|
76
|
+
/* @__PURE__ */ jsx("div", {
|
|
77
|
+
className: "mt-2 flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row sm:justify-center [&>*]:min-h-11 sm:[&>*]:min-h-0",
|
|
78
|
+
children: /* @__PURE__ */ jsx(Actions, { prompt })
|
|
79
|
+
})
|
|
80
|
+
]
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { SignInPrompt };
|
|
@@ -20,6 +20,7 @@ export declare const useClient: () => {
|
|
|
20
20
|
facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
|
|
21
21
|
filter?: import("@protobase/client").FilterInput;
|
|
22
22
|
limit?: number;
|
|
23
|
+
label?: string;
|
|
23
24
|
}) => Promise<import("@protobase/client").Facet[]>;
|
|
24
25
|
series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
|
|
25
26
|
filter?: import("@protobase/client").FilterInput;
|
|
@@ -59,6 +60,7 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
59
60
|
facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
|
|
60
61
|
filter?: import("@protobase/client").FilterInput;
|
|
61
62
|
limit?: number;
|
|
63
|
+
label?: string;
|
|
62
64
|
}) => Promise<import("@protobase/client").Facet[]>;
|
|
63
65
|
series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
|
|
64
66
|
filter?: import("@protobase/client").FilterInput;
|