@protobase/ui 0.19.3 → 0.19.5

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 (66) hide show
  1. package/dist/account/backup-codes.js +2 -9
  2. package/dist/app/displays/list-displays.js +2 -1
  3. package/dist/app/displays/map-display.js +3 -1
  4. package/dist/app/filled-fields.js +5 -0
  5. package/dist/app/gallery/details-open-storage.js +10 -0
  6. package/dist/app/gallery/gallery-layout.js +3 -1
  7. package/dist/app/gallery/lightbox.js +107 -61
  8. package/dist/app/import/convert.js +151 -0
  9. package/dist/app/import/csv-parser.js +119 -0
  10. package/dist/app/import/failed-rows.js +21 -0
  11. package/dist/app/import/import-button.js +31 -0
  12. package/dist/app/import/import-check-step.js +123 -0
  13. package/dist/app/import/import-counts.js +19 -0
  14. package/dist/app/import/import-dialog.js +152 -0
  15. package/dist/app/import/import-file-step.js +65 -0
  16. package/dist/app/import/import-mapping-step.js +195 -0
  17. package/dist/app/import/import-result-step.js +86 -0
  18. package/dist/app/import/import-select.js +35 -0
  19. package/dist/app/import/locale-formats.js +15 -0
  20. package/dist/app/import/lookups.js +48 -0
  21. package/dist/app/import/mapping.js +78 -0
  22. package/dist/app/import/parse-worker.d.ts +1 -0
  23. package/dist/app/import/parse-worker.js +39 -0
  24. package/dist/app/import/prepare-rows.js +129 -0
  25. package/dist/app/import/run-import.js +130 -0
  26. package/dist/app/import/sheet-cell.js +17 -0
  27. package/dist/app/import/sheet-file.js +58 -0
  28. package/dist/app/import/sheet-source.js +72 -0
  29. package/dist/app/import/use-import-flow.js +225 -0
  30. package/dist/app/import/xlsx-reader.js +43 -0
  31. package/dist/app/import/xlsx-xml.js +169 -0
  32. package/dist/app/import/zip-reader.js +72 -0
  33. package/dist/app/list-page.js +8 -1
  34. package/dist/app/map/map-layer-choice.js +11 -0
  35. package/dist/app/map/map-layer-switch.js +55 -0
  36. package/dist/app/map/map-section.js +3 -1
  37. package/dist/app/map/position-areas.js +4 -2
  38. package/dist/app/map/record-map.js +16 -11
  39. package/dist/app/map/records-map.js +7 -2
  40. package/dist/app/named-icon.js +9 -1
  41. package/dist/app/record-page.js +11 -2
  42. package/dist/auth/sign-in-prompt.js +1 -1
  43. package/dist/data/api-provider.js +8 -2
  44. package/dist/data/meta-data.d.ts +3 -2
  45. package/dist/lib/save-file.js +12 -0
  46. package/dist/lib/use-focus-trap.js +7 -1
  47. package/dist/package.js +1 -1
  48. package/dist/primitives/sheet-swipe.js +5 -0
  49. package/dist/primitives/sheet.js +40 -12
  50. package/dist/record-view/key-facts.js +29 -0
  51. package/dist/shell/assets/app-CZ4drn7G.js +67 -0
  52. package/dist/shell/assets/index-BoeKjvo8.css +2 -0
  53. package/dist/shell/assets/{index-5frCs7fQ.js → index-BzCCIfII.js} +1 -1
  54. package/dist/shell/assets/maplibre-BOsAtlAo.js +814 -0
  55. package/dist/shell/assets/parse-worker-BtcY0dQw.js +5 -0
  56. package/dist/shell/assets/record-map-BK_02Lmk.js +1 -0
  57. package/dist/shell/assets/records-map-Dn72A_l-.js +1 -0
  58. package/dist/shell/assets/{shared-protobase-ui-Ceamz_7l.js → shared-protobase-ui-B6lrj7eA.js} +1 -1
  59. package/dist/shell/index.html +4 -4
  60. package/dist/shell/protobase.shell.json +1 -1
  61. package/package.json +4 -4
  62. package/dist/shell/assets/app-ChEqQ9ca.js +0 -66
  63. package/dist/shell/assets/index-D-4IHhqX.css +0 -2
  64. package/dist/shell/assets/maplibre-D8plsjP2.js +0 -814
  65. package/dist/shell/assets/record-map-BDJTOwZo.js +0 -1
  66. package/dist/shell/assets/records-map-C-XKw6m1.js +0 -1
