@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.
Files changed (35) hide show
  1. package/dist/app/displays/map-display.js +3 -1
  2. package/dist/app/gallery/detail-fields.js +12 -0
  3. package/dist/app/gallery/gallery-layout.js +3 -1
  4. package/dist/app/gallery/gallery-section.js +7 -4
  5. package/dist/app/gallery/lightbox.js +38 -44
  6. package/dist/app/gallery/photo-details.js +21 -28
  7. package/dist/app/map/map-layer-choice.js +11 -0
  8. package/dist/app/map/map-layer-switch.js +55 -0
  9. package/dist/app/map/map-section.js +3 -1
  10. package/dist/app/map/position-areas.js +4 -2
  11. package/dist/app/map/record-map.js +16 -11
  12. package/dist/app/map/records-map.js +7 -2
  13. package/dist/app/pages/record-actions.js +32 -6
  14. package/dist/app/record-mode/record-mode.js +12 -7
  15. package/dist/app/record-page.js +27 -38
  16. package/dist/data/meta-data.d.ts +3 -2
  17. package/dist/format/date.js +2 -15
  18. package/dist/package.js +1 -1
  19. package/dist/record-view/action-rail.js +22 -11
  20. package/dist/record-view/record-header.js +5 -5
  21. package/dist/shell/assets/app-BYFiTXK6.js +66 -0
  22. package/dist/shell/assets/{index-Crd6_WsY.js → index-CEvfMgFJ.js} +1 -1
  23. package/dist/shell/assets/index-CiGP6kle.css +2 -0
  24. package/dist/shell/assets/maplibre-D6zfLfTV.js +814 -0
  25. package/dist/shell/assets/record-map-B6_rbCcG.js +1 -0
  26. package/dist/shell/assets/records-map-bQbuIc13.js +1 -0
  27. package/dist/shell/assets/{shared-protobase-ui-DvvdY2OO.js → shared-protobase-ui-8pJgwjfc.js} +1 -1
  28. package/dist/shell/index.html +4 -4
  29. package/dist/shell/protobase.shell.json +1 -1
  30. package/package.json +4 -4
  31. package/dist/shell/assets/app-Rbouc_gR.js +0 -66
  32. package/dist/shell/assets/index-DaFzUWmg.css +0 -2
  33. package/dist/shell/assets/maplibre-Gu5hHDDK.js +0 -814
  34. package/dist/shell/assets/record-map-DQ0oyZff.js +0 -1
  35. 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 style = useAdminMeta().maps?.style;
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 gets a details button there, whose details the record API answers. A record
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 readable = holder !== void 0 && photoPermissions.read !== false;
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
- detailsLabel: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
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 { shownPhoto } from "../displays/card-photos.js";
6
- import { keyStep, swipeStep } from "./gallery-layout.js";
7
- import { useRef, useState } from "react";
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 wrap around, a sideways swipe on a touch screen, and, for a photo with details the user may
15
- * read, a button that shows them under the photo. The page does not scroll behind it, and focus returns to what opened it.
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, detailsLabel = "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) => by !== 0 && onIndex(shownPhoto(index + by, count));
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
- /* @__PURE__ */ jsxs("div", {
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__ */ jsxs("span", {
62
- className: "flex items-center gap-2",
63
- children: [shownDetails !== void 0 && /* @__PURE__ */ jsx("button", {
64
- type: "button",
65
- className: cn(control, showDetails && "bg-background ring-2 ring-primary/40"),
66
- "aria-label": detailsLabel,
67
- "aria-pressed": showDetails,
68
- onClick: () => setShowDetails(!showDetails),
69
- children: /* @__PURE__ */ jsx(Info, {
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
- showDetails && shownDetails !== void 0 && /* @__PURE__ */ jsx("div", {
114
- className: "shrink-0 border-t border-border bg-background p-4 text-foreground",
115
- children: shownDetails
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: the fields its
17
- * record page shows, as label/value rows, and a link to that page.
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 = detailFields(model, view).filter((name) => fieldAccess(record.data.permissions, name) !== "hidden");
28
- return /* @__PURE__ */ jsxs("div", {
29
- className: "mx-auto max-w-2xl",
30
- children: [/* @__PURE__ */ jsx(SpecTable, { rows: shown.map((name) => ({
31
- label: view?.fields[name]?.label ?? humanize(name),
32
- value: /* @__PURE__ */ jsx(FieldValue, {
33
- model,
34
- field: model.fields[name],
35
- value: record.data.record[name]
36
- })
37
- })) }), /* @__PURE__ */ jsx(AppLink, {
38
- to: {
39
- kind: "record",
40
- resource: model.name,
41
- key: photoKey
42
- },
43
- className: "mt-3 inline-block text-[13px] font-medium text-primary-text hover:underline",
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 style = useAdminMeta().maps?.style;
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: "position-area-fills",
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: [/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }), /* @__PURE__ */ jsx(Marker, {
23
- latitude: position.latitude,
24
- longitude: position.longitude,
25
- anchor: "bottom",
26
- children: /* @__PURE__ */ jsx(MapPin, {
27
- "aria-hidden": true,
28
- className: "size-8 fill-primary text-primary-foreground drop-shadow",
29
- strokeWidth: 1.5
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
- /** The record page's named actions, run like the Action block runs them; deleting the record returns to the list. */
10
- const RecordActions = ({ model, stored, recordKey }) => {
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
- if (runner.actions.length === 0) return null;
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
- })) }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
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`). Always editable (`edit`, the default) shows the form, locked to a user who may not change
5
- * it, and ignores `edit`; `read` and `locked` show the read view or the locked form until the address asks for the
6
- * form, which only a user who may change the record gets. `offersEdit` is whether the page links to the form;
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: canUpdate ? "form" : "locked",
16
+ shown: "form",
12
17
  offersEdit: false,
13
- editing: canUpdate
18
+ editing: true
14
19
  };
15
- if (canUpdate && edit) return {
20
+ if (edit) return {
16
21
  shown: "form",
17
22
  offersEdit: false,
18
23
  editing: true