@protobase/ui 0.16.0 → 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/app/current-address.d.ts +5 -1
- package/dist/app/current-address.js +2 -1
- package/dist/app/displays/record-card.js +28 -16
- package/dist/app/embedded-list/embedded-display.js +7 -3
- package/dist/app/embedded-list/embedded-list.js +5 -4
- package/dist/app/list-page.js +4 -2
- package/dist/app/pages/blocks/record-row.js +51 -0
- package/dist/app/pages/render-node.js +2 -0
- 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/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 +64 -18
- package/dist/app/searches/use-search-row.js +56 -0
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +3 -2
- package/dist/primitives/responsive-dialog.d.ts +3 -1
- package/dist/primitives/responsive-dialog.js +2 -1
- package/dist/shell/assets/app-sDEk9LFu.js +66 -0
- package/dist/shell/assets/index-D0AzKE47.css +2 -0
- package/dist/shell/assets/{index-LBhMBiK4.js → index-L7nXyA_7.js} +1 -1
- package/dist/shell/assets/{maplibre-Dy0ZTnFZ.js → maplibre-AOh9A0NP.js} +1 -1
- package/dist/shell/assets/{record-map-BiWSbqqT.js → record-map-DbwHSPf3.js} +1 -1
- package/dist/shell/assets/{records-map-DabYc3ad.js → records-map-2U_CQ4RG.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BURelnPG.js → shared-protobase-ui-BMcStVNP.js} +1 -1
- 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-DJAZP1yB.js +0 -66
- package/dist/shell/assets/index-C5pYkUfv.css +0 -2
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
|
|
2
2
|
//#region src/app/current-address.d.ts
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* A list as the app shows it: every value there, the view's default where the address names none; `map` and
|
|
5
|
+
* `savedSearch`, the saved search it was opened from, have none.
|
|
6
|
+
*/
|
|
4
7
|
export type ListAddress = {
|
|
5
8
|
kind: 'list';
|
|
6
9
|
resource: string;
|
|
@@ -9,6 +12,7 @@ export type ListAddress = {
|
|
|
9
12
|
orderBy: string;
|
|
10
13
|
filterLayout: FilterLayout;
|
|
11
14
|
map?: MapView;
|
|
15
|
+
savedSearch?: string;
|
|
12
16
|
};
|
|
13
17
|
/** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
|
|
14
18
|
export type CurrentAddress = ListAddress | {
|
|
@@ -54,7 +54,8 @@ const changedList = (read, change, defaults) => {
|
|
|
54
54
|
...differs("display") && { display: next.display },
|
|
55
55
|
...differs("orderBy") && { orderBy: next.orderBy },
|
|
56
56
|
...differs("filterLayout") && { filterLayout: next.filterLayout },
|
|
57
|
-
...next.map && { map: next.map }
|
|
57
|
+
...next.map && { map: next.map },
|
|
58
|
+
...next.savedSearch && { savedSearch: next.savedSearch }
|
|
58
59
|
};
|
|
59
60
|
};
|
|
60
61
|
//#endregion
|
|
@@ -32,7 +32,7 @@ const MetaValue = ({ icon, children }) => {
|
|
|
32
32
|
* value, joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it
|
|
33
33
|
* is missing or throws. With a favorite button, a heart in the top right corner saves the record.
|
|
34
34
|
*/
|
|
35
|
-
const RecordCard = ({ model, view, display, row, swipe = true }) => {
|
|
35
|
+
const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
|
|
36
36
|
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
37
37
|
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
38
38
|
const text = (name) => {
|
|
@@ -135,32 +135,44 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
|
|
|
135
135
|
}), shownValue(name)] }, name))
|
|
136
136
|
})
|
|
137
137
|
] });
|
|
138
|
-
const card = (picture) => /* @__PURE__ */
|
|
138
|
+
const card = (picture) => /* @__PURE__ */ jsxs(AppLink, {
|
|
139
139
|
to: {
|
|
140
140
|
kind: "record",
|
|
141
141
|
resource: model.name,
|
|
142
142
|
key: row.key
|
|
143
143
|
},
|
|
144
144
|
className: "flex h-full flex-col gap-1.5 rounded-lg border border-border bg-background p-3 text-[13px] hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
|
|
145
|
-
children:
|
|
145
|
+
children: [mark && !pictured && /* @__PURE__ */ jsx(Badge, {
|
|
146
|
+
tone: "blue",
|
|
147
|
+
className: "self-start",
|
|
148
|
+
children: mark
|
|
149
|
+
}), Card ? /* @__PURE__ */ jsx(CustomBoundary, {
|
|
146
150
|
name: display.card,
|
|
147
151
|
fallback: builtIn(picture),
|
|
148
152
|
children: /* @__PURE__ */ jsx(Card, { row })
|
|
149
|
-
}) : builtIn(picture)
|
|
153
|
+
}) : builtIn(picture)]
|
|
150
154
|
});
|
|
151
155
|
return /* @__PURE__ */ jsxs("div", {
|
|
152
|
-
className: photos ? "group/card relative h-full" : favorite ? "relative h-full" : "h-full",
|
|
153
|
-
children: [
|
|
154
|
-
photos
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
className: photos ? "group/card relative h-full" : favorite || mark ? "relative h-full" : "h-full",
|
|
157
|
+
children: [
|
|
158
|
+
photos ? /* @__PURE__ */ jsx(CardCarousel, {
|
|
159
|
+
photos: row.photos ?? [],
|
|
160
|
+
cover,
|
|
161
|
+
swipe,
|
|
162
|
+
children: card
|
|
163
|
+
}, row.key) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })),
|
|
164
|
+
mark && pictured && /* @__PURE__ */ jsx(Badge, {
|
|
165
|
+
tone: "blue",
|
|
166
|
+
className: "pointer-events-none absolute top-2 left-2 shadow-sm",
|
|
167
|
+
children: mark
|
|
168
|
+
}),
|
|
169
|
+
favorite && /* @__PURE__ */ jsx(FavoriteButton, {
|
|
170
|
+
favorite,
|
|
171
|
+
recordKey: row.key,
|
|
172
|
+
target: recordKey(model, row.record),
|
|
173
|
+
className: pictured ? void 0 : "right-1.5 top-1.5"
|
|
174
|
+
})
|
|
175
|
+
]
|
|
164
176
|
});
|
|
165
177
|
};
|
|
166
178
|
//#endregion
|
|
@@ -17,10 +17,14 @@ const embeddedDisplay = (view, xs, { picked, preferred }) => {
|
|
|
17
17
|
if (picked !== void 0 && names.includes(picked)) return picked;
|
|
18
18
|
return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
|
|
19
19
|
};
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The resource's list with the embedded list's filter, order and display, leaving out what the list does by default, and
|
|
22
|
+
* the saved search whose results it shows, if any. `resource` names another list of the same records, such as the
|
|
23
|
+
* favorites that list a user's saved listings.
|
|
24
|
+
*/
|
|
25
|
+
const viewAllAddress = (model, view, xs, { resource = model.name, ...list }) => changedList({
|
|
22
26
|
kind: "list",
|
|
23
|
-
resource
|
|
27
|
+
resource
|
|
24
28
|
}, list, listDefaults(model, view, xs));
|
|
25
29
|
//#endregion
|
|
26
30
|
export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
|
|
@@ -36,7 +36,7 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
|
36
36
|
* The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
|
|
37
37
|
* to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
|
|
38
38
|
*/
|
|
39
|
-
const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll }) => {
|
|
39
|
+
const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll, link = true }) => {
|
|
40
40
|
const { resources, views } = useAdminMeta();
|
|
41
41
|
const { fields: fieldTypes } = useProjectUi();
|
|
42
42
|
const { go } = useAppAddress();
|
|
@@ -74,11 +74,12 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
|
|
|
74
74
|
}, [loaded]);
|
|
75
75
|
const shown = shownDisplay(view, display);
|
|
76
76
|
const plural = view?.names?.plural ?? humanize(model.name);
|
|
77
|
-
const allLink = /* @__PURE__ */ jsxs(AppLink, {
|
|
77
|
+
const allLink = link && /* @__PURE__ */ jsxs(AppLink, {
|
|
78
78
|
to: viewAllAddress(model, view, xs, viewAll ? {
|
|
79
79
|
filter: viewAll.filter,
|
|
80
80
|
orderBy: defaultOrderBy(model, view),
|
|
81
|
-
display
|
|
81
|
+
display,
|
|
82
|
+
savedSearch: viewAll.savedSearch
|
|
82
83
|
} : {
|
|
83
84
|
filter,
|
|
84
85
|
orderBy,
|
|
@@ -104,7 +105,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
|
|
|
104
105
|
className: "text-[13px] text-muted-foreground",
|
|
105
106
|
children: empty ?? "Nothing here yet."
|
|
106
107
|
});
|
|
107
|
-
return viewAll ? /* @__PURE__ */ jsxs("div", {
|
|
108
|
+
return viewAll && link ? /* @__PURE__ */ jsxs("div", {
|
|
108
109
|
className: "flex flex-col gap-3",
|
|
109
110
|
children: [nothing, allLink]
|
|
110
111
|
}) : nothing;
|
package/dist/app/list-page.js
CHANGED
|
@@ -39,7 +39,7 @@ import { ListChart } from "./list-chart.js";
|
|
|
39
39
|
import { ListSearch } from "./list-search.js";
|
|
40
40
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
41
41
|
import { SavedList } from "./saved/saved-list.js";
|
|
42
|
-
import { facetNames, filterChips
|
|
42
|
+
import { facetNames, filterChips } from "./searches/saved-search.js";
|
|
43
43
|
import { SaveSearchButton } from "./searches/save-search-button.js";
|
|
44
44
|
import { SavedSearches } from "./searches/saved-searches.js";
|
|
45
45
|
import { useMemo, useState } from "react";
|
|
@@ -264,9 +264,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
264
264
|
]
|
|
265
265
|
});
|
|
266
266
|
const saveSearch = view?.saveSearch && !scope && parsed.ok && filterText && /* @__PURE__ */ jsx(SaveSearchButton, {
|
|
267
|
+
model,
|
|
268
|
+
view,
|
|
267
269
|
search: view.saveSearch,
|
|
268
270
|
filter: filterText,
|
|
269
|
-
|
|
271
|
+
chips: filterChips(model, view, filterText, facetNames(config))
|
|
270
272
|
});
|
|
271
273
|
const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
272
274
|
view,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useAdminMeta } from "../../meta-gate.js";
|
|
2
|
+
import { RecordRowBody } from "../../record-row/record-row-body.js";
|
|
3
|
+
import { propsOf } from "./block-props.js";
|
|
4
|
+
import { CardFrame } from "./card-frame.js";
|
|
5
|
+
import { SavedRecordRow } from "../../record-row/saved-record-row.js";
|
|
6
|
+
import { SavedSearchRows } from "../../searches/saved-search-rows.js";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/pages/blocks/record-row.tsx
|
|
9
|
+
/**
|
|
10
|
+
* What a filter finds: its chips, the count, its records as a sideways row of their list's cards, and "View all". Of a
|
|
11
|
+
* resource whose view has `listOf`, the records the user's saved ones point at; of one with `searchesOf`, a row per
|
|
12
|
+
* saved search of the user's.
|
|
13
|
+
*/
|
|
14
|
+
const RecordRowBlock = ({ node, slots }) => {
|
|
15
|
+
const { resource, filter = "", sort, limit, display, title, description, empty } = propsOf(node);
|
|
16
|
+
const { resources, views } = useAdminMeta();
|
|
17
|
+
const model = resources[resource];
|
|
18
|
+
if (!model) return null;
|
|
19
|
+
const view = views[model.name];
|
|
20
|
+
const body = () => {
|
|
21
|
+
if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearchRows, {
|
|
22
|
+
model,
|
|
23
|
+
view,
|
|
24
|
+
empty
|
|
25
|
+
});
|
|
26
|
+
if (view?.listOf !== void 0) return /* @__PURE__ */ jsx(SavedRecordRow, {
|
|
27
|
+
model,
|
|
28
|
+
view,
|
|
29
|
+
limit,
|
|
30
|
+
display,
|
|
31
|
+
empty
|
|
32
|
+
});
|
|
33
|
+
return /* @__PURE__ */ jsx(RecordRowBody, {
|
|
34
|
+
model,
|
|
35
|
+
view,
|
|
36
|
+
filter,
|
|
37
|
+
sort,
|
|
38
|
+
limit,
|
|
39
|
+
display,
|
|
40
|
+
empty
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
return /* @__PURE__ */ jsx(CardFrame, {
|
|
44
|
+
title,
|
|
45
|
+
description,
|
|
46
|
+
actions: slots.actions,
|
|
47
|
+
children: body()
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { RecordRowBlock };
|
|
@@ -11,6 +11,7 @@ import { ModalFormBlock } from "./blocks/modal-form.js";
|
|
|
11
11
|
import { PageBlock } from "./blocks/page-block.js";
|
|
12
12
|
import { ProgressBlock } from "./blocks/progress.js";
|
|
13
13
|
import { RecordCardBlock } from "./blocks/record-card.js";
|
|
14
|
+
import { RecordRowBlock } from "./blocks/record-row.js";
|
|
14
15
|
import { ShowBlock } from "./blocks/show.js";
|
|
15
16
|
import { StatBlock } from "./blocks/stat.js";
|
|
16
17
|
import { TableBlock } from "./blocks/table.js";
|
|
@@ -28,6 +29,7 @@ const blocks = {
|
|
|
28
29
|
Field: FieldBlock,
|
|
29
30
|
Table: TableBlock,
|
|
30
31
|
CardRow: CardRowBlock,
|
|
32
|
+
RecordRow: RecordRowBlock,
|
|
31
33
|
Progress: ProgressBlock,
|
|
32
34
|
ModalForm: ModalFormBlock,
|
|
33
35
|
Action: ActionBlock,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useIsXs } from "../../lib/use-media-query.js";
|
|
2
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { displaysOf, loadedFields } from "../displays/list-displays.js";
|
|
5
|
+
import { defaultOrderBy } from "../list-order.js";
|
|
6
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
7
|
+
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
9
|
+
import { useLiveRows } from "../../live/use-live-rows.js";
|
|
10
|
+
import { FilterChipList } from "../searches/filter-chip-list.js";
|
|
11
|
+
import { viewAllAddress } from "../embedded-list/embedded-display.js";
|
|
12
|
+
import { useFilterChips } from "../searches/use-filter-chips.js";
|
|
13
|
+
import { RecordRow } from "./record-row.js";
|
|
14
|
+
import { useMemo } from "react";
|
|
15
|
+
import { ArrowRight } from "lucide-react";
|
|
16
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
17
|
+
import { createWhere, parseFilter, printFilter } from "@protobase/schema";
|
|
18
|
+
//#region src/app/record-row/record-row-body.tsx
|
|
19
|
+
const where = createWhere();
|
|
20
|
+
const scoped = (filter, scope) => {
|
|
21
|
+
if (!scope) return filter;
|
|
22
|
+
const parsed = parseFilter(filter);
|
|
23
|
+
if (!parsed.ok) throw new Error(`The filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
|
|
24
|
+
return printFilter(parsed.ast ? where.and(parsed.ast, scope) : scope);
|
|
25
|
+
};
|
|
26
|
+
/** The display a row draws its cards with: the one named, else the view's first cards display, else a card of the title. */
|
|
27
|
+
const rowDisplay = (view, name) => {
|
|
28
|
+
const cards = displaysOf(view).filter((display) => display.kind === "cards");
|
|
29
|
+
return cards.find((display) => display.name === name) ?? cards[0] ?? {
|
|
30
|
+
kind: "cards",
|
|
31
|
+
name: "cards"
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
35
|
+
className: "flex gap-3 overflow-hidden",
|
|
36
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-64 shrink-0" }, index))
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* What a filter finds, in a card's body: its filters as chips, how many records match, the records in a row of their
|
|
40
|
+
* list's cards that scrolls sideways (`RecordRow`), and "View all", which opens the list with the filter.
|
|
41
|
+
*/
|
|
42
|
+
const RecordRowBody = ({ model, view, filter, sort = "", display: named, limit, chips: showChips = true, empty, note, isNew, viewAll, scope, list }) => {
|
|
43
|
+
const { resources, views } = useAdminMeta();
|
|
44
|
+
const xs = useIsXs();
|
|
45
|
+
const display = rowDisplay(view, named);
|
|
46
|
+
const orderBy = sort || defaultOrderBy(model, view);
|
|
47
|
+
const columns = useMemo(() => loadedFields(model, view, []), [model, view]);
|
|
48
|
+
const loadFilter = useMemo(() => scoped(filter, scope), [filter, scope]);
|
|
49
|
+
const rows = useLiveRows({
|
|
50
|
+
resources,
|
|
51
|
+
views,
|
|
52
|
+
model,
|
|
53
|
+
columns,
|
|
54
|
+
filter: loadFilter,
|
|
55
|
+
orderBy
|
|
56
|
+
});
|
|
57
|
+
const chips = useFilterChips(model, filter);
|
|
58
|
+
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
59
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
60
|
+
const total = rows.source?.total;
|
|
61
|
+
const source = useMemo(() => rows.source && {
|
|
62
|
+
...rows.source,
|
|
63
|
+
total: Math.min(rows.source.total, limit ?? Infinity)
|
|
64
|
+
}, [rows.source, limit]);
|
|
65
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
66
|
+
className: "flex min-w-0 flex-col gap-3",
|
|
67
|
+
children: [
|
|
68
|
+
showChips && chips.length > 0 || total !== void 0 || note ? /* @__PURE__ */ jsxs("div", {
|
|
69
|
+
className: "flex flex-wrap items-center justify-between gap-2",
|
|
70
|
+
children: [showChips && chips.length > 0 ? /* @__PURE__ */ jsx(FilterChipList, { chips }) : /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsxs("p", {
|
|
71
|
+
className: "flex flex-wrap gap-x-3 text-[13px] text-muted-foreground",
|
|
72
|
+
children: [note && /* @__PURE__ */ jsx("span", { children: note }), total !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
73
|
+
className: "tabular-nums",
|
|
74
|
+
children: `${total.toLocaleString("en-GB")} ${total === 1 ? singular : plural.toLowerCase()}`
|
|
75
|
+
})]
|
|
76
|
+
})]
|
|
77
|
+
}) : null,
|
|
78
|
+
rows.isLoading ? /* @__PURE__ */ jsx(Loading, {}) : rows.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
79
|
+
title: "Could not load",
|
|
80
|
+
error: rows.error
|
|
81
|
+
}) : !source || source.total === 0 ? /* @__PURE__ */ jsx("p", {
|
|
82
|
+
className: "text-[13px] text-muted-foreground",
|
|
83
|
+
children: empty ?? `No ${plural.toLowerCase()} match.`
|
|
84
|
+
}) : /* @__PURE__ */ jsx(RecordRow, {
|
|
85
|
+
model,
|
|
86
|
+
view,
|
|
87
|
+
display,
|
|
88
|
+
source,
|
|
89
|
+
label: plural,
|
|
90
|
+
isNew
|
|
91
|
+
}),
|
|
92
|
+
/* @__PURE__ */ jsxs(AppLink, {
|
|
93
|
+
to: viewAllAddress(model, view, xs, {
|
|
94
|
+
filter,
|
|
95
|
+
orderBy,
|
|
96
|
+
display: display.name,
|
|
97
|
+
savedSearch: viewAll?.savedSearch,
|
|
98
|
+
resource: list
|
|
99
|
+
}),
|
|
100
|
+
onClick: viewAll?.onOpen,
|
|
101
|
+
className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
|
|
102
|
+
children: [viewAll?.label ?? /* @__PURE__ */ jsxs(Fragment$1, { children: ["View all ", /* @__PURE__ */ jsx("span", {
|
|
103
|
+
className: "sr-only",
|
|
104
|
+
children: plural.toLowerCase()
|
|
105
|
+
})] }), /* @__PURE__ */ jsx(ArrowRight, {
|
|
106
|
+
className: "size-3.5",
|
|
107
|
+
"aria-hidden": true
|
|
108
|
+
})]
|
|
109
|
+
})
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
export { RecordRowBody };
|
|
@@ -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 };
|
|
@@ -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 };
|