@protobase/ui 0.19.2 → 0.19.4
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/map-display.js +3 -1
- package/dist/app/gallery/detail-fields.js +12 -0
- package/dist/app/gallery/gallery-layout.js +3 -1
- package/dist/app/gallery/gallery-section.js +7 -4
- package/dist/app/gallery/lightbox.js +38 -44
- package/dist/app/gallery/photo-details.js +21 -28
- package/dist/app/map/map-layer-choice.js +11 -0
- package/dist/app/map/map-layer-switch.js +55 -0
- package/dist/app/map/map-section.js +3 -1
- package/dist/app/map/position-areas.js +4 -2
- package/dist/app/map/record-map.js +16 -11
- package/dist/app/map/records-map.js +7 -2
- package/dist/app/pages/record-actions.js +32 -6
- package/dist/app/record-mode/record-mode.js +12 -7
- package/dist/app/record-page.js +27 -38
- package/dist/data/meta-data.d.ts +3 -2
- package/dist/format/date.js +2 -15
- package/dist/package.js +1 -1
- package/dist/record-view/action-rail.js +22 -11
- package/dist/record-view/record-header.js +5 -5
- package/dist/shell/assets/app-BYFiTXK6.js +66 -0
- package/dist/shell/assets/{index-Crd6_WsY.js → index-CEvfMgFJ.js} +1 -1
- package/dist/shell/assets/index-CiGP6kle.css +2 -0
- package/dist/shell/assets/maplibre-D6zfLfTV.js +814 -0
- package/dist/shell/assets/record-map-B6_rbCcG.js +1 -0
- package/dist/shell/assets/records-map-bQbuIc13.js +1 -0
- package/dist/shell/assets/{shared-protobase-ui-DvvdY2OO.js → shared-protobase-ui-8pJgwjfc.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-Rbouc_gR.js +0 -66
- package/dist/shell/assets/index-DaFzUWmg.css +0 -2
- package/dist/shell/assets/maplibre-Gu5hHDDK.js +0 -814
- package/dist/shell/assets/record-map-DQ0oyZff.js +0 -1
- package/dist/shell/assets/records-map-Dp97bL3p.js +0 -1
|
@@ -25,7 +25,8 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
|
|
|
25
25
|
* elsewhere clears it. A marker and a card whose record's position is approximate say so.
|
|
26
26
|
*/
|
|
27
27
|
const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true, flush = false }) => {
|
|
28
|
-
const
|
|
28
|
+
const maps = useAdminMeta().maps;
|
|
29
|
+
const style = maps?.style;
|
|
29
30
|
const [chosen, setChosen] = useState();
|
|
30
31
|
const [area, setArea] = useState();
|
|
31
32
|
const settled = useDebounced(area, settleMs);
|
|
@@ -70,6 +71,7 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
|
|
|
70
71
|
fallback: null,
|
|
71
72
|
children: /* @__PURE__ */ jsx(RecordsMap, {
|
|
72
73
|
style,
|
|
74
|
+
layers: maps?.layers,
|
|
73
75
|
initial: at,
|
|
74
76
|
points,
|
|
75
77
|
buckets,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/app/gallery/detail-fields.ts
|
|
2
|
+
/**
|
|
3
|
+
* The fields of a photo's details: those its record page shows in its sections, or without a layout every field but its
|
|
4
|
+
* key and tenant, less sensitive ones and those the gallery already shows: `omit`, such as the photo's file and the
|
|
5
|
+
* field pointing at the gallery's record.
|
|
6
|
+
*/
|
|
7
|
+
const detailFields = (model, view, omit) => {
|
|
8
|
+
const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
|
|
9
|
+
return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive && !omit.includes(name));
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { detailFields };
|
|
@@ -13,5 +13,7 @@ const galleryTiles = (count) => {
|
|
|
13
13
|
const swipeStep = (dx, dy) => Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy) ? 0 : dx < 0 ? 1 : -1;
|
|
14
14
|
/** A key's step: the right arrow moves to the next photo, the left arrow to the previous one. */
|
|
15
15
|
const keyStep = (key) => key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
|
|
16
|
+
/** The photo a step from `index` lands on among `count`: it stops at the first and the last rather than wrapping around. */
|
|
17
|
+
const steppedPhoto = (index, by, count) => Math.min(Math.max(index + by, 0), Math.max(count - 1, 0));
|
|
16
18
|
//#endregion
|
|
17
|
-
export { galleryTiles, keyStep, swipeStep };
|
|
19
|
+
export { galleryTiles, keyStep, steppedPhoto, swipeStep };
|
|
@@ -8,6 +8,7 @@ import { FileImage } from "../../live/file-image.js";
|
|
|
8
8
|
import { Section } from "../../record-view/section.js";
|
|
9
9
|
import { galleryTiles } from "./gallery-layout.js";
|
|
10
10
|
import { Lightbox } from "./lightbox.js";
|
|
11
|
+
import { detailFields } from "./detail-fields.js";
|
|
11
12
|
import { PhotoDetails } from "./photo-details.js";
|
|
12
13
|
import { useGalleryPhotos } from "./use-gallery-photos.js";
|
|
13
14
|
import { useState } from "react";
|
|
@@ -47,7 +48,7 @@ const Tile = ({ photo, large }) => {
|
|
|
47
48
|
/**
|
|
48
49
|
* A record's photos as tiles, one large and the next ones smaller, the last counting those not drawn ("+N"); a mobile
|
|
49
50
|
* screen draws the large one with the number of photos. Each tile opens the lightbox at its photo. A photo held by a
|
|
50
|
-
* record of a resource the user may read
|
|
51
|
+
* record of a resource the user may read has its details beside it there, as the record API answers them. A record
|
|
51
52
|
* without photos has no gallery.
|
|
52
53
|
*/
|
|
53
54
|
const GallerySection = ({ item, record, recordKey }) => {
|
|
@@ -71,13 +72,15 @@ const GallerySection = ({ item, record, recordKey }) => {
|
|
|
71
72
|
const { shown, more } = galleryTiles(count);
|
|
72
73
|
const holder = photos.holder;
|
|
73
74
|
const holderView = holder && views[holder.name];
|
|
74
|
-
const
|
|
75
|
+
const fields = holder ? detailFields(holder, holderView, [item.photos?.image, item.photos?.field].filter((name) => name !== void 0)) : [];
|
|
76
|
+
const readable = holder !== void 0 && photoPermissions.read !== false && fields.length > 0;
|
|
75
77
|
const details = (index) => {
|
|
76
78
|
const key = photos.items[index]?.key;
|
|
77
79
|
return readable && key !== void 0 ? /* @__PURE__ */ jsx(PhotoDetails, {
|
|
78
80
|
model: holder,
|
|
79
81
|
view: holderView,
|
|
80
|
-
photoKey: key
|
|
82
|
+
photoKey: key,
|
|
83
|
+
fields
|
|
81
84
|
}, key) : void 0;
|
|
82
85
|
};
|
|
83
86
|
return /* @__PURE__ */ jsxs(Section, {
|
|
@@ -116,7 +119,7 @@ const GallerySection = ({ item, record, recordKey }) => {
|
|
|
116
119
|
onIndex: setOpen,
|
|
117
120
|
onClose: () => setOpen(void 0),
|
|
118
121
|
details,
|
|
119
|
-
|
|
122
|
+
detailsTitle: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
|
|
120
123
|
})]
|
|
121
124
|
});
|
|
122
125
|
};
|
|
@@ -2,25 +2,27 @@ import { cn } from "../../lib/cn.js";
|
|
|
2
2
|
import { useFocusTrap } from "../../lib/use-focus-trap.js";
|
|
3
3
|
import { shownImage } from "../../live/file-values.js";
|
|
4
4
|
import { FileImage } from "../../live/file-image.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ChevronLeft, ChevronRight, Info, X } from "lucide-react";
|
|
5
|
+
import { keyStep, steppedPhoto, swipeStep } from "./gallery-layout.js";
|
|
6
|
+
import { useRef } from "react";
|
|
7
|
+
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
9
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
9
|
//#region src/app/gallery/lightbox.tsx
|
|
11
10
|
const control = "flex size-10 shrink-0 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm transition-colors hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40";
|
|
12
11
|
/**
|
|
13
12
|
* Photos one at a time at full size over the page: a modal dialog with a counter, arrows and keys (left, right, Escape)
|
|
14
|
-
* that step through them and
|
|
15
|
-
* read,
|
|
13
|
+
* that step through them, stopping at the first and the last, a sideways swipe on a touch screen, and, for a photo with details the user may
|
|
14
|
+
* read, those details in a sidebar beside it, or below it on a mobile screen, where the dialog scrolls down to them. The
|
|
15
|
+
* page does not scroll behind it, and focus returns to what opened it.
|
|
16
16
|
*/
|
|
17
|
-
const Lightbox = ({ title, photos, index, onIndex, onClose, details,
|
|
17
|
+
const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitle = "Details" }) => {
|
|
18
18
|
const panel = useRef(null);
|
|
19
19
|
const start = useRef(void 0);
|
|
20
|
-
const [showDetails, setShowDetails] = useState(false);
|
|
21
20
|
useFocusTrap(panel, true, onClose);
|
|
22
21
|
const count = photos.length;
|
|
23
|
-
const step = (by) =>
|
|
22
|
+
const step = (by) => {
|
|
23
|
+
const next = steppedPhoto(index, by, count);
|
|
24
|
+
if (next !== index) onIndex(next);
|
|
25
|
+
};
|
|
24
26
|
const image = shownImage(photos[index], {
|
|
25
27
|
width: 1600,
|
|
26
28
|
height: 1200
|
|
@@ -50,39 +52,26 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
50
52
|
"aria-modal": "true",
|
|
51
53
|
"aria-label": title,
|
|
52
54
|
onKeyDown: key,
|
|
53
|
-
className: "fixed inset-0 z-[90] flex flex-col bg-backdrop outline-none",
|
|
54
|
-
children: [
|
|
55
|
-
|
|
55
|
+
className: "fixed inset-0 z-[90] flex flex-col overflow-y-auto overscroll-contain bg-backdrop outline-none md:flex-row",
|
|
56
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
57
|
+
className: cn("flex shrink-0 flex-col md:sticky md:top-0 md:h-dvh md:min-w-0 md:flex-1", shownDetails === void 0 ? "h-dvh" : "h-[85svh]"),
|
|
58
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
56
59
|
className: "flex shrink-0 items-center justify-between gap-2 p-3",
|
|
57
60
|
children: [/* @__PURE__ */ jsx("span", {
|
|
58
61
|
className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
|
|
59
62
|
"aria-live": "polite",
|
|
60
63
|
children: `${index + 1} / ${count}`
|
|
61
|
-
}), /* @__PURE__ */
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
className: "size-4",
|
|
71
|
-
"aria-hidden": true
|
|
72
|
-
})
|
|
73
|
-
}), /* @__PURE__ */ jsx("button", {
|
|
74
|
-
type: "button",
|
|
75
|
-
className: control,
|
|
76
|
-
"aria-label": "Close",
|
|
77
|
-
onClick: onClose,
|
|
78
|
-
children: /* @__PURE__ */ jsx(X, {
|
|
79
|
-
className: "size-4",
|
|
80
|
-
"aria-hidden": true
|
|
81
|
-
})
|
|
82
|
-
})]
|
|
64
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
65
|
+
type: "button",
|
|
66
|
+
className: control,
|
|
67
|
+
"aria-label": "Close",
|
|
68
|
+
onClick: onClose,
|
|
69
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
70
|
+
className: "size-4",
|
|
71
|
+
"aria-hidden": true
|
|
72
|
+
})
|
|
83
73
|
})]
|
|
84
|
-
}),
|
|
85
|
-
/* @__PURE__ */ jsxs("div", {
|
|
74
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
86
75
|
className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
|
|
87
76
|
onPointerDown: down,
|
|
88
77
|
onPointerUp: up,
|
|
@@ -92,8 +81,9 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
92
81
|
className: "max-h-full max-w-full select-none object-contain"
|
|
93
82
|
}, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
94
83
|
type: "button",
|
|
95
|
-
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2"),
|
|
84
|
+
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
96
85
|
"aria-label": "Previous photo",
|
|
86
|
+
"aria-disabled": index === 0,
|
|
97
87
|
onClick: () => step(-1),
|
|
98
88
|
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
99
89
|
className: "size-5",
|
|
@@ -101,20 +91,24 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
101
91
|
})
|
|
102
92
|
}), /* @__PURE__ */ jsx("button", {
|
|
103
93
|
type: "button",
|
|
104
|
-
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2"),
|
|
94
|
+
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
105
95
|
"aria-label": "Next photo",
|
|
96
|
+
"aria-disabled": index === count - 1,
|
|
106
97
|
onClick: () => step(1),
|
|
107
98
|
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
108
99
|
className: "size-5",
|
|
109
100
|
"aria-hidden": true
|
|
110
101
|
})
|
|
111
102
|
})] })]
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
})]
|
|
104
|
+
}), shownDetails !== void 0 && /* @__PURE__ */ jsxs("aside", {
|
|
105
|
+
"aria-label": detailsTitle,
|
|
106
|
+
className: "shrink-0 border-t border-border bg-background p-5 text-foreground md:min-h-dvh md:w-[360px] md:border-l md:border-t-0",
|
|
107
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
108
|
+
className: "mb-3 text-[15px] font-semibold",
|
|
109
|
+
children: detailsTitle
|
|
110
|
+
}), shownDetails]
|
|
111
|
+
})]
|
|
118
112
|
});
|
|
119
113
|
};
|
|
120
114
|
//#endregion
|
|
@@ -6,17 +6,13 @@ import { ErrorBanner } from "../error-banner.js";
|
|
|
6
6
|
import { fieldAccess } from "../permissions.js";
|
|
7
7
|
import { SpecTable } from "../../record-view/spec-table.js";
|
|
8
8
|
import { FieldValue } from "../pages/field-value.js";
|
|
9
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
10
|
//#region src/app/gallery/photo-details.tsx
|
|
11
|
-
const detailFields = (model, view) => {
|
|
12
|
-
const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
|
|
13
|
-
return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive);
|
|
14
|
-
};
|
|
15
11
|
/**
|
|
16
|
-
* A photo's record, as the record API answers the user, so the server decides what they may read:
|
|
17
|
-
*
|
|
12
|
+
* A photo's record, as the record API answers the user, so the server decides what they may read: its `fields` (see
|
|
13
|
+
* `detailFields`), as label/value rows, and a link to its page.
|
|
18
14
|
*/
|
|
19
|
-
const PhotoDetails = ({ model, view, photoKey }) => {
|
|
15
|
+
const PhotoDetails = ({ model, view, photoKey, fields }) => {
|
|
20
16
|
const record = useRecord(model.name, photoKey);
|
|
21
17
|
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
22
18
|
if (record.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" });
|
|
@@ -24,26 +20,23 @@ const PhotoDetails = ({ model, view, photoKey }) => {
|
|
|
24
20
|
title: `Could not load the ${singular}`,
|
|
25
21
|
error: record.error
|
|
26
22
|
});
|
|
27
|
-
const shown =
|
|
28
|
-
return /* @__PURE__ */ jsxs(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
children: `Open ${singular}`
|
|
45
|
-
})]
|
|
46
|
-
});
|
|
23
|
+
const shown = fields.filter((name) => fieldAccess(record.data.permissions, name) !== "hidden");
|
|
24
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SpecTable, { rows: shown.map((name) => ({
|
|
25
|
+
label: view?.fields[name]?.label ?? humanize(name),
|
|
26
|
+
value: /* @__PURE__ */ jsx(FieldValue, {
|
|
27
|
+
model,
|
|
28
|
+
field: model.fields[name],
|
|
29
|
+
value: record.data.record[name]
|
|
30
|
+
})
|
|
31
|
+
})) }), /* @__PURE__ */ jsx(AppLink, {
|
|
32
|
+
to: {
|
|
33
|
+
kind: "record",
|
|
34
|
+
resource: model.name,
|
|
35
|
+
key: photoKey
|
|
36
|
+
},
|
|
37
|
+
className: "mt-3 inline-block text-[13px] font-medium text-primary-text hover:underline",
|
|
38
|
+
children: `Open ${singular}`
|
|
39
|
+
})] });
|
|
47
40
|
};
|
|
48
41
|
//#endregion
|
|
49
42
|
export { PhotoDetails };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { guarded } from "../../lib/guarded.js";
|
|
2
|
+
//#region src/app/map/map-layer-choice.ts
|
|
3
|
+
const mapLayerStorageKey = "protobase:map-layer";
|
|
4
|
+
/** The layer the browser last chose, if the app still offers it: else the style's own map, which is the default. */
|
|
5
|
+
const chosenMapLayer = (layers, id) => layers.find((layer) => layer.id === id);
|
|
6
|
+
/** The id of the layer chosen in this browser, if any; unavailable storage reads as the default map. */
|
|
7
|
+
const readMapLayer = (storage) => guarded(() => storage.getItem(mapLayerStorageKey), null);
|
|
8
|
+
/** Remembers a layer by its id, and the default map by forgetting the choice, so the default costs no storage. */
|
|
9
|
+
const writeMapLayer = (storage, id) => guarded(() => id === void 0 ? storage.removeItem(mapLayerStorageKey) : storage.setItem(mapLayerStorageKey, id), void 0);
|
|
10
|
+
//#endregion
|
|
11
|
+
export { chosenMapLayer, mapLayerStorageKey, readMapLayer, writeMapLayer };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Segmented } from "../../primitives/segmented.js";
|
|
2
|
+
import { chosenMapLayer, readMapLayer, writeMapLayer } from "./map-layer-choice.js";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Layer, Source, useMap } from "react-map-gl/maplibre";
|
|
6
|
+
//#region src/app/map/map-layer-switch.tsx
|
|
7
|
+
const styleValue = "";
|
|
8
|
+
/**
|
|
9
|
+
* Inside a map, a switch in its top left corner between the style's map and each of `layers`, and the chosen layer's
|
|
10
|
+
* tiles drawn over the style's, under `below` when the map has that layer, such as the areas around records. The
|
|
11
|
+
* layer's credit joins the map's attribution while it shows. Outside its bounds the style's map shows through. The
|
|
12
|
+
* choice is remembered in this browser, for every map. Nothing without layers.
|
|
13
|
+
*/
|
|
14
|
+
const MapLayerSwitch = ({ layers, below }) => {
|
|
15
|
+
const [chosenId, setChosenId] = useState(() => readMapLayer(localStorage));
|
|
16
|
+
const { current: map } = useMap();
|
|
17
|
+
if (!layers || layers.length === 0) return null;
|
|
18
|
+
const chosen = chosenMapLayer(layers, chosenId);
|
|
19
|
+
const choose = (value) => {
|
|
20
|
+
const id = value === styleValue ? void 0 : value;
|
|
21
|
+
writeMapLayer(localStorage, id);
|
|
22
|
+
setChosenId(id ?? null);
|
|
23
|
+
};
|
|
24
|
+
const beforeId = below && map?.getLayer(below) ? below : void 0;
|
|
25
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [chosen && /* @__PURE__ */ jsx(Source, {
|
|
26
|
+
id: `map-layer-${chosen.id}`,
|
|
27
|
+
type: "raster",
|
|
28
|
+
tiles: [chosen.tiles],
|
|
29
|
+
tileSize: 256,
|
|
30
|
+
attribution: chosen.attribution,
|
|
31
|
+
maxzoom: chosen.maxzoom ?? 22,
|
|
32
|
+
...chosen.bounds && { bounds: chosen.bounds },
|
|
33
|
+
children: /* @__PURE__ */ jsx(Layer, {
|
|
34
|
+
id: `map-layer-${chosen.id}`,
|
|
35
|
+
type: "raster",
|
|
36
|
+
...beforeId && { beforeId }
|
|
37
|
+
})
|
|
38
|
+
}, chosen.id), /* @__PURE__ */ jsx("div", {
|
|
39
|
+
className: "absolute left-2.5 top-2.5 z-10 rounded-md shadow",
|
|
40
|
+
children: /* @__PURE__ */ jsx(Segmented, {
|
|
41
|
+
label: "Map layer",
|
|
42
|
+
value: chosen?.id ?? styleValue,
|
|
43
|
+
onChange: choose,
|
|
44
|
+
options: [{
|
|
45
|
+
value: styleValue,
|
|
46
|
+
label: "Map"
|
|
47
|
+
}, ...layers.map((layer) => ({
|
|
48
|
+
value: layer.id,
|
|
49
|
+
label: layer.label
|
|
50
|
+
}))]
|
|
51
|
+
})
|
|
52
|
+
})] });
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { MapLayerSwitch };
|
|
@@ -12,7 +12,8 @@ const defaultZoom = 15;
|
|
|
12
12
|
* `/meta` names. Without a style, or for a record without a position, there is nothing to draw and no section.
|
|
13
13
|
*/
|
|
14
14
|
const MapSection = ({ item, record }) => {
|
|
15
|
-
const
|
|
15
|
+
const maps = useAdminMeta().maps;
|
|
16
|
+
const style = maps?.style;
|
|
16
17
|
const position = recordPosition(record, item);
|
|
17
18
|
if (!style || !position) return null;
|
|
18
19
|
const map = /* @__PURE__ */ jsx("div", {
|
|
@@ -21,6 +22,7 @@ const MapSection = ({ item, record }) => {
|
|
|
21
22
|
fallback: null,
|
|
22
23
|
children: /* @__PURE__ */ jsx(RecordMap, {
|
|
23
24
|
style,
|
|
25
|
+
layers: maps?.layers,
|
|
24
26
|
position,
|
|
25
27
|
zoom: item.zoom ?? defaultZoom
|
|
26
28
|
}, `${position.latitude},${position.longitude}`)
|
|
@@ -2,6 +2,8 @@ import { areaFeatures, fadeIn } from "./area-features.js";
|
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Layer, Source } from "react-map-gl/maplibre";
|
|
4
4
|
//#region src/app/map/position-areas.tsx
|
|
5
|
+
/** The areas' lowest layer, which a layer of tiles goes under. */
|
|
6
|
+
const areaLayerId = "position-area-fills";
|
|
5
7
|
const polygons = [
|
|
6
8
|
"==",
|
|
7
9
|
["get", "kind"],
|
|
@@ -38,7 +40,7 @@ const PositionAreas = ({ points, selected, colors }) => {
|
|
|
38
40
|
data,
|
|
39
41
|
children: [
|
|
40
42
|
/* @__PURE__ */ jsx(Layer, {
|
|
41
|
-
id:
|
|
43
|
+
id: areaLayerId,
|
|
42
44
|
type: "fill",
|
|
43
45
|
filter: polygons,
|
|
44
46
|
paint: {
|
|
@@ -75,4 +77,4 @@ const PositionAreas = ({ points, selected, colors }) => {
|
|
|
75
77
|
});
|
|
76
78
|
};
|
|
77
79
|
//#endregion
|
|
78
|
-
export { PositionAreas };
|
|
80
|
+
export { PositionAreas, areaLayerId };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MapLayerSwitch } from "./map-layer-switch.js";
|
|
1
2
|
import { mapLib } from "./maplibre.js";
|
|
2
3
|
import { MapPin } from "lucide-react";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -5,9 +6,9 @@ import { Map as Map$1, Marker, NavigationControl } from "react-map-gl/maplibre";
|
|
|
5
6
|
//#region src/app/map/record-map.tsx
|
|
6
7
|
/**
|
|
7
8
|
* A map of `style` centred on `position` at `zoom`, with a pin on it. It pans and zooms with two fingers on a phone and
|
|
8
|
-
* with Ctrl or Cmd and the wheel, so scrolling the page past it does not move it.
|
|
9
|
+
* with Ctrl or Cmd and the wheel, so scrolling the page past it does not move it. With `layers`, it switches to them.
|
|
9
10
|
*/
|
|
10
|
-
const RecordMap = ({ style, position, zoom }) => /* @__PURE__ */ jsxs(Map$1, {
|
|
11
|
+
const RecordMap = ({ style, layers, position, zoom }) => /* @__PURE__ */ jsxs(Map$1, {
|
|
11
12
|
mapLib,
|
|
12
13
|
mapStyle: style,
|
|
13
14
|
initialViewState: {
|
|
@@ -19,16 +20,20 @@ const RecordMap = ({ style, position, zoom }) => /* @__PURE__ */ jsxs(Map$1, {
|
|
|
19
20
|
width: "100%",
|
|
20
21
|
height: "100%"
|
|
21
22
|
},
|
|
22
|
-
children: [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
children: [
|
|
24
|
+
/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
|
|
25
|
+
/* @__PURE__ */ jsx(MapLayerSwitch, { layers }),
|
|
26
|
+
/* @__PURE__ */ jsx(Marker, {
|
|
27
|
+
latitude: position.latitude,
|
|
28
|
+
longitude: position.longitude,
|
|
29
|
+
anchor: "bottom",
|
|
30
|
+
children: /* @__PURE__ */ jsx(MapPin, {
|
|
31
|
+
"aria-hidden": true,
|
|
32
|
+
className: "size-8 fill-primary text-primary-foreground drop-shadow",
|
|
33
|
+
strokeWidth: 1.5
|
|
34
|
+
})
|
|
30
35
|
})
|
|
31
|
-
|
|
36
|
+
]
|
|
32
37
|
});
|
|
33
38
|
//#endregion
|
|
34
39
|
export { RecordMap };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { groupPoints } from "../displays/map-groups.js";
|
|
3
|
+
import { MapLayerSwitch } from "./map-layer-switch.js";
|
|
3
4
|
import { mapLib } from "./maplibre.js";
|
|
4
|
-
import { PositionAreas } from "./position-areas.js";
|
|
5
|
+
import { PositionAreas, areaLayerId } from "./position-areas.js";
|
|
5
6
|
import { themeColor } from "./theme-color.js";
|
|
6
7
|
import { useEffect, useRef, useState } from "react";
|
|
7
8
|
import { MapPin } from "lucide-react";
|
|
@@ -32,7 +33,7 @@ const corners = (bounds) => [[bounds.west, bounds.south], [bounds.east, bounds.n
|
|
|
32
33
|
* marker selects its records; choosing the map elsewhere clears the selection. A chosen marker the covered bottom would
|
|
33
34
|
* hide moves up into the open part of the map.
|
|
34
35
|
*/
|
|
35
|
-
const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
|
|
36
|
+
const RecordsMap = ({ style, layers, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
|
|
36
37
|
const ref = useRef(null);
|
|
37
38
|
const [groups, setGroups] = useState([]);
|
|
38
39
|
const [colors, setColors] = useState();
|
|
@@ -117,6 +118,10 @@ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, se
|
|
|
117
118
|
onClick: onClear,
|
|
118
119
|
children: [
|
|
119
120
|
/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
|
|
121
|
+
/* @__PURE__ */ jsx(MapLayerSwitch, {
|
|
122
|
+
layers,
|
|
123
|
+
below: areaLayerId
|
|
124
|
+
}),
|
|
120
125
|
colors && /* @__PURE__ */ jsx(PositionAreas, {
|
|
121
126
|
points,
|
|
122
127
|
selected,
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { useAppAddress } from "../use-app-address.js";
|
|
1
|
+
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
2
2
|
import { ActionRail } from "../../record-view/action-rail.js";
|
|
3
3
|
import { ConfirmAction } from "./confirm-action.js";
|
|
4
4
|
import { useActionRunner } from "./use-run-action.js";
|
|
5
5
|
import { actionIcon } from "../action-icon.js";
|
|
6
6
|
import { useState } from "react";
|
|
7
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
7
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
9
|
//#region src/app/pages/record-actions.tsx
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
|
|
12
|
+
* actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
|
|
13
|
+
* `onDelete`, unless a named action deletes already.
|
|
14
|
+
*/
|
|
15
|
+
const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
|
|
11
16
|
const { go } = useAppAddress();
|
|
12
17
|
const scope = {
|
|
13
18
|
model,
|
|
@@ -21,14 +26,35 @@ const RecordActions = ({ model, stored, recordKey }) => {
|
|
|
21
26
|
resource: model.name
|
|
22
27
|
}, { replace: true }));
|
|
23
28
|
const [asking, setAsking] = useState();
|
|
24
|
-
|
|
25
|
-
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions: runner.actions.map((action) => ({
|
|
29
|
+
const named = runner.actions.map((action) => ({
|
|
26
30
|
name: action.label,
|
|
27
31
|
icon: actionIcon(action.icon),
|
|
28
32
|
blocked: runner.blocked(action),
|
|
29
33
|
running: runner.running === action.name,
|
|
34
|
+
...action.run?.kind === "delete" && { tone: "danger" },
|
|
30
35
|
onRun: () => action.confirm ? setAsking(action) : void runner.run(action)
|
|
31
|
-
}))
|
|
36
|
+
}));
|
|
37
|
+
const deletes = runner.actions.some((action) => action.run?.kind === "delete");
|
|
38
|
+
const actions = [
|
|
39
|
+
...edit ? [{
|
|
40
|
+
name: "Edit",
|
|
41
|
+
icon: Pencil,
|
|
42
|
+
render: ({ className, children }) => /* @__PURE__ */ jsx(AppLink, {
|
|
43
|
+
to: edit,
|
|
44
|
+
className,
|
|
45
|
+
children
|
|
46
|
+
})
|
|
47
|
+
}] : [],
|
|
48
|
+
...named,
|
|
49
|
+
...onDelete && !deletes ? [{
|
|
50
|
+
name: "Delete",
|
|
51
|
+
icon: Trash2,
|
|
52
|
+
tone: "danger",
|
|
53
|
+
onRun: onDelete
|
|
54
|
+
}] : []
|
|
55
|
+
];
|
|
56
|
+
if (actions.length === 0) return null;
|
|
57
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
|
|
32
58
|
action: asking,
|
|
33
59
|
label: asking.label,
|
|
34
60
|
danger: asking.run?.kind === "delete",
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
//#region src/app/record-mode/record-mode.ts
|
|
2
2
|
/**
|
|
3
3
|
* What the record page shows a user, by the view's `mode`, whether they may change the record and whether its address
|
|
4
|
-
* asks for the form (`edit`).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `editing` whether the record is being edited, which decides the help shown only while editing.
|
|
4
|
+
* asks for the form (`edit`). A user who may not change the record gets the read view in every mode, at any address.
|
|
5
|
+
* To one who may, always editable (`edit`, the default) shows the form and ignores `edit`; `read` and `locked` show the
|
|
6
|
+
* read view or the locked form until the address asks for the form. `offersEdit` is whether the page links to the
|
|
7
|
+
* form; `editing` whether the record is being edited, which decides the help shown only while editing.
|
|
8
8
|
*/
|
|
9
9
|
const recordShown = (mode, canUpdate, edit) => {
|
|
10
|
+
if (!canUpdate) return {
|
|
11
|
+
shown: "read",
|
|
12
|
+
offersEdit: false,
|
|
13
|
+
editing: false
|
|
14
|
+
};
|
|
10
15
|
if (mode === void 0 || mode === "edit") return {
|
|
11
|
-
shown:
|
|
16
|
+
shown: "form",
|
|
12
17
|
offersEdit: false,
|
|
13
|
-
editing:
|
|
18
|
+
editing: true
|
|
14
19
|
};
|
|
15
|
-
if (
|
|
20
|
+
if (edit) return {
|
|
16
21
|
shown: "form",
|
|
17
22
|
offersEdit: false,
|
|
18
23
|
editing: true
|