@@ -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,4 +1,4 @@
1
- import { Bed, Bell, Bookmark, ExternalLink, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bath, Bed, Bell, Bookmark, Calendar, DoorOpen, ExternalLink, FileText, Heart, House, KeyRound, LandPlot, Leaf, RulerDimensionLine, Send, Tag, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -7,6 +7,14 @@ const byName = {
7
7
  send: Send,
8
8
  ruler: RulerDimensionLine,
9
9
  bed: Bed,
10
+ bath: Bath,
11
+ house: House,
12
+ "door-open": DoorOpen,
13
+ "land-plot": LandPlot,
14
+ leaf: Leaf,
15
+ tag: Tag,
16
+ key: KeyRound,
17
+ calendar: Calendar,
10
18
  heart: Heart,
11
19
  bell: Bell,
12
20
  bookmark: Bookmark,
@@ -10,6 +10,7 @@ import { recordTitle } from "../live/model-helpers.js";
10
10
  import { humanize } from "../live/naming.js";
11
11
  import { useProjectUi } from "./pages/project-ui.js";
12
12
  import { displayValue } from "../live/display-value.js";
13
+ import { filledFields } from "./filled-fields.js";
13
14
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
14
15
  import { AppLink, useAppAddress } from "./use-app-address.js";
15
16
  import { ErrorBanner } from "./error-banner.js";
@@ -22,9 +23,11 @@ import { Section } from "../record-view/section.js";
22
23
  import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
23
24
  import { RecordHeader } from "../record-view/record-header.js";
24
25
  import { SpecTable } from "../record-view/spec-table.js";
26
+ import { KeyFacts } from "../record-view/key-facts.js";
25
27
  import { toneFor } from "../live/columns-from-model.js";
26
28
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
27
29
  import { useRestore } from "./delete/use-restore.js";
30
+ import { namedIcon } from "./named-icon.js";
28
31
  import { ValueBadge } from "./value-badge.js";
29
32
  import { FieldValue } from "./pages/field-value.js";
30
33
  import { auditField } from "../live/audit-fields.js";
@@ -375,12 +378,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
375
378
  recordKey
376
379
  }, `gallery:${index}`)];
377
380
  if (item.kind !== "section") return [];
378
- const fields = item.fields.filter((name) => model.fields[name] && visible(name));
381
+ const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
382
+ const facts = shown === "read" && item.presentation === "facts";
383
+ const fields = facts ? filledFields(stored.record, visibleFields) : visibleFields;
379
384
  if (fields.length === 0) return [];
