@protobase/ui 0.12.22 → 0.13.3
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/create/create-page.js +1 -0
- package/dist/app/current-address.d.ts +3 -2
- package/dist/app/current-address.js +2 -1
- package/dist/app/delete/use-delete-flow.js +2 -1
- package/dist/app/displays/card-carousel.js +4 -1
- package/dist/app/displays/card-layout.js +19 -0
- package/dist/app/displays/card-photos.js +6 -3
- package/dist/app/displays/cards-display.js +16 -11
- package/dist/app/displays/list-cards.js +50 -0
- package/dist/app/displays/list-displays.js +4 -3
- package/dist/app/displays/map-display.js +83 -23
- package/dist/app/displays/map-groups.js +1 -8
- package/dist/app/displays/record-card.js +8 -6
- package/dist/app/displays/use-map-records.js +44 -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 +157 -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/file-field.js +16 -13
- package/dist/app/list-page.js +19 -6
- package/dist/app/list-scroll.js +25 -0
- package/dist/app/map/map-area.js +53 -0
- package/dist/app/map/records-map.js +91 -38
- package/dist/app/pages/blocks/table.js +21 -41
- package/dist/app/pages/field-value.js +22 -9
- package/dist/app/record-page.js +2 -1
- package/dist/app/related-section.js +30 -52
- package/dist/app/router.js +1 -0
- package/dist/app/use-record-editor.js +2 -1
- package/dist/data/api-provider.d.ts +2 -0
- package/dist/data/query-keys.js +9 -0
- package/dist/data/use-update-record.js +1 -0
- package/dist/data-grid/cells.js +8 -5
- 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/file-image.js +35 -0
- package/dist/live/file-values.js +17 -1
- 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-CfuMqqAY.js +66 -0
- package/dist/shell/assets/index-CYWfU_c1.css +2 -0
- package/dist/shell/assets/{index-B8MhIaLr.js → index-DCTyUhH-.js} +3 -3
- package/dist/shell/assets/maplibre-gl-DP9hPAtI.js +1 -0
- package/dist/shell/assets/record-map-Dgn4pFlE.js +814 -0
- package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
- package/dist/shell/index.html +5 -5
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +5 -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-BYw-8qpE.js +0 -66
- package/dist/shell/assets/index-CdSIk0BQ.css +0 -2
- package/dist/shell/assets/navigation-control-CGS0pamh.js +0 -814
- package/dist/shell/assets/record-map-9Y3IL_dg.js +0 -1
- package/dist/shell/assets/records-map-DXIWowJ3.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +0 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { displayRow } from "../displays/list-displays.js";
|
|
3
|
+
import { columnsFromModel } from "../../live/columns-from-model.js";
|
|
4
|
+
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
5
|
+
import { scopeOf } from "../pages/use-layout-data.js";
|
|
6
|
+
import { FieldValue } from "../pages/field-value.js";
|
|
7
|
+
import { usePathValue } from "../pages/use-path-value.js";
|
|
8
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
|
+
import { resolveFieldPath } from "@protobase/layout";
|
|
10
|
+
//#region src/app/embedded-list/embedded-columns.tsx
|
|
11
|
+
const isPath = (column) => column.includes(".");
|
|
12
|
+
/** A value through relations (`unit_id.property_id`), fetched like the record page fetches it and cached. */
|
|
13
|
+
const PathCell = ({ model, row, column }) => {
|
|
14
|
+
const value = usePathValue(scopeOf(model, displayRow(model, row).record), column);
|
|
15
|
+
if (value.state === "loading") return /* @__PURE__ */ jsx("span", {
|
|
16
|
+
className: "text-muted-foreground",
|
|
17
|
+
children: "…"
|
|
18
|
+
});
|
|
19
|
+
if (value.state === "missing") return /* @__PURE__ */ jsx("span", {
|
|
20
|
+
className: "text-muted-foreground",
|
|
21
|
+
children: "—"
|
|
22
|
+
});
|
|
23
|
+
return /* @__PURE__ */ jsx(FieldValue, {
|
|
24
|
+
model: value.model,
|
|
25
|
+
field: value.field,
|
|
26
|
+
value: value.value
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
/** A column through relations, headed by the label of the field it ends at. */
|
|
30
|
+
const pathColumn = (model, column, { resources, views }) => {
|
|
31
|
+
const resolved = resolveFieldPath(resources, model.name, column);
|
|
32
|
+
const last = resolved.ok ? resolved.steps.at(-1) : void 0;
|
|
33
|
+
const name = last?.field.name ?? column;
|
|
34
|
+
return {
|
|
35
|
+
id: column,
|
|
36
|
+
header: views[last?.model.name ?? model.name]?.fields[name]?.label ?? humanize(name),
|
|
37
|
+
kind: "text",
|
|
38
|
+
width: 200,
|
|
39
|
+
render: (row) => /* @__PURE__ */ jsx(PathCell, {
|
|
40
|
+
model,
|
|
41
|
+
row,
|
|
42
|
+
column
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** The list page's columns for `names`: fields of `model` as its table draws them, and paths through relations. */
|
|
47
|
+
const embeddedColumns = (model, view, names, meta) => {
|
|
48
|
+
const fields = new Map(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes).map((spec) => [spec.id, spec]));
|
|
49
|
+
return names.flatMap((name) => isPath(name) ? [pathColumn(model, name, meta)] : fields.get(name) ?? []);
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { embeddedColumns };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { guarded } from "../../lib/guarded.js";
|
|
2
|
+
import { defaultDisplay, displayChoices } from "../displays/list-displays.js";
|
|
3
|
+
import { changedList, listDefaults } from "../current-address.js";
|
|
4
|
+
//#region src/app/embedded-list/embedded-display.ts
|
|
5
|
+
const prefix = "protobase:embedded-display:";
|
|
6
|
+
/** Where a viewer's pick of display is kept for one embedded list, named by the place it is shown. */
|
|
7
|
+
const embeddedDisplayKey = (place) => `${prefix}${place}`;
|
|
8
|
+
const readPickedDisplay = (storage, key) => guarded(() => storage.getItem(key) ?? void 0, void 0);
|
|
9
|
+
const writePickedDisplay = (storage, key, display) => guarded(() => storage.setItem(key, display), void 0);
|
|
10
|
+
/**
|
|
11
|
+
* The display an embedded list shows: the viewer's pick while the view still has it, else what the list opens in for
|
|
12
|
+
* the screen, with the display the page names in place of the view's default (below `sm`, a cards display when the
|
|
13
|
+
* view has one).
|
|
14
|
+
*/
|
|
15
|
+
const embeddedDisplay = (view, xs, { picked, preferred }) => {
|
|
16
|
+
const names = displayChoices(view).map((choice) => choice.name);
|
|
17
|
+
if (picked !== void 0 && names.includes(picked)) return picked;
|
|
18
|
+
return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
|
|
19
|
+
};
|
|
20
|
+
/** The resource's list with the embedded list's filter, order and display, leaving out what the list does by default. */
|
|
21
|
+
const viewAllAddress = (model, view, xs, list) => changedList({
|
|
22
|
+
kind: "list",
|
|
23
|
+
resource: model.name
|
|
24
|
+
}, list, listDefaults(model, view, xs));
|
|
25
|
+
//#endregion
|
|
26
|
+
export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useIsDesktop, useIsXs } from "../../lib/use-media-query.js";
|
|
2
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
6
|
+
import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
|
|
7
|
+
import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
|
|
8
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
9
|
+
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
10
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
11
|
+
import { DataGrid } from "../../data-grid/data-grid.js";
|
|
12
|
+
import { listColumnNames } from "../../live/columns-from-model.js";
|
|
13
|
+
import { MapDisplay } from "../displays/map-display.js";
|
|
14
|
+
import { CustomDisplay } from "../displays/custom-display.js";
|
|
15
|
+
import { DisplaySwitch } from "../displays/display-controls.js";
|
|
16
|
+
import { CardsDisplay } from "../displays/cards-display.js";
|
|
17
|
+
import { embeddedColumns } from "./embedded-columns.js";
|
|
18
|
+
import { viewAllAddress } from "./embedded-display.js";
|
|
19
|
+
import { useEmbeddedRows } from "./use-embedded-rows.js";
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
import { ArrowRight } from "lucide-react";
|
|
22
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
23
|
+
//#region src/app/embedded-list/embedded-list.tsx
|
|
24
|
+
/** The switch between the table and the view's displays, for an embedded list's heading; nothing with only a table. */
|
|
25
|
+
const EmbeddedDisplaySwitch = ({ view, value, onChange }) => /* @__PURE__ */ jsx(DisplaySwitch, {
|
|
26
|
+
view,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
menu: !useIsDesktop()
|
|
30
|
+
});
|
|
31
|
+
const Loading = () => /* @__PURE__ */ jsx("div", {
|
|
32
|
+
className: "flex flex-col gap-2",
|
|
33
|
+
children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
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
|
+
* to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
|
|
38
|
+
*/
|
|
39
|
+
const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty }) => {
|
|
40
|
+
const { resources, views } = useAdminMeta();
|
|
41
|
+
const { fields: fieldTypes } = useProjectUi();
|
|
42
|
+
const { go } = useAppAddress();
|
|
43
|
+
const xs = useIsXs();
|
|
44
|
+
const [orderBy, setOrderBy] = useState(sort || defaultOrderBy(model, view));
|
|
45
|
+
const names = columns ?? listColumnNames(model, view);
|
|
46
|
+
const specs = useMemo(() => embeddedColumns(model, view, names, {
|
|
47
|
+
resources,
|
|
48
|
+
views,
|
|
49
|
+
fieldTypes
|
|
50
|
+
}), [
|
|
51
|
+
model,
|
|
52
|
+
view,
|
|
53
|
+
names.join(","),
|
|
54
|
+
resources,
|
|
55
|
+
views,
|
|
56
|
+
fieldTypes
|
|
57
|
+
]);
|
|
58
|
+
const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
|
|
59
|
+
model,
|
|
60
|
+
view,
|
|
61
|
+
specs
|
|
62
|
+
]);
|
|
63
|
+
const rows = useEmbeddedRows({
|
|
64
|
+
model,
|
|
65
|
+
filter,
|
|
66
|
+
orderBy,
|
|
67
|
+
limit,
|
|
68
|
+
fields
|
|
69
|
+
});
|
|
70
|
+
const loaded = rows.data;
|
|
71
|
+
const source = useMemo(() => loaded && {
|
|
72
|
+
total: loaded.length,
|
|
73
|
+
rowAt: (index) => loaded[index]
|
|
74
|
+
}, [loaded]);
|
|
75
|
+
const shown = shownDisplay(view, display);
|
|
76
|
+
if (rows.isPending) return /* @__PURE__ */ jsx(Loading, {});
|
|
77
|
+
if (rows.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
78
|
+
title: "Could not load",
|
|
79
|
+
error: rows.error
|
|
80
|
+
});
|
|
81
|
+
if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
|
|
82
|
+
className: "text-[13px] text-muted-foreground",
|
|
83
|
+
children: empty ?? "Nothing here yet."
|
|
84
|
+
});
|
|
85
|
+
const list = {
|
|
86
|
+
resource: model.name,
|
|
87
|
+
rows: rows.data.map((row) => displayRow(model, row)),
|
|
88
|
+
total: rows.data.length,
|
|
89
|
+
loading: false,
|
|
90
|
+
error: null,
|
|
91
|
+
more: false,
|
|
92
|
+
loadMore: () => void 0
|
|
93
|
+
};
|
|
94
|
+
const openRecord = (row) => go({
|
|
95
|
+
kind: "record",
|
|
96
|
+
resource: model.name,
|
|
97
|
+
key: recordKey(model, row)
|
|
98
|
+
});
|
|
99
|
+
const singular = view?.names?.singular ?? humanize(model.name);
|
|
100
|
+
const plural = view?.names?.plural ?? humanize(model.name);
|
|
101
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
102
|
+
className: "flex flex-col gap-3",
|
|
103
|
+
children: [!shown ? /* @__PURE__ */ jsx(DataGrid, {
|
|
104
|
+
fit: true,
|
|
105
|
+
columns: specs,
|
|
106
|
+
source,
|
|
107
|
+
latencyMs: 0,
|
|
108
|
+
sort: parseSort(orderBy),
|
|
109
|
+
onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
|
|
110
|
+
onOpenRow: openRecord,
|
|
111
|
+
rowActions: [{
|
|
112
|
+
label: `Open ${singular.toLowerCase()}`,
|
|
113
|
+
onSelect: openRecord
|
|
114
|
+
}],
|
|
115
|
+
rowNumbers: false,
|
|
116
|
+
selectable: false
|
|
117
|
+
}) : shown.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
|
|
118
|
+
model,
|
|
119
|
+
view,
|
|
120
|
+
display: shown,
|
|
121
|
+
list,
|
|
122
|
+
row: xs,
|
|
123
|
+
footer: false
|
|
124
|
+
}) : shown.kind === "map" ? /* @__PURE__ */ jsx(MapDisplay, {
|
|
125
|
+
model,
|
|
126
|
+
view,
|
|
127
|
+
display: shown,
|
|
128
|
+
filter,
|
|
129
|
+
orderBy,
|
|
130
|
+
fields,
|
|
131
|
+
footer: false
|
|
132
|
+
}) : /* @__PURE__ */ jsx(CustomDisplay, {
|
|
133
|
+
display: shown,
|
|
134
|
+
list
|
|
135
|
+
}), /* @__PURE__ */ jsxs(AppLink, {
|
|
136
|
+
to: viewAllAddress(model, view, xs, {
|
|
137
|
+
filter,
|
|
138
|
+
orderBy,
|
|
139
|
+
display
|
|
140
|
+
}),
|
|
141
|
+
className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
|
|
142
|
+
children: [
|
|
143
|
+
"View all ",
|
|
144
|
+
/* @__PURE__ */ jsx("span", {
|
|
145
|
+
className: "sr-only",
|
|
146
|
+
children: plural.toLowerCase()
|
|
147
|
+
}),
|
|
148
|
+
/* @__PURE__ */ jsx(ArrowRight, {
|
|
149
|
+
className: "size-3.5",
|
|
150
|
+
"aria-hidden": true
|
|
151
|
+
})
|
|
152
|
+
]
|
|
153
|
+
})]
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
//#endregion
|
|
157
|
+
export { EmbeddedDisplaySwitch, EmbeddedList };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useIsXs } from "../../lib/use-media-query.js";
|
|
2
|
+
import { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, writePickedDisplay } from "./embedded-display.js";
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
//#region src/app/embedded-list/use-embedded-display.ts
|
|
5
|
+
/**
|
|
6
|
+
* The display an embedded list shows and the way to pick another, which is kept in local storage for the viewer under
|
|
7
|
+
* `place`, the list's place in the app.
|
|
8
|
+
*/
|
|
9
|
+
const useEmbeddedDisplay = (view, place, preferred) => {
|
|
10
|
+
const xs = useIsXs();
|
|
11
|
+
const key = embeddedDisplayKey(place);
|
|
12
|
+
const [picked, setPicked] = useState(() => readPickedDisplay(localStorage, key));
|
|
13
|
+
const pick = useCallback((display) => {
|
|
14
|
+
setPicked(display);
|
|
15
|
+
writePickedDisplay(localStorage, key, display);
|
|
16
|
+
}, [key]);
|
|
17
|
+
return [embeddedDisplay(view, xs, {
|
|
18
|
+
picked,
|
|
19
|
+
preferred
|
|
20
|
+
}), pick];
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { useEmbeddedDisplay };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { recordId } from "../../live/model-helpers.js";
|
|
4
|
+
import { withLabels } from "../../live/relation-labels.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
//#region src/app/embedded-list/use-embedded-rows.ts
|
|
8
|
+
/**
|
|
9
|
+
* The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
|
|
10
|
+
* `fields` as `{ id, name }`. A filter through a join resource may hold hundreds of keys, so it goes in a JSON body.
|
|
11
|
+
*/
|
|
12
|
+
const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
|
|
13
|
+
const client = useClient();
|
|
14
|
+
const queryClient = useQueryClient();
|
|
15
|
+
const { resources, views } = useAdminMeta();
|
|
16
|
+
return useQuery({
|
|
17
|
+
queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
|
|
18
|
+
placeholderData: keepPreviousData,
|
|
19
|
+
queryFn: async () => {
|
|
20
|
+
const page = await client.search(model.name, {
|
|
21
|
+
filter: filter || void 0,
|
|
22
|
+
orderBy: orderBy || void 0,
|
|
23
|
+
pageSize: limit
|
|
24
|
+
});
|
|
25
|
+
return (await withLabels(queryClient, client, resources, views, model, fields, page.items)).map((row, index) => ({
|
|
26
|
+
...row,
|
|
27
|
+
id: recordId(model, page.items[index])
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { useEmbeddedRows };
|
package/dist/app/file-field.js
CHANGED
|
@@ -2,21 +2,27 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { Button } from "../primitives/button.js";
|
|
3
3
|
import { useClient } from "../data/api-provider.js";
|
|
4
4
|
import { humanize } from "../live/naming.js";
|
|
5
|
-
import { acceptOf, fileShown, formatSize, isPreviewable } from "../live/file-values.js";
|
|
5
|
+
import { acceptOf, fileShown, formatSize, isPreviewable, shownImage } from "../live/file-values.js";
|
|
6
6
|
import { ErrorBanner } from "./error-banner.js";
|
|
7
|
+
import { FileImage } from "../live/file-image.js";
|
|
7
8
|
import { useRef, useState } from "react";
|
|
8
9
|
import { FileText, Paperclip, Upload } from "lucide-react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
import { useMutation } from "@tanstack/react-query";
|
|
11
12
|
//#region src/app/file-field.tsx
|
|
12
|
-
const Thumbnail = ({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const Thumbnail = ({ value }) => {
|
|
14
|
+
const image = shownImage(value, {
|
|
15
|
+
width: 40,
|
|
16
|
+
height: 40
|
|
17
|
+
});
|
|
18
|
+
return image ? /* @__PURE__ */ jsx(FileImage, {
|
|
19
|
+
image,
|
|
20
|
+
className: "size-10 shrink-0 rounded border border-border object-cover"
|
|
21
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
22
|
+
className: "flex size-10 shrink-0 items-center justify-center rounded border border-border bg-muted text-muted-foreground",
|
|
23
|
+
children: /* @__PURE__ */ jsx(FileText, { className: "size-4" })
|
|
24
|
+
});
|
|
25
|
+
};
|
|
20
26
|
/** The file as it is: thumbnail or icon, name (a link while it has a URL), type and size, and what the server corrected. */
|
|
21
27
|
const FileSummary = ({ value }) => {
|
|
22
28
|
const shown = fileShown(value);
|
|
@@ -32,10 +38,7 @@ const FileSummary = ({ value }) => {
|
|
|
32
38
|
const corrected = "corrected" in shown ? shown.corrected : void 0;
|
|
33
39
|
return /* @__PURE__ */ jsxs("div", {
|
|
34
40
|
className: "flex min-w-0 items-center gap-3 py-1",
|
|
35
|
-
children: [/* @__PURE__ */ jsx(Thumbnail, {
|
|
36
|
-
url,
|
|
37
|
-
type: shown.type
|
|
38
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
41
|
+
children: [/* @__PURE__ */ jsx(Thumbnail, { value }), /* @__PURE__ */ jsxs("div", {
|
|
39
42
|
className: "min-w-0 text-[13px]",
|
|
40
43
|
children: [url && !url.startsWith("blob:") ? /* @__PURE__ */ jsx("a", {
|
|
41
44
|
href: url,
|
package/dist/app/list-page.js
CHANGED
|
@@ -26,11 +26,12 @@ import { useLiveRows } from "../live/use-live-rows.js";
|
|
|
26
26
|
import { Sheet } from "../primitives/sheet.js";
|
|
27
27
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
28
28
|
import { useRestore } from "./delete/use-restore.js";
|
|
29
|
-
import {
|
|
29
|
+
import { ListCards } from "./displays/list-cards.js";
|
|
30
30
|
import { MapDisplay } from "./displays/map-display.js";
|
|
31
31
|
import { CustomDisplay } from "./displays/custom-display.js";
|
|
32
32
|
import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
|
|
33
33
|
import { useDisplayRows } from "./displays/use-display-rows.js";
|
|
34
|
+
import { useListScroll } from "./list-scroll.js";
|
|
34
35
|
import { ListChart } from "./list-chart.js";
|
|
35
36
|
import { ListSearch } from "./list-search.js";
|
|
36
37
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
@@ -98,7 +99,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
98
99
|
orderBy
|
|
99
100
|
});
|
|
100
101
|
const display = shownDisplay(view, address.display);
|
|
101
|
-
const displayRows = useDisplayRows(model, rows.page, rows.key, display
|
|
102
|
+
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
103
|
+
const scroll = useListScroll();
|
|
102
104
|
const titleField = recordTitleField(model, view);
|
|
103
105
|
const target = (row) => ({
|
|
104
106
|
key: row.id,
|
|
@@ -234,6 +236,8 @@ const LoadedList = ({ model, address }) => {
|
|
|
234
236
|
sort: parseSort(orderBy),
|
|
235
237
|
onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
|
|
236
238
|
onOpenRow: openRecord,
|
|
239
|
+
initialScrollIndex: scroll.initialIndex,
|
|
240
|
+
onScrollIndex: scroll.remember,
|
|
237
241
|
rowActions: [{
|
|
238
242
|
label: `Open ${singular.toLowerCase()}`,
|
|
239
243
|
onSelect: openRecord
|
|
@@ -252,19 +256,28 @@ const LoadedList = ({ model, address }) => {
|
|
|
252
256
|
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
|
|
253
257
|
className: "min-h-0 flex-1"
|
|
254
258
|
}) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
|
|
259
|
+
const cardsList = {
|
|
260
|
+
...rows,
|
|
261
|
+
initialIndex: scroll.initialIndex,
|
|
262
|
+
onScrollIndex: scroll.remember
|
|
263
|
+
};
|
|
255
264
|
const drawDisplay = () => {
|
|
256
265
|
if (!display) return table;
|
|
257
|
-
if (display.kind === "cards") return /* @__PURE__ */ jsx(
|
|
266
|
+
if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
|
|
258
267
|
model,
|
|
259
268
|
view,
|
|
260
269
|
display,
|
|
261
|
-
list:
|
|
262
|
-
});
|
|
270
|
+
list: cardsList
|
|
271
|
+
}, rows.key.join(" "));
|
|
263
272
|
if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
|
|
264
273
|
model,
|
|
265
274
|
view,
|
|
266
275
|
display,
|
|
267
|
-
|
|
276
|
+
filter: parsed.ok ? filterText : "",
|
|
277
|
+
orderBy,
|
|
278
|
+
fields: loaded,
|
|
279
|
+
at: address.map,
|
|
280
|
+
onMove: (map) => set({ map })
|
|
268
281
|
});
|
|
269
282
|
return /* @__PURE__ */ jsx(CustomDisplay, {
|
|
270
283
|
display,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useRouter } from "./router.js";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
//#region src/app/list-scroll.ts
|
|
4
|
+
/** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
|
|
5
|
+
const remembered = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
|
|
8
|
+
* history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
|
|
9
|
+
* the top again.
|
|
10
|
+
*/
|
|
11
|
+
const useListScroll = () => {
|
|
12
|
+
const { entry, path, params } = useRouter();
|
|
13
|
+
const url = `${path}?${params.toString()}`;
|
|
14
|
+
const last = remembered.get(entry);
|
|
15
|
+
const remember = useCallback((index) => void remembered.set(entry, {
|
|
16
|
+
url,
|
|
17
|
+
index
|
|
18
|
+
}), [entry, url]);
|
|
19
|
+
return {
|
|
20
|
+
initialIndex: last?.url === url ? last.index : 0,
|
|
21
|
+
remember
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { useListScroll };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/map/map-area.ts
|
|
3
|
+
const where = createWhere();
|
|
4
|
+
/** The whole world from as far out as a map goes: what a map asks for before it knows where its records are. */
|
|
5
|
+
const worldArea = {
|
|
6
|
+
bounds: {
|
|
7
|
+
south: -90,
|
|
8
|
+
west: -180,
|
|
9
|
+
north: 90,
|
|
10
|
+
east: 180
|
|
11
|
+
},
|
|
12
|
+
zoom: 0
|
|
13
|
+
};
|
|
14
|
+
const maxZoom = 22;
|
|
15
|
+
const down = (degrees) => Math.floor(degrees * 1e6) / 1e6;
|
|
16
|
+
const up = (degrees) => Math.ceil(degrees * 1e6) / 1e6;
|
|
17
|
+
const wrapped = (longitude) => ((longitude + 180) % 360 + 360) % 360 - 180;
|
|
18
|
+
/**
|
|
19
|
+
* The AIP-160 filter for the records inside `bounds`: between its latitudes, and between its longitudes on the globe.
|
|
20
|
+
* A view across the antimeridian takes the longitudes on both sides of it, and one around the whole world takes them all.
|
|
21
|
+
*/
|
|
22
|
+
const boundsFilter = (fields, bounds) => {
|
|
23
|
+
const latitude = [where.gte(fields.latitude, down(Math.max(bounds.south, -90))), where.lte(fields.latitude, up(Math.min(bounds.north, 90)))];
|
|
24
|
+
if (bounds.east - bounds.west >= 360) return where.and(...latitude);
|
|
25
|
+
const west = where.gte(fields.longitude, down(wrapped(bounds.west)));
|
|
26
|
+
const east = where.lte(fields.longitude, up(wrapped(bounds.east)));
|
|
27
|
+
return wrapped(bounds.west) <= wrapped(bounds.east) ? where.and(...latitude, west, east) : where.and(...latitude, where.or(west, east));
|
|
28
|
+
};
|
|
29
|
+
/** The list's filter, its search included, narrowed to the records inside the map's bounds. */
|
|
30
|
+
const areaFilter = (listFilter, fields, bounds) => {
|
|
31
|
+
const inside = printFilter(boundsFilter(fields, bounds));
|
|
32
|
+
return listFilter.trim() === "" ? inside : `(${listFilter}) AND ${inside}`;
|
|
33
|
+
};
|
|
34
|
+
/** The zoom the records are asked for at: the whole zoom level the map is at. */
|
|
35
|
+
const requestZoom = (zoom) => Math.min(Math.max(Math.floor(zoom), 0), maxZoom);
|
|
36
|
+
/** The bounds around the records and the buckets, or none without either. */
|
|
37
|
+
const boundsAround = (points, buckets) => {
|
|
38
|
+
const corners = [...points.map((point) => ({
|
|
39
|
+
south: point.latitude,
|
|
40
|
+
west: point.longitude,
|
|
41
|
+
north: point.latitude,
|
|
42
|
+
east: point.longitude
|
|
43
|
+
})), ...buckets.map((bucket) => bucket.bounds)];
|
|
44
|
+
if (corners.length === 0) return void 0;
|
|
45
|
+
return {
|
|
46
|
+
south: Math.min(...corners.map((corner) => corner.south)),
|
|
47
|
+
west: Math.min(...corners.map((corner) => corner.west)),
|
|
48
|
+
north: Math.max(...corners.map((corner) => corner.north)),
|
|
49
|
+
east: Math.max(...corners.map((corner) => corner.east))
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
export { areaFilter, boundsAround, boundsFilter, requestZoom, worldArea };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
|
-
import { groupPoints
|
|
2
|
+
import { groupPoints } from "../displays/map-groups.js";
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { MapPin } from "lucide-react";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -12,18 +12,29 @@ const fitOptions = {
|
|
|
12
12
|
maxZoom: 15,
|
|
13
13
|
duration: 0
|
|
14
14
|
};
|
|
15
|
+
const bucketOptions = {
|
|
16
|
+
padding: 48,
|
|
17
|
+
maxZoom: 16,
|
|
18
|
+
duration: 300
|
|
19
|
+
};
|
|
15
20
|
const markerHeight = 32;
|
|
21
|
+
const compact = new Intl.NumberFormat("en-IE", {
|
|
22
|
+
notation: "compact",
|
|
23
|
+
maximumFractionDigits: 1
|
|
24
|
+
});
|
|
25
|
+
const corners = (bounds) => [[bounds.west, bounds.south], [bounds.east, bounds.north]];
|
|
16
26
|
/**
|
|
17
27
|
* A map of `style` with a marker for each group of records that lie close together at the zoom, showing how many it
|
|
18
|
-
* stands for
|
|
19
|
-
* clears the selection. A chosen marker the covered bottom would hide
|
|
28
|
+
* stands for, and a circle with the count of each bucket, which zooms in on its records when chosen. Choosing a marker
|
|
29
|
+
* selects its records; choosing the map elsewhere clears the selection. A chosen marker the covered bottom would hide
|
|
30
|
+
* moves up into the open part of the map.
|
|
20
31
|
*/
|
|
21
|
-
const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, markerLabel }) => {
|
|
32
|
+
const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
|
|
22
33
|
const ref = useRef(null);
|
|
23
34
|
const [groups, setGroups] = useState([]);
|
|
24
35
|
const byKey = new globalThis.Map(points.map((point) => [point.key, point]));
|
|
25
36
|
const pointKeys = points.map((point) => point.key).join(",");
|
|
26
|
-
const
|
|
37
|
+
const fitKey = fit && JSON.stringify(fit);
|
|
27
38
|
const regroup = () => {
|
|
28
39
|
const map = ref.current?.getMap();
|
|
29
40
|
if (!map) return;
|
|
@@ -32,12 +43,34 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
|
|
|
32
43
|
...map.project([point.longitude, point.latitude])
|
|
33
44
|
})), groupRadius));
|
|
34
45
|
};
|
|
35
|
-
|
|
46
|
+
const report = () => {
|
|
36
47
|
const map = ref.current?.getMap();
|
|
37
48
|
if (!map) return;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
const bounds = map.getBounds();
|
|
50
|
+
const center = map.getCenter();
|
|
51
|
+
onMove({
|
|
52
|
+
bounds: {
|
|
53
|
+
south: bounds.getSouth(),
|
|
54
|
+
west: bounds.getWest(),
|
|
55
|
+
north: bounds.getNorth(),
|
|
56
|
+
east: bounds.getEast()
|
|
57
|
+
},
|
|
58
|
+
zoom: map.getZoom()
|
|
59
|
+
}, {
|
|
60
|
+
latitude: center.lat,
|
|
61
|
+
longitude: center.lng,
|
|
62
|
+
zoom: map.getZoom()
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const fitNow = () => {
|
|
66
|
+
const map = ref.current?.getMap();
|
|
67
|
+
if (!map || !fit) return;
|
|
68
|
+
map.fitBounds(corners(fit), fitOptions);
|
|
69
|
+
onFitted();
|
|
70
|
+
report();
|
|
71
|
+
};
|
|
72
|
+
useEffect(regroup, [pointKeys]);
|
|
73
|
+
useEffect(fitNow, [fitKey]);
|
|
41
74
|
const chosenAt = groups.find((group) => selected !== void 0 && group.keys.some((key) => selected.includes(key)))?.at;
|
|
42
75
|
useEffect(() => {
|
|
43
76
|
const map = ref.current?.getMap();
|
|
@@ -56,10 +89,7 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
|
|
|
56
89
|
ref,
|
|
57
90
|
mapLib: maplibre,
|
|
58
91
|
mapStyle: style,
|
|
59
|
-
initialViewState:
|
|
60
|
-
bounds,
|
|
61
|
-
fitBoundsOptions: fitOptions
|
|
62
|
-
} : {
|
|
92
|
+
initialViewState: initial ?? {
|
|
63
93
|
latitude: 0,
|
|
64
94
|
longitude: 0,
|
|
65
95
|
zoom: 1
|
|
@@ -68,38 +98,61 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
|
|
|
68
98
|
width: "100%",
|
|
69
99
|
height: "100%"
|
|
70
100
|
},
|
|
71
|
-
onLoad:
|
|
101
|
+
onLoad: () => {
|
|
102
|
+
regroup();
|
|
103
|
+
if (fit) fitNow();
|
|
104
|
+
else report();
|
|
105
|
+
},
|
|
72
106
|
onZoomEnd: regroup,
|
|
107
|
+
onMoveEnd: report,
|
|
73
108
|
onClick: onClear,
|
|
74
|
-
children: [
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
anchor: "bottom",
|
|
82
|
-
style: { zIndex: chosen ? 1 : 0 },
|
|
83
|
-
children: /* @__PURE__ */ jsxs("button", {
|
|
109
|
+
children: [
|
|
110
|
+
/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
|
|
111
|
+
buckets.map((bucket) => /* @__PURE__ */ jsx(Marker, {
|
|
112
|
+
latitude: bucket.latitude,
|
|
113
|
+
longitude: bucket.longitude,
|
|
114
|
+
anchor: "center",
|
|
115
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
84
116
|
type: "button",
|
|
85
|
-
"aria-label":
|
|
86
|
-
"
|
|
87
|
-
className: "relative block cursor-pointer",
|
|
117
|
+
"aria-label": bucketLabel(bucket.count),
|
|
118
|
+
className: "flex size-10 cursor-pointer items-center justify-center rounded-full border-2 border-background bg-primary text-xs font-semibold text-primary-foreground tabular-nums shadow",
|
|
88
119
|
onClick: (event) => {
|
|
89
120
|
event.stopPropagation();
|
|
90
|
-
|
|
121
|
+
ref.current?.getMap().fitBounds(corners(bucket.bounds), bucketOptions);
|
|
91
122
|
},
|
|
92
|
-
children:
|
|
93
|
-
"aria-hidden": true,
|
|
94
|
-
className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
|
|
95
|
-
strokeWidth: 1.5
|
|
96
|
-
}), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
|
|
97
|
-
className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
|
|
98
|
-
children: group.keys.length
|
|
99
|
-
})]
|
|
123
|
+
children: compact.format(bucket.count)
|
|
100
124
|
})
|
|
101
|
-
},
|
|
102
|
-
|
|
125
|
+
}, `${bucket.latitude},${bucket.longitude}`)),
|
|
126
|
+
groups.map((group) => {
|
|
127
|
+
const at = byKey.get(group.at);
|
|
128
|
+
if (!at) return null;
|
|
129
|
+
const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
|
|
130
|
+
return /* @__PURE__ */ jsx(Marker, {
|
|
131
|
+
latitude: at.latitude,
|
|
132
|
+
longitude: at.longitude,
|
|
133
|
+
anchor: "bottom",
|
|
134
|
+
style: { zIndex: chosen ? 1 : 0 },
|
|
135
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
136
|
+
type: "button",
|
|
137
|
+
"aria-label": markerLabel(group.keys),
|
|
138
|
+
"aria-pressed": chosen,
|
|
139
|
+
className: "relative block cursor-pointer",
|
|
140
|
+
onClick: (event) => {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
onSelect(group.keys);
|
|
143
|
+
},
|
|
144
|
+
children: [/* @__PURE__ */ jsx(MapPin, {
|
|
145
|
+
"aria-hidden": true,
|
|
146
|
+
className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
|
|
147
|
+
strokeWidth: 1.5
|
|
148
|
+
}), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
|
|
149
|
+
className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
|
|
150
|
+
children: group.keys.length
|
|
151
|
+
})]
|
|
152
|
+
})
|
|
153
|
+
}, group.at);
|
|
154
|
+
})
|
|
155
|
+
]
|
|
103
156
|
});
|
|
104
157
|
};
|
|
105
158
|
//#endregion
|