@protobase/ui 0.12.21 → 0.12.22
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/cards-display.js +9 -140
- package/dist/app/displays/display-controls.js +3 -2
- package/dist/app/displays/list-displays.d.ts +1 -1
- package/dist/app/displays/list-displays.js +36 -3
- 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/field-editor.js +1 -1
- package/dist/app/list-page.js +21 -9
- 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/field-value.js +2 -2
- package/dist/app/record-page.js +1 -1
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BYw-8qpE.js +66 -0
- package/dist/shell/assets/{index-BNrginzc.js → index-B8MhIaLr.js} +1 -1
- package/dist/shell/assets/index-CdSIk0BQ.css +2 -0
- package/dist/shell/assets/navigation-control-CGS0pamh.js +814 -0
- package/dist/shell/assets/record-map-9Y3IL_dg.js +1 -0
- package/dist/shell/assets/records-map-DXIWowJ3.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-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
|
@@ -1,84 +1,13 @@
|
|
|
1
|
-
import { Badge } from "../../primitives/badge.js";
|
|
2
1
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
2
|
import { humanize } from "../../live/naming.js";
|
|
4
|
-
import { useProjectUi } from "../pages/project-ui.js";
|
|
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";
|
|
15
3
|
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
16
|
-
import {
|
|
4
|
+
import { RecordCard } from "./record-card.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
17
6
|
//#region src/app/displays/cards-display.tsx
|
|
18
7
|
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
19
|
-
|
|
20
|
-
const file = fileShown(value);
|
|
21
|
-
const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
|
|
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
|
-
*/
|
|
8
|
+
/** A card per record (see `RecordCard`), in a grid, with how many match and a button for more below. */
|
|
48
9
|
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
|
-
};
|
|
80
10
|
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
81
|
-
const photos = Card ? void 0 : display.photos;
|
|
82
11
|
return /* @__PURE__ */ jsxs(DisplayState, {
|
|
83
12
|
list,
|
|
84
13
|
empty: `No ${plural} match.`,
|
|
@@ -89,72 +18,12 @@ const CardsDisplay = ({ model, view, display, list }) => {
|
|
|
89
18
|
children: [/* @__PURE__ */ jsx("ul", {
|
|
90
19
|
className: grid,
|
|
91
20
|
"aria-label": view?.names?.plural ?? humanize(model.name),
|
|
92
|
-
children: list.rows.map((row) => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/* @__PURE__ */ jsxs("span", {
|
|
99
|
-
className: "flex items-start justify-between gap-2",
|
|
100
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
101
|
-
className: "min-w-0 truncate font-medium",
|
|
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
|
-
})
|
|
21
|
+
children: list.rows.map((row) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(RecordCard, {
|
|
22
|
+
model,
|
|
23
|
+
view,
|
|
24
|
+
display,
|
|
25
|
+
row
|
|
26
|
+
}) }, row.key))
|
|
158
27
|
}), /* @__PURE__ */ jsx(DisplayFooter, { list })]
|
|
159
28
|
});
|
|
160
29
|
};
|
|
@@ -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
|
|
@@ -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,5 +1,6 @@
|
|
|
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 ?? [];
|
|
@@ -19,6 +20,10 @@ const shownDisplay = (view, name) => {
|
|
|
19
20
|
const named = (wanted) => displaysOf(view).find((display) => display.name === wanted);
|
|
20
21
|
return named(name) ?? named(view?.list?.default);
|
|
21
22
|
};
|
|
23
|
+
const defaultIcons = {
|
|
24
|
+
cards: "cards",
|
|
25
|
+
map: "map"
|
|
26
|
+
};
|
|
22
27
|
const displayChoices = (view) => [{
|
|
23
28
|
name: tableDisplay,
|
|
24
29
|
label: "Table",
|
|
@@ -26,29 +31,57 @@ const displayChoices = (view) => [{
|
|
|
26
31
|
}, ...displaysOf(view).map((display) => ({
|
|
27
32
|
name: display.name,
|
|
28
33
|
label: display.label ?? humanize(display.name),
|
|
29
|
-
icon: display.icon ??
|
|
34
|
+
icon: display.icon ?? defaultIcons[display.kind]
|
|
30
35
|
}))];
|
|
31
36
|
/** The fields a cards display shows, its title the view's title field when it names none. */
|
|
32
37
|
const cardFields = (model, view, display) => ({
|
|
33
38
|
image: display.image,
|
|
34
39
|
title: display.title ?? recordTitleField(model, view),
|
|
35
40
|
subtitle: display.subtitle,
|
|
41
|
+
amount: display.amount ?? [],
|
|
36
42
|
badge: display.badge,
|
|
37
43
|
meta: display.meta ?? [],
|
|
44
|
+
footer: display.footer ?? [],
|
|
38
45
|
flags: display.flags ?? []
|
|
39
46
|
});
|
|
40
47
|
/** The meta fields a card shows for a row: those with a value, so a listing for rent only shows no purchase price. */
|
|
41
48
|
const filledMeta = (row, meta) => meta.filter((name) => row.record[name] !== null && row.record[name] !== void 0 && row.record[name] !== "");
|
|
49
|
+
/** The amount field a card shows for a row: the first with a value, so a listing for rent only shows its rent. */
|
|
50
|
+
const filledAmount = (row, amount) => filledMeta(row, amount)[0];
|
|
51
|
+
/** A card's amount split for drawing: the number with its prefix, such as `€1,450`, apart from its suffix, such as ` /month`. */
|
|
52
|
+
const amountParts = (field, value, hints = {}) => {
|
|
53
|
+
const { suffix, ...unsuffixed } = hints;
|
|
54
|
+
if (!(field.type === "decimal" || field.type === "integer" || field.type === "bigint") || suffix === void 0 || hints.format === "percent") return { amount: displayValue(field, value, hints) };
|
|
55
|
+
return {
|
|
56
|
+
amount: displayValue(field, value, unsuffixed),
|
|
57
|
+
suffix
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/** The cards display whose card a map shows for a record: the one it names, else the list's first, else the title alone. */
|
|
61
|
+
const mapCards = (view, display) => {
|
|
62
|
+
const cards = displaysOf(view).filter((other) => other.kind === "cards");
|
|
63
|
+
return (display.cards === void 0 ? cards[0] : cards.find((other) => other.name === display.cards)) ?? {
|
|
64
|
+
kind: "cards",
|
|
65
|
+
name: display.name
|
|
66
|
+
};
|
|
67
|
+
};
|
|
42
68
|
/** Every field a display shows, the fields its rows are loaded and labelled for. */
|
|
43
69
|
const shownFields = (model, view, display) => {
|
|
44
70
|
if (display.kind === "custom") return display.fields;
|
|
45
|
-
|
|
71
|
+
if (display.kind === "map") return [
|
|
72
|
+
display.latitude,
|
|
73
|
+
display.longitude,
|
|
74
|
+
...shownFields(model, view, mapCards(view, display))
|
|
75
|
+
];
|
|
76
|
+
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
46
77
|
return [
|
|
47
78
|
image,
|
|
48
79
|
title,
|
|
49
80
|
subtitle,
|
|
81
|
+
...amount,
|
|
50
82
|
badge,
|
|
51
83
|
...meta,
|
|
84
|
+
...footer,
|
|
52
85
|
...flags
|
|
53
86
|
].filter((name) => name !== void 0);
|
|
54
87
|
};
|
|
@@ -75,4 +108,4 @@ const displayRow = (model, row) => {
|
|
|
75
108
|
};
|
|
76
109
|
};
|
|
77
110
|
//#endregion
|
|
78
|
-
export { cardFields, defaultDisplay, displayChoices, displayRow, displaysOf, filledMeta, loadedFields, shownDisplay, shownFields, sortFields };
|
|
111
|
+
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 { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
7
|
+
import { RecordCard } from "./record-card.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 }) => {
|
|
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
|
+
}), /* @__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 };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Badge } from "../../primitives/badge.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
|
+
import { fileShown, isPreviewable } from "../../live/file-values.js";
|
|
5
|
+
import { displayValue } from "../../live/display-value.js";
|
|
6
|
+
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
7
|
+
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { shownWidget } from "../field-types/shown-widget.js";
|
|
9
|
+
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
10
|
+
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
|
+
import { namedIcon } from "../named-icon.js";
|
|
12
|
+
import { ValueBadge } from "../value-badge.js";
|
|
13
|
+
import { CardCarousel } from "./card-carousel.js";
|
|
14
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
15
|
+
//#region src/app/displays/record-card.tsx
|
|
16
|
+
const CardImage = ({ value }) => {
|
|
17
|
+
const file = fileShown(value);
|
|
18
|
+
const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
|
|
19
|
+
return /* @__PURE__ */ jsx("span", {
|
|
20
|
+
className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
|
|
21
|
+
children: url && /* @__PURE__ */ jsx("img", {
|
|
22
|
+
src: url,
|
|
23
|
+
alt: "",
|
|
24
|
+
loading: "lazy",
|
|
25
|
+
className: "size-full object-cover"
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
const MetaValue = ({ icon, children }) => {
|
|
30
|
+
const Icon = namedIcon(icon);
|
|
31
|
+
return /* @__PURE__ */ jsxs("dd", {
|
|
32
|
+
className: "flex items-center gap-1 tabular-nums",
|
|
33
|
+
children: [Icon && /* @__PURE__ */ jsx(Icon, {
|
|
34
|
+
className: "size-3.5 shrink-0",
|
|
35
|
+
"aria-hidden": true
|
|
36
|
+
}), children]
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A record's card of a cards display, opening its page: its image across the top, with arrows through its photos when
|
|
41
|
+
* the display names them, the title over the subtitle, the badge beside it, the first amount with a value in bold, small
|
|
42
|
+
* meta fields below, those with a value, the labels of its flags that are true and the footer's fields with a value,
|
|
43
|
+
* joined by bullets; or, with a `card` component, what it draws of the row, the built-in content again when it is
|
|
44
|
+
* missing or throws.
|
|
45
|
+
*/
|
|
46
|
+
const RecordCard = ({ model, view, display, row }) => {
|
|
47
|
+
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
48
|
+
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
49
|
+
const text = (name) => {
|
|
50
|
+
const field = model.fields[name];
|
|
51
|
+
if (!field) return "";
|
|
52
|
+
return row.labels[name] ?? displayValue(field, row.record[name], view?.fields[name]);
|
|
53
|
+
};
|
|
54
|
+
const { fields: fieldTypes, components } = useProjectUi();
|
|
55
|
+
const Card = display.card === void 0 ? void 0 : components?.[display.card];
|
|
56
|
+
const shownValue = (name) => {
|
|
57
|
+
const field = model.fields[name];
|
|
58
|
+
const shown = field && shownWidget(fieldTypes, field, view?.fields[name]);
|
|
59
|
+
return shown ? /* @__PURE__ */ jsx(WidgetPart, {
|
|
60
|
+
shown,
|
|
61
|
+
part: "display",
|
|
62
|
+
field,
|
|
63
|
+
value: row.record[name],
|
|
64
|
+
fallback: text(name)
|
|
65
|
+
}) : text(name);
|
|
66
|
+
};
|
|
67
|
+
const metaValue = (name) => {
|
|
68
|
+
const field = model.fields[name];
|
|
69
|
+
const hints = view?.fields[name];
|
|
70
|
+
const value = row.record[name];
|
|
71
|
+
if (!field || hints?.format !== "badge" || shownWidget(fieldTypes, field, hints) || value === null || value === void 0 || value === "") return shownValue(name);
|
|
72
|
+
return /* @__PURE__ */ jsx(ValueBadge, {
|
|
73
|
+
value,
|
|
74
|
+
hints,
|
|
75
|
+
children: text(name)
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const amountValue = (name) => {
|
|
79
|
+
const field = model.fields[name];
|
|
80
|
+
if (!field) return void 0;
|
|
81
|
+
if (row.labels[name] !== void 0 || shownWidget(fieldTypes, field, view?.fields[name])) return /* @__PURE__ */ jsx("strong", {
|
|
82
|
+
className: "font-semibold text-foreground",
|
|
83
|
+
children: shownValue(name)
|
|
84
|
+
});
|
|
85
|
+
const parts = amountParts(field, row.record[name], view?.fields[name]);
|
|
86
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("strong", {
|
|
87
|
+
className: "font-semibold text-foreground",
|
|
88
|
+
children: parts.amount
|
|
89
|
+
}), parts.suffix] });
|
|
90
|
+
};
|
|
91
|
+
const photos = Card ? void 0 : display.photos;
|
|
92
|
+
const status = badge === void 0 ? void 0 : row.record[badge];
|
|
93
|
+
const raised = flags.filter((name) => row.record[name] === true);
|
|
94
|
+
const cover = image === void 0 ? void 0 : row.record[image];
|
|
95
|
+
const shownAmount = filledAmount(row, amount);
|
|
96
|
+
const footing = filledMeta(row, footer);
|
|
97
|
+
const builtIn = (photo) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
98
|
+
(image !== void 0 || photos !== void 0) && /* @__PURE__ */ jsx(CardImage, { value: photo }),
|
|
99
|
+
/* @__PURE__ */ jsxs("span", {
|
|
100
|
+
className: "flex items-start justify-between gap-2",
|
|
101
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
102
|
+
className: "min-w-0 truncate font-medium",
|
|
103
|
+
children: shownValue(title)
|
|
104
|
+
}), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(ValueBadge, {
|
|
105
|
+
value: status,
|
|
106
|
+
hints: view?.fields[badge],
|
|
107
|
+
children: text(badge)
|
|
108
|
+
})]
|
|
109
|
+
}),
|
|
110
|
+
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
111
|
+
className: "truncate text-muted-foreground",
|
|
112
|
+
children: shownValue(subtitle)
|
|
113
|
+
}),
|
|
114
|
+
shownAmount !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
115
|
+
className: "truncate tabular-nums text-muted-foreground",
|
|
116
|
+
children: amountValue(shownAmount)
|
|
117
|
+
}),
|
|
118
|
+
meta.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
119
|
+
className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
|
|
120
|
+
children: filledMeta(row, meta).map((name) => /* @__PURE__ */ jsxs("div", {
|
|
121
|
+
className: "flex gap-1",
|
|
122
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
123
|
+
className: "sr-only",
|
|
124
|
+
children: label(name)
|
|
125
|
+
}), /* @__PURE__ */ jsx(MetaValue, {
|
|
126
|
+
icon: view?.fields[name]?.icon,
|
|
127
|
+
children: metaValue(name)
|
|
128
|
+
})]
|
|
129
|
+
}, name))
|
|
130
|
+
}),
|
|
131
|
+
raised.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
132
|
+
className: "flex flex-wrap gap-1",
|
|
133
|
+
"aria-label": "Features",
|
|
134
|
+
children: raised.map((name) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Badge, {
|
|
135
|
+
dot: false,
|
|
136
|
+
children: label(name)
|
|
137
|
+
}) }, name))
|
|
138
|
+
}),
|
|
139
|
+
footing.length > 0 && /* @__PURE__ */ jsx("span", {
|
|
140
|
+
className: `truncate text-xs text-muted-foreground ${meta.length > 0 ? "" : "mt-auto"}`,
|
|
141
|
+
children: footing.map((name, index) => /* @__PURE__ */ jsxs("span", { children: [index > 0 && /* @__PURE__ */ jsx("span", {
|
|
142
|
+
"aria-hidden": true,
|
|
143
|
+
children: " • "
|
|
144
|
+
}), shownValue(name)] }, name))
|
|
145
|
+
})
|
|
146
|
+
] });
|
|
147
|
+
const card = (photo) => /* @__PURE__ */ jsx(AppLink, {
|
|
148
|
+
to: {
|
|
149
|
+
kind: "record",
|
|
150
|
+
resource: model.name,
|
|
151
|
+
key: row.key
|
|
152
|
+
},
|
|
153
|
+
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",
|
|
154
|
+
children: Card ? /* @__PURE__ */ jsx(CustomBoundary, {
|
|
155
|
+
name: display.card,
|
|
156
|
+
fallback: builtIn(photo),
|
|
157
|
+
children: /* @__PURE__ */ jsx(Card, { row })
|
|
158
|
+
}) : builtIn(photo)
|
|
159
|
+
});
|
|
160
|
+
return /* @__PURE__ */ jsx("div", {
|
|
161
|
+
className: photos ? "group/card relative h-full" : "h-full",
|
|
162
|
+
children: photos ? /* @__PURE__ */ jsx(CardCarousel, {
|
|
163
|
+
photos,
|
|
164
|
+
recordKey: row.key,
|
|
165
|
+
cover,
|
|
166
|
+
children: card
|
|
167
|
+
}) : card(cover)
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
//#endregion
|
|
171
|
+
export { RecordCard };
|
package/dist/app/field-editor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Input } from "../primitives/input.js";
|
|
3
3
|
import { Switch } from "../primitives/switch.js";
|
|
4
|
+
import { displayValue } from "../live/display-value.js";
|
|
4
5
|
import { AppLink } from "./use-app-address.js";
|
|
5
6
|
import { RelationPicker } from "./relation-picker.js";
|
|
6
|
-
import { displayValue } from "../live/display-value.js";
|
|
7
7
|
import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
|
|
8
8
|
import { SensitiveField } from "./sensitive-field.js";
|
|
9
9
|
import { FileField } from "./file-field.js";
|