@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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
|
|
1
|
+
import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
|
|
2
2
|
//#region src/app/current-address.d.ts
|
|
3
|
-
/** A list as the app shows it: every value there, the view's default where the address names none. */
|
|
3
|
+
/** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
|
|
4
4
|
export type ListAddress = {
|
|
5
5
|
kind: 'list';
|
|
6
6
|
resource: string;
|
|
@@ -8,6 +8,7 @@ export type ListAddress = {
|
|
|
8
8
|
filter: string;
|
|
9
9
|
orderBy: string;
|
|
10
10
|
filterLayout: FilterLayout;
|
|
11
|
+
map?: MapView;
|
|
11
12
|
};
|
|
12
13
|
/** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
|
|
13
14
|
export type CurrentAddress = ListAddress | {
|
|
@@ -35,7 +35,8 @@ const changedList = (read, change, defaults) => {
|
|
|
35
35
|
...next.filter && { filter: next.filter },
|
|
36
36
|
...differs("display") && { display: next.display },
|
|
37
37
|
...differs("orderBy") && { orderBy: next.orderBy },
|
|
38
|
-
...differs("filterLayout") && { filterLayout: next.filterLayout }
|
|
38
|
+
...differs("filterLayout") && { filterLayout: next.filterLayout },
|
|
39
|
+
...next.map && { map: next.map }
|
|
39
40
|
};
|
|
40
41
|
};
|
|
41
42
|
//#endregion
|
|
@@ -25,7 +25,8 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
|
|
|
25
25
|
for (const prefix of [
|
|
26
26
|
"page",
|
|
27
27
|
"list",
|
|
28
|
-
"first-page"
|
|
28
|
+
"first-page",
|
|
29
|
+
"map"
|
|
29
30
|
]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
|
|
30
31
|
for (const prefix of [
|
|
31
32
|
"facets",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { keys } from "../../data/query-keys.js";
|
|
3
3
|
import { useClient } from "../../data/api-provider.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
5
|
import { cardPhotoList, cardPhotosRequest, shownPhoto } from "./card-photos.js";
|
|
5
6
|
import { useState } from "react";
|
|
6
7
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
@@ -17,7 +18,9 @@ const CardCarousel = ({ photos, recordKey, cover, children }) => {
|
|
|
17
18
|
const client = useClient();
|
|
18
19
|
const [opened, setOpened] = useState(false);
|
|
19
20
|
const [step, setStep] = useState(0);
|
|
20
|
-
const
|
|
21
|
+
const target = useAdminMeta().resources[photos.resource];
|
|
22
|
+
const placeholder = target?.fields[photos.image]?.file?.placeholder;
|
|
23
|
+
const request = cardPhotosRequest(photos, recordKey, placeholder !== void 0 && target?.fields[placeholder] ? placeholder : void 0);
|
|
21
24
|
const loaded = useQuery({
|
|
22
25
|
queryKey: keys.list(photos.resource, request.filter, request.orderBy ?? "", request.pageSize, `card-photos:${photos.image}`),
|
|
23
26
|
enabled: opened,
|
|
@@ -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 };
|
|
@@ -2,11 +2,14 @@ import { isStoredFile } from "../../live/file-values.js";
|
|
|
2
2
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
3
3
|
//#region src/app/displays/card-photos.ts
|
|
4
4
|
const where = createWhere();
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The list request for a card's photos: the records pointing at the card's, in the display's order, with only their
|
|
7
|
+
* image and its placeholder field, when it has one.
|
|
8
|
+
*/
|
|
9
|
+
const cardPhotosRequest = (photos, recordKey, placeholder) => ({
|
|
7
10
|
filter: printFilter(where.eq(photos.field, recordKey)),
|
|
8
11
|
...photos.sort !== void 0 && { orderBy: photos.sort },
|
|
9
|
-
fields: [photos.image],
|
|
12
|
+
fields: placeholder === void 0 ? [photos.image] : [photos.image, placeholder],
|
|
10
13
|
pageSize: 100
|
|
11
14
|
});
|
|
12
15
|
/** The files a card steps through: its own image first, then the loaded photos less that same file; empty values are left out. */
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
1
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
3
|
import { humanize } from "../../live/naming.js";
|
|
3
|
-
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
4
4
|
import { RecordCard } from "./record-card.js";
|
|
5
|
+
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
5
6
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
7
|
//#region src/app/displays/cards-display.tsx
|
|
7
8
|
const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
|
|
8
|
-
|
|
9
|
-
|
|
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 }) => {
|
|
10
12
|
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
11
13
|
return /* @__PURE__ */ jsxs(DisplayState, {
|
|
12
14
|
list,
|
|
@@ -16,15 +18,18 @@ const CardsDisplay = ({ model, view, display, list }) => {
|
|
|
16
18
|
children: Array.from({ length: 6 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-24" }, index))
|
|
17
19
|
}),
|
|
18
20
|
children: [/* @__PURE__ */ jsx("ul", {
|
|
19
|
-
className: grid,
|
|
21
|
+
className: row ? scroller : grid,
|
|
20
22
|
"aria-label": view?.names?.plural ?? humanize(model.name),
|
|
21
|
-
children: list.rows.map((
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 })]
|
|
28
33
|
});
|
|
29
34
|
};
|
|
30
35
|
//#endregion
|
|
@@ -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 };
|
|
@@ -6,10 +6,11 @@ import { encodeKey, tableDisplay } from "@protobase/schema";
|
|
|
6
6
|
const displaysOf = (view) => view?.list?.displays ?? [];
|
|
7
7
|
/**
|
|
8
8
|
* The display a list opens in when its address names none. Below the `sm` breakpoint a list with a cards display opens
|
|
9
|
-
* 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.
|
|
10
11
|
*/
|
|
11
|
-
const defaultDisplay = (view, xs) => {
|
|
12
|
-
const fallback = view?.list?.default ?? tableDisplay;
|
|
12
|
+
const defaultDisplay = (view, xs, preferred) => {
|
|
13
|
+
const fallback = preferred ?? view?.list?.default ?? tableDisplay;
|
|
13
14
|
if (!xs) return fallback;
|
|
14
15
|
const cards = displaysOf(view).filter((display) => display.kind === "cards");
|
|
15
16
|
return cards.find((display) => display.name === fallback)?.name ?? cards[0]?.name ?? fallback;
|
|
@@ -1,48 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
3
|
import { mapCards } from "./list-displays.js";
|
|
4
4
|
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
5
6
|
import { Problem } from "../pages/problem.js";
|
|
6
|
-
import { DisplayFooter, DisplayState } from "./display-frame.js";
|
|
7
7
|
import { RecordCard } from "./record-card.js";
|
|
8
|
+
import { useDebounced } from "../../lib/use-debounced.js";
|
|
9
|
+
import { boundsAround, worldArea } from "../map/map-area.js";
|
|
8
10
|
import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
|
|
11
|
+
import { useMapRecords } from "./use-map-records.js";
|
|
9
12
|
import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
|
|
10
13
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
11
14
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
15
|
//#region src/app/displays/map-display.tsx
|
|
13
16
|
const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
|
|
17
|
+
const settleMs = 250;
|
|
14
18
|
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
19
|
/**
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* The matching records in the map's view, drawn with the map style `/meta` names: the records themselves when few are
|
|
21
|
+
* in view or the map is close, else a count for each part of it, which zooms in when chosen. The map asks again as it
|
|
22
|
+
* pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
|
|
23
|
+
* its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
|
|
24
|
+
* elsewhere clears it.
|
|
19
25
|
*/
|
|
20
|
-
const MapDisplay = ({ model, view, display,
|
|
26
|
+
const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true }) => {
|
|
21
27
|
const style = useAdminMeta().maps?.style;
|
|
22
28
|
const [chosen, setChosen] = useState();
|
|
29
|
+
const [area, setArea] = useState();
|
|
30
|
+
const settled = useDebounced(area, settleMs);
|
|
31
|
+
const [fitting, setFitting] = useState(at === void 0);
|
|
32
|
+
const records = useMapRecords({
|
|
33
|
+
model,
|
|
34
|
+
display,
|
|
35
|
+
fields,
|
|
36
|
+
filter,
|
|
37
|
+
orderBy,
|
|
38
|
+
area: fitting ? worldArea : settled
|
|
39
|
+
});
|
|
40
|
+
const loaded = records.isPlaceholderData ? void 0 : records.data;
|
|
41
|
+
const rows = records.data?.rows ?? [];
|
|
42
|
+
const buckets = records.data?.buckets ?? [];
|
|
43
|
+
const points = mapPoints(rows, display);
|
|
44
|
+
const fit = fitting && loaded ? boundsAround(mapPoints(loaded.rows, display), loaded.buckets) : void 0;
|
|
23
45
|
const cards = mapCards(view, display);
|
|
24
46
|
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
47
|
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
48
|
+
const selection = keptSelection(chosen, rows);
|
|
49
|
+
const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
|
|
28
50
|
const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
|
|
51
|
+
const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
|
|
29
52
|
const overlay = useRef(null);
|
|
30
53
|
const [covered, setCovered] = useState(0);
|
|
31
54
|
useLayoutEffect(() => setCovered(overlay.current?.offsetHeight ?? 0), [row?.key]);
|
|
32
55
|
if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
|
|
41
|
-
children: [/* @__PURE__ */ jsx(Suspense, {
|
|
56
|
+
const empty = loaded?.total === 0;
|
|
57
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
58
|
+
className: "flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]",
|
|
59
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
60
|
+
className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
|
|
61
|
+
children: [
|
|
62
|
+
/* @__PURE__ */ jsx(Suspense, {
|
|
42
63
|
fallback: null,
|
|
43
64
|
children: /* @__PURE__ */ jsx(RecordsMap, {
|
|
44
65
|
style,
|
|
45
|
-
|
|
66
|
+
initial: at,
|
|
67
|
+
points,
|
|
68
|
+
buckets,
|
|
69
|
+
fit,
|
|
70
|
+
onFitted: () => setFitting(false),
|
|
71
|
+
onMove: (next, center) => {
|
|
72
|
+
setArea(next);
|
|
73
|
+
onMove?.(center);
|
|
74
|
+
},
|
|
46
75
|
selected: selection?.keys,
|
|
47
76
|
covered: selection ? covered : 0,
|
|
48
77
|
onSelect: (keys) => setChosen({
|
|
@@ -50,9 +79,30 @@ const MapDisplay = ({ model, view, display, list }) => {
|
|
|
50
79
|
index: 0
|
|
51
80
|
}),
|
|
52
81
|
onClear: () => setChosen(void 0),
|
|
53
|
-
markerLabel
|
|
82
|
+
markerLabel,
|
|
83
|
+
bucketLabel
|
|
84
|
+
})
|
|
85
|
+
}),
|
|
86
|
+
records.error && /* @__PURE__ */ jsx("div", {
|
|
87
|
+
className: "absolute inset-x-3 top-3 z-10",
|
|
88
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
89
|
+
title: "Could not load rows",
|
|
90
|
+
error: records.error
|
|
54
91
|
})
|
|
55
|
-
}),
|
|
92
|
+
}),
|
|
93
|
+
empty && /* @__PURE__ */ jsx("div", {
|
|
94
|
+
className: "pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center",
|
|
95
|
+
children: /* @__PURE__ */ jsxs("p", {
|
|
96
|
+
className: "pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background px-4 py-1.5 text-[13px] text-muted-foreground shadow",
|
|
97
|
+
role: "status",
|
|
98
|
+
children: [fitting ? `No ${plural} match.` : `No ${plural} in this part of the map.`, !fitting && /* @__PURE__ */ jsx(Button, {
|
|
99
|
+
size: "sm",
|
|
100
|
+
onClick: () => setFitting(true),
|
|
101
|
+
children: "Show all"
|
|
102
|
+
})]
|
|
103
|
+
})
|
|
104
|
+
}),
|
|
105
|
+
selection && row && /* @__PURE__ */ jsx("div", {
|
|
56
106
|
ref: overlay,
|
|
57
107
|
className: "pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center",
|
|
58
108
|
children: /* @__PURE__ */ jsxs("div", {
|
|
@@ -111,10 +161,20 @@ const MapDisplay = ({ model, view, display, list }) => {
|
|
|
111
161
|
})
|
|
112
162
|
})]
|
|
113
163
|
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
})
|
|
164
|
+
})
|
|
165
|
+
]
|
|
166
|
+
}), footer && /* @__PURE__ */ jsx("p", {
|
|
167
|
+
className: "py-3 text-xs text-muted-foreground",
|
|
168
|
+
children: inView(records.data, plural)
|
|
169
|
+
})]
|
|
117
170
|
});
|
|
118
171
|
};
|
|
172
|
+
/** How many of the matching records are in view, and how many the map shows when it cannot show them all. */
|
|
173
|
+
const inView = (data, plural) => {
|
|
174
|
+
if (!data) return "\xA0";
|
|
175
|
+
const total = data.total.toLocaleString("en-IE");
|
|
176
|
+
if (data.buckets.length === 0 && data.rows.length < data.total) return `${data.rows.length.toLocaleString("en-IE")} of ${total} ${plural} in view`;
|
|
177
|
+
return `${total} ${plural} in view`;
|
|
178
|
+
};
|
|
119
179
|
//#endregion
|
|
120
180
|
export { MapDisplay };
|
|
@@ -8,13 +8,6 @@ const mapPoints = (rows, fields) => rows.flatMap((row) => {
|
|
|
8
8
|
...position
|
|
9
9
|
}] : [];
|
|
10
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
11
|
/**
|
|
19
12
|
* The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
|
|
20
13
|
* of it, else starts its own. A marker is at its first point, so markers do not move as others join them.
|
|
@@ -52,4 +45,4 @@ const stepSelection = (selection, step) => ({
|
|
|
52
45
|
index: ((selection.index + step) % selection.keys.length + selection.keys.length) % selection.keys.length
|
|
53
46
|
});
|
|
54
47
|
//#endregion
|
|
55
|
-
export { groupPoints, keptSelection, mapPoints,
|
|
48
|
+
export { groupPoints, keptSelection, mapPoints, stepSelection };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Badge } from "../../primitives/badge.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
3
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
|
-
import {
|
|
4
|
+
import { shownImage } from "../../live/file-values.js";
|
|
5
5
|
import { displayValue } from "../../live/display-value.js";
|
|
6
6
|
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
7
7
|
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { FileImage } from "../../live/file-image.js";
|
|
8
9
|
import { shownWidget } from "../field-types/shown-widget.js";
|
|
9
10
|
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
10
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
@@ -14,13 +15,14 @@ import { CardCarousel } from "./card-carousel.js";
|
|
|
14
15
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
15
16
|
//#region src/app/displays/record-card.tsx
|
|
16
17
|
const CardImage = ({ value }) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const image = shownImage(value, {
|
|
19
|
+
width: 320,
|
|
20
|
+
height: 240
|
|
21
|
+
});
|
|
19
22
|
return /* @__PURE__ */ jsx("span", {
|
|
20
23
|
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:
|
|
22
|
-
|
|
23
|
-
alt: "",
|
|
24
|
+
children: image && /* @__PURE__ */ jsx(FileImage, {
|
|
25
|
+
image,
|
|
24
26
|
loading: "lazy",
|
|
25
27
|
className: "size-full object-cover"
|
|
26
28
|
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { withLabels } from "../../live/relation-labels.js";
|
|
4
|
+
import { displayRow } from "./list-displays.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { areaFilter, requestZoom } from "../map/map-area.js";
|
|
7
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
//#region src/app/displays/use-map-records.ts
|
|
9
|
+
/**
|
|
10
|
+
* The records in the map's area through the list API's `:map`, labelled for the card, or buckets of them when it holds
|
|
11
|
+
* many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
|
|
12
|
+
* left is never shown.
|
|
13
|
+
*/
|
|
14
|
+
const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
|
|
15
|
+
const client = useClient();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
const { resources, views } = useAdminMeta();
|
|
18
|
+
const position = {
|
|
19
|
+
latitude: display.latitude,
|
|
20
|
+
longitude: display.longitude
|
|
21
|
+
};
|
|
22
|
+
const inside = area ? areaFilter(filter, position, area.bounds) : "";
|
|
23
|
+
const zoom = area ? requestZoom(area.zoom) : 0;
|
|
24
|
+
return useQuery({
|
|
25
|
+
queryKey: keys.map(model.name, `${position.latitude},${position.longitude}`, zoom, inside, orderBy),
|
|
26
|
+
enabled: area !== void 0,
|
|
27
|
+
placeholderData: keepPreviousData,
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const result = await client.map(model.name, {
|
|
30
|
+
...position,
|
|
31
|
+
zoom,
|
|
32
|
+
filter: inside,
|
|
33
|
+
orderBy
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
rows: (await withLabels(queryClient, client, resources, views, model, fields, result.items)).map((record) => displayRow(model, record)),
|
|
37
|
+
buckets: result.buckets,
|
|
38
|
+
total: result.totalSize
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { useMapRecords };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { usePagedRows } from "../../data-grid/use-paged-rows.js";
|
|
3
|
+
import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
|
|
4
|
+
import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
|
|
5
|
+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/displays/virtual-cards.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Cards in rows as wide as the list, scrolled like the table: only the rows in view are drawn, over a scroller that
|
|
10
|
+
* holds any number of records, and the records are loaded in the table's pages, so a change of width keeps the loaded
|
|
11
|
+
* pages and the first record in view. Every card in a width is as tall as the tallest drawn so far.
|
|
12
|
+
*/
|
|
13
|
+
const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
|
|
14
|
+
const { total } = source;
|
|
15
|
+
const [width, setWidth] = useState(0);
|
|
16
|
+
const columns = cardColumns(width);
|
|
17
|
+
const itemWidth = cardWidth(width, columns);
|
|
18
|
+
const [measured, setMeasured] = useState({
|
|
19
|
+
width: 0,
|
|
20
|
+
height: 0
|
|
21
|
+
});
|
|
22
|
+
const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
|
|
23
|
+
const rowHeight = itemHeight + 12;
|
|
24
|
+
const layout = {
|
|
25
|
+
columns,
|
|
26
|
+
rowHeight
|
|
27
|
+
};
|
|
28
|
+
const anchor = useRef({
|
|
29
|
+
layout,
|
|
30
|
+
top: 0
|
|
31
|
+
});
|
|
32
|
+
const changed = anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight;
|
|
33
|
+
const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
|
|
34
|
+
const scroll = useVirtualRows({
|
|
35
|
+
total: Math.ceil(total / columns),
|
|
36
|
+
rowHeight,
|
|
37
|
+
initialIndex: Math.floor(initialIndex / columns),
|
|
38
|
+
settle
|
|
39
|
+
});
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
if (changed) scroll.scrollTo(scroll.top);
|
|
42
|
+
anchor.current = {
|
|
43
|
+
layout,
|
|
44
|
+
top: scroll.top
|
|
45
|
+
};
|
|
46
|
+
if (!changed) onScrollIndex(Math.floor(scroll.top / rowHeight) * columns);
|
|
47
|
+
});
|
|
48
|
+
const range = recordRange(scroll.first, scroll.last, columns, total);
|
|
49
|
+
const getRow = usePagedRows({
|
|
50
|
+
total,
|
|
51
|
+
rowAt: source.rowAt,
|
|
52
|
+
loadRows: source.loadRows,
|
|
53
|
+
latencyMs: 0
|
|
54
|
+
}, range.start, range.end);
|
|
55
|
+
const bodyRef = useRef(null);
|
|
56
|
+
useLayoutEffect(() => {
|
|
57
|
+
const body = bodyRef.current;
|
|
58
|
+
if (!body) return;
|
|
59
|
+
const observer = new ResizeObserver(() => setWidth(body.clientWidth));
|
|
60
|
+
observer.observe(body);
|
|
61
|
+
setWidth(body.clientWidth);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, []);
|
|
64
|
+
const widthRef = useRef(itemWidth);
|
|
65
|
+
widthRef.current = itemWidth;
|
|
66
|
+
const [observer] = useState(() => new ResizeObserver((entries) => {
|
|
67
|
+
const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
|
|
68
|
+
const at = widthRef.current;
|
|
69
|
+
setMeasured((current) => current.width === at && current.height >= tallest ? current : {
|
|
70
|
+
width: at,
|
|
71
|
+
height: Math.max(tallest, current.width === at ? current.height : 0)
|
|
72
|
+
});
|
|
73
|
+
}));
|
|
74
|
+
useLayoutEffect(() => () => observer.disconnect(), [observer]);
|
|
75
|
+
const observe = useCallback((item) => {
|
|
76
|
+
if (!item) return;
|
|
77
|
+
observer.observe(item);
|
|
78
|
+
return () => observer.unobserve(item);
|
|
79
|
+
}, [observer]);
|
|
80
|
+
const firstShown = Math.min(total, Math.floor(scroll.top / rowHeight) * columns + 1);
|
|
81
|
+
const lastShown = Math.min(total, (Math.floor((scroll.top + Math.max(0, scroll.viewport - 1)) / rowHeight) + 1) * columns);
|
|
82
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
83
|
+
className: "flex min-h-0 flex-1 flex-col",
|
|
84
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
85
|
+
ref: scroll.ref,
|
|
86
|
+
onScroll: scroll.onScroll,
|
|
87
|
+
className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
|
|
88
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
89
|
+
style: { height: scroll.geometry.contentHeight || void 0 },
|
|
90
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
91
|
+
ref: bodyRef,
|
|
92
|
+
className: "sticky top-0 overflow-clip",
|
|
93
|
+
style: { height: scroll.viewport },
|
|
94
|
+
children: /* @__PURE__ */ jsx("ul", {
|
|
95
|
+
className: "relative",
|
|
96
|
+
"aria-label": label,
|
|
97
|
+
children: scroll.rows.flatMap(({ index: rowIndex, offset }) => Array.from({ length: columns }, (_, column) => {
|
|
98
|
+
const index = rowIndex * columns + column;
|
|
99
|
+
if (index >= total) return [];
|
|
100
|
+
const row = getRow(index);
|
|
101
|
+
const place = {
|
|
102
|
+
top: offset,
|
|
103
|
+
left: column * (itemWidth + 12),
|
|
104
|
+
width: itemWidth,
|
|
105
|
+
minHeight: itemHeight
|
|
106
|
+
};
|
|
107
|
+
if (!row) return /* @__PURE__ */ jsx("li", {
|
|
108
|
+
"aria-hidden": true,
|
|
109
|
+
className: "absolute grid grid-cols-[minmax(0,1fr)]",
|
|
110
|
+
style: place,
|
|
111
|
+
children: /* @__PURE__ */ jsx(Skeleton, { className: "h-full rounded-lg" })
|
|
112
|
+
}, `skeleton-${index}`);
|
|
113
|
+
return /* @__PURE__ */ jsx("li", {
|
|
114
|
+
ref: observe,
|
|
115
|
+
"aria-posinset": index + 1,
|
|
116
|
+
"aria-setsize": total,
|
|
117
|
+
className: "absolute grid grid-cols-[minmax(0,1fr)]",
|
|
118
|
+
style: place,
|
|
119
|
+
children: children(row)
|
|
120
|
+
}, row.id);
|
|
121
|
+
}))
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
126
|
+
className: "py-3 text-xs tabular-nums text-muted-foreground",
|
|
127
|
+
children: [
|
|
128
|
+
firstShown.toLocaleString("en-IE"),
|
|
129
|
+
"–",
|
|
130
|
+
lastShown.toLocaleString("en-IE"),
|
|
131
|
+
" of ",
|
|
132
|
+
total.toLocaleString("en-IE")
|
|
133
|
+
]
|
|
134
|
+
})]
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
export { VirtualCards };
|