@protobase/ui 0.12.21 → 0.13.0
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/displays/card-layout.js +19 -0
- package/dist/app/displays/cards-display.js +17 -143
- package/dist/app/displays/display-controls.js +3 -2
- package/dist/app/displays/list-cards.js +50 -0
- package/dist/app/displays/list-displays.d.ts +1 -1
- package/dist/app/displays/list-displays.js +40 -6
- package/dist/app/displays/map-display.js +120 -0
- package/dist/app/displays/map-groups.js +55 -0
- package/dist/app/displays/record-card.js +171 -0
- package/dist/app/displays/virtual-cards.js +138 -0
- package/dist/app/embedded-list/embedded-columns.js +52 -0
- package/dist/app/embedded-list/embedded-display.js +26 -0
- package/dist/app/embedded-list/embedded-list.js +155 -0
- package/dist/app/embedded-list/use-embedded-display.js +23 -0
- package/dist/app/embedded-list/use-embedded-rows.js +33 -0
- package/dist/app/field-editor.js +1 -1
- package/dist/app/list-page.js +32 -11
- package/dist/app/list-scroll.js +25 -0
- package/dist/app/map/record-map.js +2 -2
- package/dist/app/map/records-map.js +106 -0
- package/dist/app/named-icon.js +3 -2
- package/dist/app/pages/blocks/table.js +21 -41
- package/dist/app/pages/field-value.js +2 -2
- package/dist/app/record-page.js +3 -2
- package/dist/app/related-section.js +30 -52
- package/dist/app/router.js +1 -0
- package/dist/data-grid/data-grid.js +19 -39
- package/dist/data-grid/geometry.js +7 -9
- package/dist/data-grid/use-grid-scroll.js +8 -1
- package/dist/data-grid/use-virtual-rows.js +31 -0
- package/dist/live/columns-from-model.js +6 -4
- package/dist/live/grid-loader.js +26 -10
- package/dist/package.js +1 -1
- package/dist/record-view/section.js +24 -17
- package/dist/shell/assets/app-DLjaMVJD.js +66 -0
- package/dist/shell/assets/index-BYPphaEm.css +2 -0
- package/dist/shell/assets/{index-BNrginzc.js → index-D7IcSr6n.js} +3 -3
- package/dist/shell/assets/maplibre-gl-Bt-3iOM-.js +1 -0
- package/dist/shell/assets/record-map-DL4b9dZO.js +814 -0
- package/dist/shell/assets/records-map-D0EOO5kZ.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +1 -0
- package/dist/shell/index.html +5 -5
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -5
- package/dist/app/pages/record-table.js +0 -114
- package/dist/app/pages/use-relation-labels.js +0 -30
- package/dist/shell/assets/app-CdxyCTXE.js +0 -66
- package/dist/shell/assets/index-UCjnDdFX.css +0 -2
- package/dist/shell/assets/maplibre-gl-B1f3q0xK.js +0 -1
- package/dist/shell/assets/record-map-HYe7IrpM.js +0 -814
- package/dist/shell/assets/shared-protobase-ui-DkwITozF.js +0 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** How many cards fit across `width`, as a grid of `repeat(auto-fill, minmax(15rem, 1fr))` would place them. */
|
|
2
|
+
const cardColumns = (width) => Math.max(1, Math.floor((width + 12) / 252));
|
|
3
|
+
const cardWidth = (width, columns) => Math.max(0, (width - (columns - 1) * 12) / columns);
|
|
4
|
+
/** The records the card rows `first` to `last` hold, as a first and last record index. */
|
|
5
|
+
const recordRange = (first, last, columns, total) => ({
|
|
6
|
+
start: first * columns,
|
|
7
|
+
end: Math.max(0, Math.min(total, (last + 1) * columns) - 1)
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* The scroll offset after a layout change that keeps the same records in view: the same place in the same row when
|
|
11
|
+
* only the row height changed, else the row now holding the first record of the row that was at the top.
|
|
12
|
+
*/
|
|
13
|
+
const anchoredTop = (top, before, after) => {
|
|
14
|
+
const row = top / before.rowHeight;
|
|
15
|
+
if (before.columns === after.columns) return row * after.rowHeight;
|
|
16
|
+
return Math.floor(Math.floor(row) * before.columns / after.columns) * after.rowHeight;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { anchoredTop, cardColumns, cardWidth, recordRange };
|
|
@@ -1,84 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
3
|
import { humanize } from "../../live/naming.js";
|
|
4
|
-
import {
|
|
5
|
-
import { cardFields, filledMeta } from "./list-displays.js";
|
|
6
|
-
import { AppLink } from "../use-app-address.js";
|
|
7
|
-
import { fileShown, isPreviewable } from "../../live/file-values.js";
|
|
8
|
-
import { displayValue } from "../../live/display-value.js";
|
|
9
|
-
import { shownWidget } from "../field-types/shown-widget.js";
|
|
10
|
-
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
11
|
-
import { WidgetPart } from "../field-types/widget-part.js";
|
|
12
|
-
import { namedIcon } from "../named-icon.js";
|
|
13
|
-
import { ValueBadge } from "../value-badge.js";
|
|
14
|
-
import { CardCarousel } from "./card-carousel.js";
|
|
4
|
+
import { RecordCard } from "./record-card.js";
|
|
15
5
|
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
16
|
-
import {
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
17
7
|
//#region src/app/displays/cards-display.tsx
|
|
18
8
|
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return /* @__PURE__ */ jsx("span", {
|
|
23
|
-
className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
|
|
24
|
-
children: url && /* @__PURE__ */ jsx("img", {
|
|
25
|
-
src: url,
|
|
26
|
-
alt: "",
|
|
27
|
-
loading: "lazy",
|
|
28
|
-
className: "size-full object-cover"
|
|
29
|
-
})
|
|
30
|
-
});
|
|
31
|
-
};
|
|
32
|
-
const MetaValue = ({ icon, children }) => {
|
|
33
|
-
const Icon = namedIcon(icon);
|
|
34
|
-
return /* @__PURE__ */ jsxs("dd", {
|
|
35
|
-
className: "flex items-center gap-1 tabular-nums",
|
|
36
|
-
children: [Icon && /* @__PURE__ */ jsx(Icon, {
|
|
37
|
-
className: "size-3.5 shrink-0",
|
|
38
|
-
"aria-hidden": true
|
|
39
|
-
}), children]
|
|
40
|
-
});
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* A card per record, opening its page: its image across the top, with arrows through its photos when the display names
|
|
44
|
-
* them, the title over the subtitle, the badge beside it, small meta fields below, those with a value, and the labels of
|
|
45
|
-
* its flags that are true; or, with a `card` component, what it draws of the row, the built-in content again when it is
|
|
46
|
-
* missing or throws.
|
|
47
|
-
*/
|
|
48
|
-
const CardsDisplay = ({ model, view, display, list }) => {
|
|
49
|
-
const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
|
|
50
|
-
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
51
|
-
const text = (row, name) => {
|
|
52
|
-
const field = model.fields[name];
|
|
53
|
-
if (!field) return "";
|
|
54
|
-
return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
|
|
55
|
-
};
|
|
56
|
-
const { fields: fieldTypes, components } = useProjectUi();
|
|
57
|
-
const Card = display.card === void 0 ? void 0 : components?.[display.card];
|
|
58
|
-
const shownValue = (row, name) => {
|
|
59
|
-
const field = model.fields[name];
|
|
60
|
-
const shown = field && shownWidget(fieldTypes, field, view?.fields[name]);
|
|
61
|
-
return shown ? /* @__PURE__ */ jsx(WidgetPart, {
|
|
62
|
-
shown,
|
|
63
|
-
part: "display",
|
|
64
|
-
field,
|
|
65
|
-
value: row.record[name],
|
|
66
|
-
fallback: text(row, name)
|
|
67
|
-
}) : text(row, name);
|
|
68
|
-
};
|
|
69
|
-
const metaValue = (row, name) => {
|
|
70
|
-
const field = model.fields[name];
|
|
71
|
-
const hints = view?.fields[name];
|
|
72
|
-
const value = row.record[name];
|
|
73
|
-
if (!field || hints?.format !== "badge" || shownWidget(fieldTypes, field, hints) || value === null || value === void 0 || value === "") return shownValue(row, name);
|
|
74
|
-
return /* @__PURE__ */ jsx(ValueBadge, {
|
|
75
|
-
value,
|
|
76
|
-
hints,
|
|
77
|
-
children: text(row, name)
|
|
78
|
-
});
|
|
79
|
-
};
|
|
9
|
+
const scroller = "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1";
|
|
10
|
+
/** A card per record (see `RecordCard`), in a grid or a row, with how many match and a button for more below. */
|
|
11
|
+
const CardsDisplay = ({ model, view, display, list, row = false, footer = true }) => {
|
|
80
12
|
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
81
|
-
const photos = Card ? void 0 : display.photos;
|
|
82
13
|
return /* @__PURE__ */ jsxs(DisplayState, {
|
|
83
14
|
list,
|
|
84
15
|
empty: `No ${plural} match.`,
|
|
@@ -87,75 +18,18 @@ const CardsDisplay = ({ model, view, display, list }) => {
|
|
|
87
18
|
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
88
19
|
}),
|
|
89
20
|
children: [/* @__PURE__ */ jsx("ul", {
|
|
90
|
-
className: grid,
|
|
21
|
+
className: row ? scroller : grid,
|
|
91
22
|
"aria-label": view?.names?.plural ?? humanize(model.name),
|
|
92
|
-
children: list.rows.map((
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
children: shownValue(row, title)
|
|
103
|
-
}), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(ValueBadge, {
|
|
104
|
-
value: status,
|
|
105
|
-
hints: view?.fields[badge],
|
|
106
|
-
children: text(row, badge)
|
|
107
|
-
})]
|
|
108
|
-
}),
|
|
109
|
-
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
110
|
-
className: "truncate text-muted-foreground",
|
|
111
|
-
children: shownValue(row, subtitle)
|
|
112
|
-
}),
|
|
113
|
-
meta.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
114
|
-
className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
|
|
115
|
-
children: filledMeta(row, meta).map((name) => /* @__PURE__ */ jsxs("div", {
|
|
116
|
-
className: "flex gap-1",
|
|
117
|
-
children: [/* @__PURE__ */ jsx("dt", {
|
|
118
|
-
className: "sr-only",
|
|
119
|
-
children: label(name)
|
|
120
|
-
}), /* @__PURE__ */ jsx(MetaValue, {
|
|
121
|
-
icon: view?.fields[name]?.icon,
|
|
122
|
-
children: metaValue(row, name)
|
|
123
|
-
})]
|
|
124
|
-
}, name))
|
|
125
|
-
}),
|
|
126
|
-
raised.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
127
|
-
className: "flex flex-wrap gap-1",
|
|
128
|
-
"aria-label": "Features",
|
|
129
|
-
children: raised.map((name) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Badge, {
|
|
130
|
-
dot: false,
|
|
131
|
-
children: label(name)
|
|
132
|
-
}) }, name))
|
|
133
|
-
})
|
|
134
|
-
] });
|
|
135
|
-
const card = (photo) => /* @__PURE__ */ jsx(AppLink, {
|
|
136
|
-
to: {
|
|
137
|
-
kind: "record",
|
|
138
|
-
resource: model.name,
|
|
139
|
-
key: row.key
|
|
140
|
-
},
|
|
141
|
-
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",
|
|
142
|
-
children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
|
|
143
|
-
name: display.card,
|
|
144
|
-
fallback: builtIn(photo),
|
|
145
|
-
children: /* @__PURE__ */ jsx(Card, { row })
|
|
146
|
-
}) : builtIn(photo)
|
|
147
|
-
});
|
|
148
|
-
return /* @__PURE__ */ jsx("li", {
|
|
149
|
-
className: photos ? "group/card relative" : void 0,
|
|
150
|
-
children: photos ? /* @__PURE__ */ jsx(CardCarousel, {
|
|
151
|
-
photos,
|
|
152
|
-
recordKey: row.key,
|
|
153
|
-
cover,
|
|
154
|
-
children: card
|
|
155
|
-
}) : card(cover)
|
|
156
|
-
}, row.key);
|
|
157
|
-
})
|
|
158
|
-
}), /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
23
|
+
children: list.rows.map((record) => /* @__PURE__ */ jsx("li", {
|
|
24
|
+
className: cn(row && "w-64 shrink-0 snap-start"),
|
|
25
|
+
children: /* @__PURE__ */ jsx(RecordCard, {
|
|
26
|
+
model,
|
|
27
|
+
view,
|
|
28
|
+
display,
|
|
29
|
+
row: record
|
|
30
|
+
})
|
|
31
|
+
}, record.key))
|
|
32
|
+
}), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
159
33
|
});
|
|
160
34
|
};
|
|
161
35
|
//#endregion
|
|
@@ -4,7 +4,7 @@ import { displayChoices, sortFields } from "./list-displays.js";
|
|
|
4
4
|
import { parseSort } from "../list-order.js";
|
|
5
5
|
import { MenuSelect } from "../../primitives/menu-select.js";
|
|
6
6
|
import { Segmented } from "../../primitives/segmented.js";
|
|
7
|
-
import { Calendar, Columns3, LayoutGrid, List, SquareKanban, Table2 } from "lucide-react";
|
|
7
|
+
import { Calendar, Columns3, LayoutGrid, List, Map, SquareKanban, Table2 } from "lucide-react";
|
|
8
8
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
9
|
//#region src/app/displays/display-controls.tsx
|
|
10
10
|
/** The icons a display's `icon` may name; another name shows the label alone. */
|
|
@@ -14,7 +14,8 @@ const icons = {
|
|
|
14
14
|
kanban: SquareKanban,
|
|
15
15
|
board: Columns3,
|
|
16
16
|
list: List,
|
|
17
|
-
calendar: Calendar
|
|
17
|
+
calendar: Calendar,
|
|
18
|
+
map: Map
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
21
|
* The list page's switch between the table and the view's displays, as buttons or, on a phone, as a menu; nothing for a
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { displayRow } from "./list-displays.js";
|
|
4
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
5
|
+
import { RecordCard } from "./record-card.js";
|
|
6
|
+
import { VirtualCards } from "./virtual-cards.js";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/displays/list-cards.tsx
|
|
9
|
+
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
10
|
+
const pictureHeight = (width) => width * .75;
|
|
11
|
+
const plainHeight = 44;
|
|
12
|
+
/**
|
|
13
|
+
* The list page's cards display: a card per record (see `RecordCard`), scrolled like the table, with which of the
|
|
14
|
+
* matching records are in view below. An embedded list, which shows a few cards whole, draws them with `CardsDisplay`.
|
|
15
|
+
*/
|
|
16
|
+
const ListCards = ({ model, view, display, list }) => {
|
|
17
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
18
|
+
const pictured = display.card === void 0 && (display.image !== void 0 || display.photos !== void 0);
|
|
19
|
+
if (list.isLoading) return /* @__PURE__ */ jsx("div", {
|
|
20
|
+
className: `min-h-0 flex-1 overflow-hidden ${grid}`,
|
|
21
|
+
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
22
|
+
});
|
|
23
|
+
if (list.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
24
|
+
title: "Could not load rows",
|
|
25
|
+
error: list.error
|
|
26
|
+
});
|
|
27
|
+
if (!list.source || list.source.total === 0) return /* @__PURE__ */ jsxs("p", {
|
|
28
|
+
className: "py-8 text-center text-[13px] text-muted-foreground",
|
|
29
|
+
children: [
|
|
30
|
+
"No ",
|
|
31
|
+
plural.toLowerCase(),
|
|
32
|
+
" match."
|
|
33
|
+
]
|
|
34
|
+
});
|
|
35
|
+
return /* @__PURE__ */ jsx(VirtualCards, {
|
|
36
|
+
source: list.source,
|
|
37
|
+
label: plural,
|
|
38
|
+
initialIndex: list.initialIndex,
|
|
39
|
+
onScrollIndex: list.onScrollIndex,
|
|
40
|
+
minHeight: (width) => (pictured ? pictureHeight(width) : 0) + plainHeight,
|
|
41
|
+
children: (row) => /* @__PURE__ */ jsx(RecordCard, {
|
|
42
|
+
model,
|
|
43
|
+
view,
|
|
44
|
+
display,
|
|
45
|
+
row: displayRow(model, row)
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
export { ListCards };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ResourceModel, ViewModel } from "@protobase/schema";
|
|
1
|
+
import { FieldModel, ResourceModel, ViewModel } from "@protobase/schema";
|
|
2
2
|
//#region src/app/displays/list-displays.d.ts
|
|
3
3
|
/** A row as a list display gets it: its key, the record as the list API gives it, and names of the records its relations point at. */
|
|
4
4
|
export type ListDisplayRow = {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { displayValue } from "../../live/display-value.js";
|
|
3
4
|
import { encodeKey, tableDisplay } from "@protobase/schema";
|
|
4
5
|
//#region src/app/displays/list-displays.ts
|
|
5
6
|
const displaysOf = (view) => view?.list?.displays ?? [];
|
|
6
7
|
/**
|
|
7
8
|
* The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
|
|
8
|
-
* in one: the view's default when that is a cards display, else the first. Otherwise
|
|
9
|
+
* in one: `preferred` or the view's default when that is a cards display, else the first. Otherwise `preferred`, the
|
|
10
|
+
* view's default, or the table.
|
|
9
11
|
*/
|
|
10
|
-
const defaultDisplay = (view, xs) => {
|
|
11
|
-
const fallback = view?.list?.default ?? tableDisplay;
|
|
12
|
+
const defaultDisplay = (view, xs, preferred) => {
|
|
13
|
+
const fallback = preferred ?? view?.list?.default ?? tableDisplay;
|
|
12
14
|
if (!xs) return fallback;
|
|
13
15
|
const cards = displaysOf(view).filter((display) => display.kind === "cards");
|
|
14
16
|
return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
|
|
@@ -19,6 +21,10 @@ const shownDisplay = (view, name) => {
|
|
|
19
21
|
const named = (wanted) => displaysOf(view).find((display) => display.name === wanted);
|
|
20
22
|
return named(name) ?? named(view?.list?.default);
|
|
21
23
|
};
|
|
24
|
+
const defaultIcons = {
|
|
25
|
+
cards: "cards",
|
|
26
|
+
map: "map"
|
|
27
|
+
};
|
|
22
28
|
const displayChoices = (view) => [{
|
|
23
29
|
name: tableDisplay,
|
|
24
30
|
label: "Table",
|
|
@@ -26,29 +32,57 @@ const displayChoices = (view) => [{
|
|
|
26
32
|
}, ...displaysOf(view).map((display) => ({
|
|
27
33
|
name: display.name,
|
|
28
34
|
label: display.label ?? humanize(display.name),
|
|
29
|
-
icon: display.icon ??
|
|
35
|
+
icon: display.icon ?? defaultIcons[display.kind]
|
|
30
36
|
}))];
|
|
31
37
|
/** The fields a cards display shows, its title the view's title field when it names none. */
|
|
32
38
|
const cardFields = (model, view, display) => ({
|
|
33
39
|
image: display.image,
|
|
34
40
|
title: display.title ?? recordTitleField(model, view),
|
|
35
41
|
subtitle: display.subtitle,
|
|
42
|
+
amount: display.amount ?? [],
|
|
36
43
|
badge: display.badge,
|
|
37
44
|
meta: display.meta ?? [],
|
|
45
|
+
footer: display.footer ?? [],
|
|
38
46
|
flags: display.flags ?? []
|
|
39
47
|
});
|
|
40
48
|
/** The meta fields a card shows for a row: those with a value, so a listing for rent only shows no purchase price. */
|
|
41
49
|
const filledMeta = (row, meta) => meta.filter((name) => row.record[name] !== null && row.record[name] !== void 0 && row.record[name] !== "");
|
|
50
|
+
/** The amount field a card shows for a row: the first with a value, so a listing for rent only shows its rent. */
|
|
51
|
+
const filledAmount = (row, amount) => filledMeta(row, amount)[0];
|
|
52
|
+
/** A card's amount split for drawing: the number with its prefix, such as `€1,450`, apart from its suffix, such as ` /month`. */
|
|
53
|
+
const amountParts = (field, value, hints = {}) => {
|
|
54
|
+
const { suffix, ...unsuffixed } = hints;
|
|
55
|
+
if (!(field.type === "decimal" || field.type === "integer" || field.type === "bigint") || suffix === void 0 || hints.format === "percent") return { amount: displayValue(field, value, hints) };
|
|
56
|
+
return {
|
|
57
|
+
amount: displayValue(field, value, unsuffixed),
|
|
58
|
+
suffix
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/** The cards display whose card a map shows for a record: the one it names, else the list's first, else the title alone. */
|
|
62
|
+
const mapCards = (view, display) => {
|
|
63
|
+
const cards = displaysOf(view).filter((other) => other.kind === "cards");
|
|
64
|
+
return (display.cards === void 0 ? cards[0] : cards.find((other) => other.name === display.cards)) ?? {
|
|
65
|
+
kind: "cards",
|
|
66
|
+
name: display.name
|
|
67
|
+
};
|
|
68
|
+
};
|
|
42
69
|
/** Every field a display shows, the fields its rows are loaded and labelled for. */
|
|
43
70
|
const shownFields = (model, view, display) => {
|
|
44
71
|
if (display.kind === "custom") return display.fields;
|
|
45
|
-
|
|
72
|
+
if (display.kind === "map") return [
|
|
73
|
+
display.latitude,
|
|
74
|
+
display.longitude,
|
|
75
|
+
...shownFields(model, view, mapCards(view, display))
|
|
76
|
+
];
|
|
77
|
+
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
46
78
|
return [
|
|
47
79
|
image,
|
|
48
80
|
title,
|
|
49
81
|
subtitle,
|
|
82
|
+
...amount,
|
|
50
83
|
badge,
|
|
51
84
|
...meta,
|
|
85
|
+
...footer,
|
|
52
86
|
...flags
|
|
53
87
|
].filter((name) => name !== void 0);
|
|
54
88
|
};
|
|
@@ -75,4 +109,4 @@ const displayRow = (model, row) => {
|
|
|
75
109
|
};
|
|
76
110
|
};
|
|
77
111
|
//#endregion
|
|
78
|
-
export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledMeta, loadedFields, shownDisplay, shownFields, sortFields };
|
|
112
|
+
export { amountParts, cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledAmount, filledMeta, loadedFields, mapCards, shownDisplay, shownFields, sortFields };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { mapCards } from "./list-displays.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { Problem } from "../pages/problem.js";
|
|
6
|
+
import { RecordCard } from "./record-card.js";
|
|
7
|
+
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
8
|
+
import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
|
|
9
|
+
import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
+
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
11
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/app/displays/map-display.tsx
|
|
13
|
+
const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
|
|
14
|
+
const arrow = "flex size-8 items-center justify-center rounded-full border border-border bg-background hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30";
|
|
15
|
+
/**
|
|
16
|
+
* The list's loaded records on a map, drawn with the map style `/meta` names. Choosing a marker shows the card of its
|
|
17
|
+
* record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
|
|
18
|
+
* elsewhere clears it. Below the map, how many of the matching records are on it and a button for more.
|
|
19
|
+
*/
|
|
20
|
+
const MapDisplay = ({ model, view, display, list, footer = true }) => {
|
|
21
|
+
const style = useAdminMeta().maps?.style;
|
|
22
|
+
const [chosen, setChosen] = useState();
|
|
23
|
+
const cards = mapCards(view, display);
|
|
24
|
+
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
25
|
+
const selection = keptSelection(chosen, list.rows);
|
|
26
|
+
const row = selection && list.rows.find((candidate) => candidate.key === selection.keys[selection.index]);
|
|
27
|
+
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
28
|
+
const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
|
|
29
|
+
const overlay = useRef(null);
|
|
30
|
+
const [covered, setCovered] = useState(0);
|
|
31
|
+
useLayoutEffect(() => setCovered(overlay.current?.offsetHeight ?? 0), [row?.key]);
|
|
32
|
+
if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
|
|
33
|
+
return /* @__PURE__ */ jsx(DisplayState, {
|
|
34
|
+
list,
|
|
35
|
+
empty: `No ${plural} match.`,
|
|
36
|
+
loading: /* @__PURE__ */ jsx(Skeleton, { className: "h-96" }),
|
|
37
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
38
|
+
className: "flex h-full min-h-[36rem] flex-col sm:min-h-[28rem]",
|
|
39
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
40
|
+
className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
|
|
41
|
+
children: [/* @__PURE__ */ jsx(Suspense, {
|
|
42
|
+
fallback: null,
|
|
43
|
+
children: /* @__PURE__ */ jsx(RecordsMap, {
|
|
44
|
+
style,
|
|
45
|
+
points: mapPoints(list.rows, display),
|
|
46
|
+
selected: selection?.keys,
|
|
47
|
+
covered: selection ? covered : 0,
|
|
48
|
+
onSelect: (keys) => setChosen({
|
|
49
|
+
keys,
|
|
50
|
+
index: 0
|
|
51
|
+
}),
|
|
52
|
+
onClear: () => setChosen(void 0),
|
|
53
|
+
markerLabel
|
|
54
|
+
})
|
|
55
|
+
}), selection && row && /* @__PURE__ */ jsx("div", {
|
|
56
|
+
ref: overlay,
|
|
57
|
+
className: "pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center",
|
|
58
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
59
|
+
className: "pointer-events-auto flex w-full max-w-[16rem] flex-col gap-2 sm:max-w-xs",
|
|
60
|
+
role: "group",
|
|
61
|
+
"aria-label": `Chosen ${singular}`,
|
|
62
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
63
|
+
className: "flex items-center justify-end gap-1.5",
|
|
64
|
+
children: [selection.keys.length > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
65
|
+
/* @__PURE__ */ jsx("button", {
|
|
66
|
+
type: "button",
|
|
67
|
+
"aria-label": "Previous",
|
|
68
|
+
className: arrow,
|
|
69
|
+
onClick: () => setChosen(stepSelection(selection, -1)),
|
|
70
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
71
|
+
className: "size-4",
|
|
72
|
+
"aria-hidden": true
|
|
73
|
+
})
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ jsxs("span", {
|
|
76
|
+
className: "rounded-full border border-border bg-background px-2 py-1 text-xs tabular-nums",
|
|
77
|
+
"aria-live": "polite",
|
|
78
|
+
children: [
|
|
79
|
+
selection.index + 1,
|
|
80
|
+
" / ",
|
|
81
|
+
selection.keys.length
|
|
82
|
+
]
|
|
83
|
+
}),
|
|
84
|
+
/* @__PURE__ */ jsx("button", {
|
|
85
|
+
type: "button",
|
|
86
|
+
"aria-label": "Next",
|
|
87
|
+
className: arrow,
|
|
88
|
+
onClick: () => setChosen(stepSelection(selection, 1)),
|
|
89
|
+
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
90
|
+
className: "size-4",
|
|
91
|
+
"aria-hidden": true
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
] }), /* @__PURE__ */ jsx("button", {
|
|
95
|
+
type: "button",
|
|
96
|
+
"aria-label": "Close",
|
|
97
|
+
className: arrow,
|
|
98
|
+
onClick: () => setChosen(void 0),
|
|
99
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
100
|
+
className: "size-4",
|
|
101
|
+
"aria-hidden": true
|
|
102
|
+
})
|
|
103
|
+
})]
|
|
104
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
105
|
+
className: "shadow-lg",
|
|
106
|
+
children: /* @__PURE__ */ jsx(RecordCard, {
|
|
107
|
+
model,
|
|
108
|
+
view,
|
|
109
|
+
display: cards,
|
|
110
|
+
row
|
|
111
|
+
})
|
|
112
|
+
})]
|
|
113
|
+
})
|
|
114
|
+
})]
|
|
115
|
+
}), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
116
|
+
})
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { MapDisplay };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { recordPosition } from "../map/record-position.js";
|
|
2
|
+
//#region src/app/displays/map-groups.ts
|
|
3
|
+
/** The rows with a position, in their order; a row without one is not on the map. */
|
|
4
|
+
const mapPoints = (rows, fields) => rows.flatMap((row) => {
|
|
5
|
+
const position = recordPosition(row.record, fields);
|
|
6
|
+
return position ? [{
|
|
7
|
+
key: row.key,
|
|
8
|
+
...position
|
|
9
|
+
}] : [];
|
|
10
|
+
});
|
|
11
|
+
/** The south-west and north-east corners around the points, as `[longitude, latitude]`, or none without points. */
|
|
12
|
+
const pointBounds = (points) => {
|
|
13
|
+
if (points.length === 0) return void 0;
|
|
14
|
+
const longitudes = points.map((point) => point.longitude);
|
|
15
|
+
const latitudes = points.map((point) => point.latitude);
|
|
16
|
+
return [[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
|
|
20
|
+
* of it, else starts its own. A marker is at its first point, so markers do not move as others join them.
|
|
21
|
+
*/
|
|
22
|
+
const groupPoints = (points, radius) => {
|
|
23
|
+
const groups = [];
|
|
24
|
+
for (const point of points) {
|
|
25
|
+
const near = groups.find((group) => Math.hypot(group.at.x - point.x, group.at.y - point.y) <= radius);
|
|
26
|
+
if (near) near.keys.push(point.key);
|
|
27
|
+
else groups.push({
|
|
28
|
+
at: point,
|
|
29
|
+
keys: [point.key]
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return groups.map((group) => ({
|
|
33
|
+
keys: group.keys,
|
|
34
|
+
at: group.at.key
|
|
35
|
+
}));
|
|
36
|
+
};
|
|
37
|
+
/** The selection kept to the records the list still has, its card the same record when it is still there. */
|
|
38
|
+
const keptSelection = (selection, rows) => {
|
|
39
|
+
if (!selection) return void 0;
|
|
40
|
+
const present = new Set(rows.map((row) => row.key));
|
|
41
|
+
const keys = selection.keys.filter((key) => present.has(key));
|
|
42
|
+
if (keys.length === 0) return void 0;
|
|
43
|
+
const index = keys.indexOf(selection.keys[selection.index]);
|
|
44
|
+
return {
|
|
45
|
+
keys,
|
|
46
|
+
index: Math.max(index, 0)
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/** The selection moved by `step` records, round from the last to the first. */
|
|
50
|
+
const stepSelection = (selection, step) => ({
|
|
51
|
+
keys: selection.keys,
|
|
52
|
+
index: ((selection.index + step) % selection.keys.length + selection.keys.length) % selection.keys.length
|
|
53
|
+
});
|
|
54
|
+
//#endregion
|
|
55
|
+
export { groupPoints, keptSelection, mapPoints, pointBounds, stepSelection };
|