380
385
  return [/* @__PURE__ */ jsx(Section, {
381
386
  title: item.title,
382
387
  help: helpShown(item.helpWhen, view, editing) ? item.help : void 0,
383
- children: shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
388
+ children: facts ? /* @__PURE__ */ jsx(KeyFacts, { facts: fields.map((name) => ({
389
+ label: readRow(name).label,
390
+ value: readRow(name).value,
391
+ icon: namedIcon(hint(name)?.icon)
392
+ })) }) : shown === "read" ? /* @__PURE__ */ jsx(SpecTable, { rows: fields.map(readRow) }) : /* @__PURE__ */ jsx("div", {
384
393
  className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
385
394
  children: fields.map(fieldView)
386
395
  })
@@ -2,8 +2,8 @@ import { RouteLink } from "../primitives/route-link.js";
2
2
  import { useAuth } from "./auth-provider.js";
3
3
  import { Button, buttonClass } from "../primitives/button.js";
4
4
  import { useSignInAddress } from "./use-sign-in-address.js";
5
- import { namedIcon } from "../app/named-icon.js";
6
5
  import { ResponsiveDialog } from "../primitives/responsive-dialog.js";
6
+ import { namedIcon } from "../app/named-icon.js";
7
7
  import { useId } from "react";
8
8
  import { Check, LogIn } from "lucide-react";
9
9
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -1,5 +1,5 @@
1
1
  import { keys } from "./query-keys.js";
2
- import { createContext, useContext, useMemo, useRef, useState } from "react";
2
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
3
3
  import { createClient } from "@protobase/client";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -21,6 +21,12 @@ const useResourceClient = (resource) => {
21
21
  const own = useClient();
22
22
  return useContext(ResourceClientContext)?.(resource) ?? own;
23
23
  };
24
+ /** `useResourceClient` for any resource: the client of the API that serves each, for code that reaches several. */
25
+ const useResourceClients = () => {
26
+ const own = useClient();
27
+ const resourceClient = useContext(ResourceClientContext);
28
+ return useCallback((resource) => resourceClient?.(resource) ?? own, [own, resourceClient]);
29
+ };
24
30
  /** The client of a source's API, one per URL, with the signed-in user's token; for `MetaGate`. */
25
31
  const useSourceClients = () => {
26
32
  const clientOf = useContext(SourceClientsContext);
@@ -104,4 +110,4 @@ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: give
104
110
  });
105
111
  };
106
112
  //#endregion
107
- export { ApiProvider, ClientProvider, useClient, useResourceClient, useSourceClients };
113
+ export { ApiProvider, ClientProvider, useClient, useResourceClient, useResourceClients, useSourceClients };
@@ -1,4 +1,4 @@
1
- import { PluginRef, ResourcePermissions, SourceRef } from "@protobase/client";
1
+ import { MapLayerModel, PluginRef, ResourcePermissions, SourceRef } from "@protobase/client";
2
2
  import { BottomBarModel, ResourceModel, SignInViewModel, UserMenuModel, ViewModel } from "@protobase/schema";
3
3
  import { PageModel } from "@protobase/layout";
4
4
  //#region src/data/meta-data.d.ts
@@ -27,9 +27,10 @@ export type MetaData = {
27
27
  dsn: string;
28
28
  environment?: string;
29
29
  };
30
- /** The map style the record page's maps are drawn with, when the app has one (see `MapSection`). */
30
+ /** The map style the app's maps are drawn with, and the layers they may switch to, when the app has one (see `MapSection`). */
31
31
  maps?: {
32
32
  style: string;
33
+ layers?: MapLayerModel[];
33
34
  };
34
35
  /** The hosts besides the app's own whose images are drawn with the page's referrer (see `FileImage`). */
35
36
  files?: {
@@ -0,0 +1,12 @@
1
+ //#region src/lib/save-file.ts
2
+ /** Hands the browser a file to save under `name`, made from `text`. */
3
+ const saveFile = (text, name, type = "text/plain") => {
4
+ const url = URL.createObjectURL(new Blob([text], { type }));
5
+ Object.assign(document.createElement("a"), {
6
+ href: url,
7
+ download: name
8
+ }).click();
9
+ URL.revokeObjectURL(url);
10
+ };
11
+ //#endregion
12
+ export { saveFile };
@@ -2,9 +2,11 @@ import { lockPageScroll } from "./page-scroll-lock.js";
2
2
  import { useEffect, useRef } from "react";
3
3
  //#region src/lib/use-focus-trap.ts
4
4
  const focusable = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
5
+ const stack = [];
5
6
  /**
6
7
  * While `active`: moves focus into `container`, keeps Tab inside it, locks page scrolling (see `lockPageScroll`),
7
- * calls `onEscape` on Escape, and returns focus to the previously focused element afterwards.
8
+ * calls `onEscape` on Escape, and returns focus to the previously focused element afterwards. A trap opened while
9
+ * another is active takes Tab and Escape until it ends.
8
10
  */
9
11
  const useFocusTrap = (container, active, onEscape) => {
10
12
  const escape = useRef(onEscape);
@@ -14,9 +16,12 @@ const useFocusTrap = (container, active, onEscape) => {
14
16
  const root = container.current;
15
17
  const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
16
18
  const unlock = lockPageScroll();
19
+ const token = {};
20
+ stack.push(token);
17
21
  const items = () => [...root.querySelectorAll(focusable)];
18
22
  (items()[0] ?? root).focus();
19
23
  const onKey = (event) => {
24
+ if (stack.at(-1) !== token) return;
20
25
  if (event.key === "Escape") return escape.current();
21
26
  if (event.key !== "Tab") return;
22
27
  const list = items();
@@ -37,6 +42,7 @@ const useFocusTrap = (container, active, onEscape) => {
37
42
  document.addEventListener("keydown", onKey);
38
43
  return () => {
39
44
  document.removeEventListener("keydown", onKey);
45
+ stack.splice(stack.indexOf(token), 1);
40
46
  unlock();
41
47
  previous?.focus();
42
48
  };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.3";
2
+ var version = "0.19.5";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -0,0 +1,5 @@
1
+ //#region src/primitives/sheet-swipe.ts
2
+ /** Whether a drag closes a sheet: mostly downwards, by 60 pixels or more. */
3
+ const closesSheet = (dx, dy) => dy >= 60 && dy > Math.abs(dx);
4
+ //#endregion
5
+ export { closesSheet };
@@ -1,13 +1,31 @@
1
1
  import { useFocusTrap } from "../lib/use-focus-trap.js";
2
+ import { closesSheet } from "./sheet-swipe.js";
2
3
  import { useRef } from "react";
3
4
  import { X } from "lucide-react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/primitives/sheet.tsx
6
- /** A panel that rises from the bottom of the screen, for the filter panel on phones. */
7
+ /**
8
+ * A panel that rises from the bottom of the screen, for the filter panel on mobile screens. It closes on Escape, the
9
+ * close button, a press above it and a swipe down on its header. Its content scrolls inside it when taller than the
10
+ * panel, which stops short of the top so the screen behind stays in sight.
11
+ */
7
12
  const Sheet = ({ title, open, onClose, children }) => {
8
13
  const panel = useRef(null);
14
+ const start = useRef(void 0);
9
15
  useFocusTrap(panel, open, onClose);
10
16
  if (!open) return null;
17
+ const down = (event) => {
18
+ if (event.pointerType === "touch") start.current = {
19
+ x: event.clientX,
20
+ y: event.clientY
21
+ };
22
+ };
23
+ const up = (event) => {
24
+ if (!start.current) return;
25
+ const closes = closesSheet(event.clientX - start.current.x, event.clientY - start.current.y);
26
+ start.current = void 0;
27
+ if (closes) onClose();
28
+ };
11
29
  return /* @__PURE__ */ jsxs("div", {
12
30
  className: "fixed inset-0 z-[80]",
13
31
  children: [/* @__PURE__ */ jsx("div", {
@@ -22,17 +40,27 @@ const Sheet = ({ title, open, onClose, children }) => {
22
40
  tabIndex: -1,
23
41
  className: "absolute inset-x-0 bottom-0 flex max-h-[85vh] flex-col rounded-t-xl border-t bg-background shadow-pop outline-none",
24
42
  children: [/* @__PURE__ */ jsxs("header", {
25
- className: "flex shrink-0 items-center justify-between border-b px-4",
26
- children: [/* @__PURE__ */ jsx("h2", {
27
- className: "text-[15px] font-semibold",
28
- children: title
29
- }), /* @__PURE__ */ jsx("button", {
30
- type: "button",
31
- "aria-label": "Close",
32
- onClick: onClose,
33
- className: "-mr-2 inline-flex size-11 items-center justify-center rounded-md text-muted-foreground hover:bg-muted",
34
- children: /* @__PURE__ */ jsx(X, { className: "size-5" })
35
- })]
43
+ className: "relative flex shrink-0 touch-none items-center justify-between border-b px-4",
44
+ onPointerDown: down,
45
+ onPointerUp: up,
46
+ onPointerCancel: () => start.current = void 0,
47
+ children: [
48
+ /* @__PURE__ */ jsx("span", {
49
+ className: "absolute left-1/2 top-1.5 h-1 w-9 -translate-x-1/2 rounded-full bg-border-strong",
50
+ "aria-hidden": true
51
+ }),
52
+ /* @__PURE__ */ jsx("h2", {
53
+ className: "text-[15px] font-semibold",
54
+ children: title
55
+ }),
56
+ /* @__PURE__ */ jsx("button", {
57
+ type: "button",
58
+ "aria-label": "Close",
59
+ onClick: onClose,
60
+ className: "-mr-2 inline-flex size-11 items-center justify-center rounded-md text-muted-foreground hover:bg-muted",
61
+ children: /* @__PURE__ */ jsx(X, { className: "size-5" })
62
+ })
63
+ ]
36
64
  }), /* @__PURE__ */ jsx("div", {
37
65
  className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6",
38
66
  children
@@ -0,0 +1,29 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ //#region src/record-view/key-facts.tsx
3
+ /**
4
+ * A record's key facts side by side, as the bedrooms, area and energy label of a listing on a real estate site: each
5
+ * value under its icon, or under its label without one, wrapping onto more rows as needed, two to a row on a mobile
6
+ * screen.
7
+ */
8
+ const KeyFacts = ({ facts }) => /* @__PURE__ */ jsx("dl", {
9
+ className: "grid grid-cols-2 gap-x-4 gap-y-5 text-[13px] sm:flex sm:flex-wrap sm:gap-x-10",
10
+ children: facts.map(({ label, value, icon: Icon }) => /* @__PURE__ */ jsxs("div", {
11
+ title: label,
12
+ className: "flex min-w-0 flex-col gap-1.5",
13
+ children: [/* @__PURE__ */ jsx("dt", {
14
+ className: "text-muted-foreground",
15
+ children: Icon ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Icon, {
16
+ className: "size-5",
17
+ "aria-hidden": true
18
+ }), /* @__PURE__ */ jsx("span", {
19
+ className: "sr-only",
20
+ children: label
21
+ })] }) : label
22
+ }), /* @__PURE__ */ jsx("dd", {
23
+ className: "min-w-0 break-words text-[15px] font-semibold text-foreground",
24
+ children: value
25
+ })]
26
+ }, label))
27
+ });
28
+ //#endregion
29
+ export { KeyFacts };