@protobase/ui 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-cards.js +2 -2
- 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/displays/virtual-cards.js +43 -65
- 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 +53 -13
- 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/app-shell/app-shell.js +54 -38
- package/dist/app-shell/bottom-bar.d.ts +1 -0
- package/dist/app-shell/bottom-bar.js +3 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/page-scroll.d.ts +1 -0
- package/dist/app-shell/page-scroll.js +56 -0
- package/dist/app-shell/sidebar.js +2 -2
- package/dist/app-shell/top-bar.js +3 -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/data-grid/data-grid.js +92 -85
- package/dist/data-grid/geometry.js +26 -6
- package/dist/data-grid/grid-footer.js +5 -12
- package/dist/data-grid/scroll-parent.js +35 -0
- package/dist/data-grid/use-virtual-rows.js +123 -18
- 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-CwUFCDf9.js → index-DTbA97zz.js} +1 -1
- package/dist/shell/assets/{maplibre-gl-QGc1hPKt.js → maplibre-gl-CSTJJJQT.js} +1 -1
- package/dist/shell/assets/{record-map-D5vqYcYw.js → record-map-CeTF-ArN.js} +2 -2
- package/dist/shell/assets/{records-map-DqqaK1rm.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/data-grid/use-grid-scroll.js +0 -87
- package/dist/shell/assets/app-DtPlcCA3.js +0 -66
- package/dist/shell/assets/index-CYWfU_c1.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-0ozDlcDr.js +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { humanize } from "../../live/naming.js";
|
|
2
2
|
import { displayRow } from "../displays/list-displays.js";
|
|
3
3
|
import { columnsFromModel } from "../../live/columns-from-model.js";
|
|
4
|
-
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
5
4
|
import { scopeOf } from "../pages/use-layout-data.js";
|
|
5
|
+
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
6
6
|
import { FieldValue } from "../pages/field-value.js";
|
|
7
7
|
import { usePathValue } from "../pages/use-path-value.js";
|
|
8
8
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
import { keys } from "../../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../../data/api-provider.js";
|
|
3
3
|
import { recordId } from "../../live/model-helpers.js";
|
|
4
|
-
import { withLabels } from "../../live/relation-labels.js";
|
|
4
|
+
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
5
|
+
import { listPhotos, withPhotos } from "../../live/row-photos.js";
|
|
5
6
|
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
-
import { keepPreviousData, useQuery
|
|
7
|
+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
7
8
|
//#region src/app/embedded-list/use-embedded-rows.ts
|
|
8
9
|
/**
|
|
9
10
|
* The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
|
|
10
|
-
* `fields` as `{ id, name }
|
|
11
|
+
* `fields` as `{ id, name }`, named in the same call, with the photos of its cards. A filter through a join resource may hold hundreds of keys, so it
|
|
12
|
+
* goes in a JSON body.
|
|
11
13
|
*/
|
|
12
14
|
const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
|
|
13
15
|
const client = useClient();
|
|
14
|
-
const queryClient = useQueryClient();
|
|
15
16
|
const { resources, views } = useAdminMeta();
|
|
16
17
|
return useQuery({
|
|
17
18
|
queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
|
|
18
19
|
placeholderData: keepPreviousData,
|
|
19
20
|
queryFn: async () => {
|
|
21
|
+
const labels = listLabels(resources, views, model, fields);
|
|
22
|
+
const photos = listPhotos(views[model.name]);
|
|
20
23
|
const page = await client.search(model.name, {
|
|
21
24
|
filter: filter || void 0,
|
|
22
25
|
orderBy: orderBy || void 0,
|
|
23
|
-
pageSize: limit
|
|
26
|
+
pageSize: limit,
|
|
27
|
+
labels,
|
|
28
|
+
photos
|
|
24
29
|
});
|
|
25
|
-
return
|
|
30
|
+
return withLabels(resources, model, fields, withPhotos(model, page.items, page.photos), page.labels).map((row, index) => ({
|
|
26
31
|
...row,
|
|
27
32
|
id: recordId(model, page.items[index])
|
|
28
33
|
}));
|
package/dist/app/list-page.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useIsDesktop } from "../lib/use-media-query.js";
|
|
2
|
+
import { usePageScroll } from "../app-shell/page-scroll.js";
|
|
2
3
|
import { PageHeader } from "../app-shell/page-header.js";
|
|
3
4
|
import { useAuth } from "../auth/auth-provider.js";
|
|
4
5
|
import { Button } from "../primitives/button.js";
|
|
@@ -7,6 +8,7 @@ import { humanize } from "../live/naming.js";
|
|
|
7
8
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
8
9
|
import { loadedFields, shownDisplay } from "./displays/list-displays.js";
|
|
9
10
|
import { parseSort, sortToOrderBy } from "./list-order.js";
|
|
11
|
+
import { listedResource } from "./current-address.js";
|
|
10
12
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
11
13
|
import { useAppAddress } from "./use-app-address.js";
|
|
12
14
|
import { ErrorBanner } from "./error-banner.js";
|
|
@@ -26,6 +28,7 @@ import { useLiveRows } from "../live/use-live-rows.js";
|
|
|
26
28
|
import { Sheet } from "../primitives/sheet.js";
|
|
27
29
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
28
30
|
import { useRestore } from "./delete/use-restore.js";
|
|
31
|
+
import { SignInPrompt } from "../auth/sign-in-prompt.js";
|
|
29
32
|
import { ListCards } from "./displays/list-cards.js";
|
|
30
33
|
import { MapDisplay } from "./displays/map-display.js";
|
|
31
34
|
import { CustomDisplay } from "./displays/custom-display.js";
|
|
@@ -35,6 +38,7 @@ import { useListScroll } from "./list-scroll.js";
|
|
|
35
38
|
import { ListChart } from "./list-chart.js";
|
|
36
39
|
import { ListSearch } from "./list-search.js";
|
|
37
40
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
41
|
+
import { SavedList } from "./saved/saved-list.js";
|
|
38
42
|
import { useMemo, useState } from "react";
|
|
39
43
|
import { Plus, SlidersHorizontal } from "lucide-react";
|
|
40
44
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -43,10 +47,18 @@ import { createWhere } from "@protobase/schema";
|
|
|
43
47
|
//#region src/app/list-page.tsx
|
|
44
48
|
const where = createWhere();
|
|
45
49
|
const searchText = (extras) => extras.find((clause) => clause.kind === "search");
|
|
50
|
+
/**
|
|
51
|
+
* A resource's list: its rows, or with `listOf` the records its relation points at (`SavedList`). A visitor who cannot
|
|
52
|
+
* see the resource is told what signing in gives, when its view says (`SignInPrompt`).
|
|
53
|
+
*/
|
|
46
54
|
const ListPage = ({ address }) => {
|
|
47
|
-
const { resources } = useAdminMeta();
|
|
55
|
+
const { resources, views, signIn } = useAdminMeta();
|
|
48
56
|
const model = resources[address.resource];
|
|
49
|
-
|
|
57
|
+
const prompt = signIn?.[address.resource];
|
|
58
|
+
if (!model) return prompt ? /* @__PURE__ */ jsx(SignInPrompt, {
|
|
59
|
+
as: "page",
|
|
60
|
+
prompt: prompt.signIn
|
|
61
|
+
}) : /* @__PURE__ */ jsxs(Notice, {
|
|
50
62
|
title: "Not found",
|
|
51
63
|
children: [
|
|
52
64
|
"There is no resource called ",
|
|
@@ -54,12 +66,31 @@ const ListPage = ({ address }) => {
|
|
|
54
66
|
"."
|
|
55
67
|
]
|
|
56
68
|
});
|
|
69
|
+
const view = views[address.resource];
|
|
70
|
+
const listed = listedResource(resources, views, address.resource);
|
|
71
|
+
if (view && listed.listOf) {
|
|
72
|
+
const target = listed.model;
|
|
73
|
+
return /* @__PURE__ */ jsx(SavedList, {
|
|
74
|
+
model,
|
|
75
|
+
view,
|
|
76
|
+
target,
|
|
77
|
+
children: (scope) => /* @__PURE__ */ jsx(LoadedList, {
|
|
78
|
+
model: target,
|
|
79
|
+
address,
|
|
80
|
+
scope,
|
|
81
|
+
heading: {
|
|
82
|
+
title: view.names?.plural ?? humanize(model.name),
|
|
83
|
+
subtitle: view.help
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
});
|
|
87
|
+
}
|
|
57
88
|
return /* @__PURE__ */ jsx(LoadedList, {
|
|
58
89
|
model,
|
|
59
90
|
address
|
|
60
91
|
});
|
|
61
92
|
};
|
|
62
|
-
const LoadedList = ({ model, address }) => {
|
|
93
|
+
const LoadedList = ({ model, address, scope, heading }) => {
|
|
63
94
|
const meta = useAdminMeta();
|
|
64
95
|
const { set, go } = useAppAddress();
|
|
65
96
|
const resource = model.name;
|
|
@@ -78,14 +109,16 @@ const LoadedList = ({ model, address }) => {
|
|
|
78
109
|
const { orderBy, filterLayout: layout } = address;
|
|
79
110
|
const desktop = useIsDesktop();
|
|
80
111
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
112
|
+
const scoped = scope ? [...extras, scope] : extras;
|
|
81
113
|
const config = useFilterConfig({
|
|
82
114
|
model,
|
|
83
115
|
resources: meta.resources,
|
|
84
116
|
views: meta.views,
|
|
85
117
|
view,
|
|
86
118
|
state,
|
|
87
|
-
extras
|
|
119
|
+
extras: scoped
|
|
88
120
|
});
|
|
121
|
+
const loadFilter = scope ? stateToText(fields, state, scoped) : parsed.ok ? filterText : "";
|
|
89
122
|
const loaded = useMemo(() => loadedFields(model, view, columns.map((column) => column.id)), [
|
|
90
123
|
model,
|
|
91
124
|
view,
|
|
@@ -96,10 +129,12 @@ const LoadedList = ({ model, address }) => {
|
|
|
96
129
|
views: meta.views,
|
|
97
130
|
model,
|
|
98
131
|
columns: loaded,
|
|
99
|
-
filter:
|
|
132
|
+
filter: loadFilter,
|
|
100
133
|
orderBy
|
|
101
134
|
});
|
|
102
135
|
const display = shownDisplay(view, address.display);
|
|
136
|
+
const pageScroll = !display || display.kind === "cards";
|
|
137
|
+
usePageScroll(pageScroll);
|
|
103
138
|
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
104
139
|
const scroll = useListScroll();
|
|
105
140
|
const titleField = recordTitleField(model, view);
|
|
@@ -208,7 +243,7 @@ const LoadedList = ({ model, address }) => {
|
|
|
208
243
|
})
|
|
209
244
|
]
|
|
210
245
|
});
|
|
211
|
-
const createButton = can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
|
|
246
|
+
const createButton = !scope && can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
|
|
212
247
|
variant: "primary",
|
|
213
248
|
onClick: () => go({
|
|
214
249
|
kind: "create",
|
|
@@ -255,7 +290,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
255
290
|
}] : [],
|
|
256
291
|
rowNumbers: false,
|
|
257
292
|
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
|
|
258
|
-
|
|
293
|
+
scroll: "parent",
|
|
294
|
+
className: "flex-1"
|
|
259
295
|
}) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
|
|
260
296
|
const cardsList = {
|
|
261
297
|
...rows,
|
|
@@ -274,11 +310,12 @@ const LoadedList = ({ model, address }) => {
|
|
|
274
310
|
model,
|
|
275
311
|
view,
|
|
276
312
|
display,
|
|
277
|
-
filter:
|
|
313
|
+
filter: loadFilter,
|
|
278
314
|
orderBy,
|
|
279
315
|
fields: loaded,
|
|
280
316
|
at: address.map,
|
|
281
|
-
onMove: (map) => set({ map })
|
|
317
|
+
onMove: (map) => set({ map }),
|
|
318
|
+
flush: true
|
|
282
319
|
});
|
|
283
320
|
return /* @__PURE__ */ jsx(CustomDisplay, {
|
|
284
321
|
display,
|
|
@@ -291,8 +328,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
291
328
|
children: [
|
|
292
329
|
remove.dialogs,
|
|
293
330
|
/* @__PURE__ */ jsx(PageHeader, {
|
|
294
|
-
title: plural,
|
|
295
|
-
subtitle: view?.help,
|
|
331
|
+
title: heading?.title ?? plural,
|
|
332
|
+
subtitle: heading ? heading.subtitle : view?.help,
|
|
296
333
|
aside: !desktop && displaySwitch(true),
|
|
297
334
|
actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
|
|
298
335
|
}),
|
|
@@ -309,12 +346,15 @@ const LoadedList = ({ model, address }) => {
|
|
|
309
346
|
chart: view.chart,
|
|
310
347
|
title: `${plural} created`,
|
|
311
348
|
noun: plural.toLowerCase(),
|
|
312
|
-
filter:
|
|
349
|
+
filter: loadFilter
|
|
313
350
|
}),
|
|
314
351
|
desktop ? desktopToolbar : phoneFilters,
|
|
315
352
|
desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
|
|
316
353
|
className: "flex min-h-0 flex-1 gap-4",
|
|
317
|
-
children: [
|
|
354
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
355
|
+
className: pageScroll ? "sticky top-[calc(var(--pb-sticky-top,0px)+1rem)] flex max-h-[calc(100dvh-var(--pb-sticky-top,0px)-var(--pb-sticky-bottom,0px)-2rem)] shrink-0 self-start" : "flex shrink-0",
|
|
356
|
+
children: panel
|
|
357
|
+
}), shown]
|
|
318
358
|
}) : shown
|
|
319
359
|
]
|
|
320
360
|
});
|
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
|