@protobase/ui 0.15.4 → 0.16.0
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/account/two-factor-setup.js +6 -5
- package/dist/app/app.js +2 -1
- package/dist/app/create/create-page.js +1 -0
- package/dist/app/create/create-record-form.js +2 -1
- package/dist/app/displays/custom-display.js +1 -1
- package/dist/app/displays/map-display.js +50 -34
- package/dist/app/displays/map-groups.js +12 -3
- package/dist/app/displays/measured-heights.js +23 -0
- package/dist/app/displays/record-card.js +2 -2
- package/dist/app/displays/virtual-cards.js +12 -14
- package/dist/app/field-editor.js +9 -1
- package/dist/app/index.html +2 -2
- package/dist/app/list-page.js +2 -2
- package/dist/app/map/approximate-position.js +76 -0
- package/dist/app/map/area-features.js +80 -0
- package/dist/app/map/maplibre.js +11 -0
- package/dist/app/map/position-areas.js +78 -0
- package/dist/app/map/record-map.js +2 -7
- package/dist/app/map/record-position.js +5 -4
- package/dist/app/map/records-map.js +24 -7
- package/dist/app/map/theme-color.js +23 -0
- package/dist/app/meta-gate.js +1 -1
- package/dist/app/nav-recent.js +1 -1
- package/dist/app/pages/blocks/card-frame.js +2 -2
- package/dist/app/pages/blocks/grid.js +10 -2
- package/dist/app/pages/blocks/link.js +1 -1
- package/dist/app/pages/blocks/page-block.js +1 -1
- package/dist/app/pages/blocks/stat.js +9 -5
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/record-page.js +8 -4
- package/dist/app/router.js +7 -8
- package/dist/app/saved/saved-records.js +1 -1
- package/dist/app/search/search-model.js +1 -1
- package/dist/app/searches/use-save-search.js +4 -3
- package/dist/app/source-notice.js +2 -2
- package/dist/app/use-record-editor.js +5 -3
- package/dist/app-shell/app-shell.d.ts +1 -0
- package/dist/app-shell/app-shell.js +6 -4
- package/dist/app-shell/page-scroll.js +21 -4
- package/dist/app-shell/sidebar.d.ts +1 -0
- package/dist/app-shell/sidebar.js +4 -4
- package/dist/app-shell/top-bar.js +4 -4
- package/dist/auth/auth-gate.js +18 -9
- package/dist/auth/auth-provider.js +1 -0
- package/dist/auth/code-form.js +77 -0
- package/dist/auth/email-code-form.js +18 -77
- package/dist/auth/forgot-password-form.js +10 -10
- package/dist/auth/forgot-password-page.js +2 -2
- package/dist/auth/resend-code-button.js +29 -0
- package/dist/auth/sign-in-form.js +7 -8
- package/dist/auth/sign-in-page.js +3 -3
- package/dist/auth/sign-in-prompt.js +13 -9
- package/dist/auth/sign-up-code-form.js +22 -0
- package/dist/auth/sign-up-details-form.js +81 -111
- package/dist/auth/sign-up-page.js +17 -8
- package/dist/auth/two-factor-form.js +3 -3
- package/dist/auth/use-sign-in-address.js +21 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/flip-guard.js +43 -0
- package/dist/lib/page-scroll-lock.js +20 -0
- package/dist/lib/use-focus-trap.js +4 -4
- package/dist/organizations/invitation-page.js +7 -6
- package/dist/package.js +1 -1
- package/dist/primitives/otp-input.d.ts +21 -0
- package/dist/primitives/otp-input.js +117 -0
- package/dist/primitives/otp-value.js +23 -0
- package/dist/primitives/route-link.d.ts +1 -0
- package/dist/primitives/route-link.js +20 -0
- package/dist/primitives/textarea.js +36 -0
- package/dist/record-view/field.js +56 -53
- package/dist/shell/assets/app-DJAZP1yB.js +66 -0
- package/dist/shell/assets/index-C5pYkUfv.css +2 -0
- package/dist/shell/assets/{index-CrtAqGCx.js → index-LBhMBiK4.js} +1 -1
- package/dist/shell/assets/maplibre-Dy0ZTnFZ.js +814 -0
- package/dist/shell/assets/record-map-BiWSbqqT.js +1 -0
- package/dist/shell/assets/records-map-DabYc3ad.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-BURelnPG.js +1 -0
- package/dist/shell/index.html +6 -6
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/styles.css +15 -0
- package/dist/toasts/toast.js +22 -6
- package/package.json +4 -4
- package/dist/auth/code-input.js +0 -17
- package/dist/auth/sign-in-address.js +0 -47
- package/dist/shell/assets/app-CpFoFNtG.js +0 -66
- package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
- package/dist/shell/assets/maplibre-gl-B_TZ9n3D.js +0 -1
- package/dist/shell/assets/record-map-B2mkMEcE.js +0 -814
- package/dist/shell/assets/records-map-CAj21Al1.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-D942ARkw.js +0 -1
- /package/dist/shell/assets/{record-map-DBE96hFo.css → maplibre-DBE96hFo.css} +0 -0
|
@@ -3,7 +3,7 @@ import { Input } from "../primitives/input.js";
|
|
|
3
3
|
import { authMessage } from "../auth/auth-messages.js";
|
|
4
4
|
import { Button } from "../primitives/button.js";
|
|
5
5
|
import { BackupCodes } from "./backup-codes.js";
|
|
6
|
-
import {
|
|
6
|
+
import { OtpInput } from "../primitives/otp-input.js";
|
|
7
7
|
import { TotpQr, totpKey } from "./totp-qr.js";
|
|
8
8
|
import { useState } from "react";
|
|
9
9
|
import { AuthError } from "@protobase/client";
|
|
@@ -49,8 +49,8 @@ const TwoFactorSetup = ({ offerEmailedCodes, needsPassword, onStartApp, onConfir
|
|
|
49
49
|
setup: await onStartApp(given)
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
-
const confirm = (setup) => attempt(async () => {
|
|
53
|
-
await onConfirmApp(
|
|
52
|
+
const confirm = (setup, entered = code) => attempt(async () => {
|
|
53
|
+
await onConfirmApp(entered);
|
|
54
54
|
setStep({
|
|
55
55
|
kind: "backup-codes",
|
|
56
56
|
codes: setup.backupCodes
|
|
@@ -103,13 +103,14 @@ const TwoFactorSetup = ({ offerEmailedCodes, needsPassword, onStartApp, onConfir
|
|
|
103
103
|
}),
|
|
104
104
|
/* @__PURE__ */ jsxs("label", {
|
|
105
105
|
className: "flex flex-col gap-1.5 text-[13px] font-medium",
|
|
106
|
-
children: ["Code from the app", /* @__PURE__ */ jsx(
|
|
106
|
+
children: ["Code from the app", /* @__PURE__ */ jsx(OtpInput, {
|
|
107
107
|
name: "code",
|
|
108
108
|
required: true,
|
|
109
109
|
autoFocus: true,
|
|
110
110
|
value: code,
|
|
111
111
|
invalid: Boolean(error),
|
|
112
|
-
onChange:
|
|
112
|
+
onChange: setCode,
|
|
113
|
+
onComplete: (complete) => !busy && void confirm(step.setup, complete)
|
|
113
114
|
})]
|
|
114
115
|
}),
|
|
115
116
|
errorLine,
|
package/dist/app/app.js
CHANGED
|
@@ -169,12 +169,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
169
169
|
recordKey: address.key
|
|
170
170
|
}, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
|
|
171
171
|
const appShell = /* @__PURE__ */ jsx(AppShell, {
|
|
172
|
+
pageScroll: true,
|
|
172
173
|
sidebarMode: sidebarMode ?? "text-small",
|
|
173
174
|
activeItem: place ?? "",
|
|
174
175
|
breadcrumb,
|
|
175
176
|
user: acting && userToShell(acting),
|
|
176
177
|
onSignOut: user && (() => void signOut()),
|
|
177
|
-
|
|
178
|
+
signIn: user ? void 0 : signIn.link(),
|
|
178
179
|
workspace: current?.organization?.name ?? workspace,
|
|
179
180
|
navGroups: groups,
|
|
180
181
|
bottomBar: bottomBarFromMeta(meta, basePath, place),
|
|
@@ -117,6 +117,7 @@ const LoadedCreate = ({ resource, values }) => {
|
|
|
117
117
|
help: view?.fields[name]?.help,
|
|
118
118
|
required: isRequired(field),
|
|
119
119
|
error: errors[name],
|
|
120
|
+
wide: Boolean(view?.fields[name]?.multiline),
|
|
120
121
|
editor: /* @__PURE__ */ jsx(FieldEditor, {
|
|
121
122
|
field,
|
|
122
123
|
hints: view?.fields[name],
|
|
@@ -6,7 +6,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
//#region src/app/create/create-record-form.tsx
|
|
7
7
|
/** The presentation of "New <thing>": the view's sections, each field with its editor, and Create or Cancel. */
|
|
8
8
|
const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel }) => /* @__PURE__ */ jsx("form", {
|
|
9
|
-
className: "
|
|
9
|
+
className: "flex-1",
|
|
10
10
|
onSubmit: (event) => {
|
|
11
11
|
event.preventDefault();
|
|
12
12
|
onSubmit();
|
|
@@ -56,6 +56,7 @@ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmi
|
|
|
56
56
|
required: field.required,
|
|
57
57
|
help: field.help,
|
|
58
58
|
error: field.error,
|
|
59
|
+
wide: field.wide,
|
|
59
60
|
children: field.editor
|
|
60
61
|
}, field.name))
|
|
61
62
|
})
|
|
@@ -20,7 +20,7 @@ const CustomDisplay = ({ display, list }) => {
|
|
|
20
20
|
return /* @__PURE__ */ jsx(ListDisplayProvider, {
|
|
21
21
|
value: list,
|
|
22
22
|
children: /* @__PURE__ */ jsx("div", {
|
|
23
|
-
className: "min-
|
|
23
|
+
className: "min-w-0 flex-1 overflow-x-auto",
|
|
24
24
|
children: /* @__PURE__ */ jsx(CustomBoundary, {
|
|
25
25
|
name: display.component,
|
|
26
26
|
children: /* @__PURE__ */ jsx(Component, { ...unmarkFieldRefs(display.props ?? {}) })
|
|
@@ -11,7 +11,7 @@ import { boundsAround, worldArea } from "../map/map-area.js";
|
|
|
11
11
|
import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
|
|
12
12
|
import { useMapRecords } from "./use-map-records.js";
|
|
13
13
|
import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
|
|
14
|
-
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
14
|
+
import { ChevronLeft, ChevronRight, CircleDashed, X } from "lucide-react";
|
|
15
15
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
16
|
//#region src/app/displays/map-display.tsx
|
|
17
17
|
const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
|
|
@@ -22,7 +22,7 @@ const arrow = "flex size-8 items-center justify-center rounded-full border borde
|
|
|
22
22
|
* 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
|
|
23
23
|
* pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
|
|
24
24
|
* its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
|
|
25
|
-
* elsewhere clears it.
|
|
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
28
|
const style = useAdminMeta().maps?.style;
|
|
@@ -48,7 +48,13 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
|
|
|
48
48
|
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
49
49
|
const selection = keptSelection(chosen, rows);
|
|
50
50
|
const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
|
|
51
|
-
const
|
|
51
|
+
const approximate = new Set(points.filter((point) => point.approximate).map((point) => point.key));
|
|
52
|
+
const markerLabel = (keys) => {
|
|
53
|
+
const near = keys.filter((key) => approximate.has(key)).length;
|
|
54
|
+
if (keys.length === 1) return near === 1 ? `Show the ${singular}, at an approximate position` : `Show the ${singular}`;
|
|
55
|
+
if (near === 0) return `Show ${keys.length} ${plural}`;
|
|
56
|
+
return near === keys.length ? `Show ${keys.length} ${plural}, at approximate positions` : `Show ${keys.length} ${plural}, ${near} at approximate positions`;
|
|
57
|
+
};
|
|
52
58
|
const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
|
|
53
59
|
const overlay = useRef(null);
|
|
54
60
|
const [covered, setCovered] = useState(0);
|
|
@@ -112,46 +118,56 @@ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove,
|
|
|
112
118
|
"aria-label": `Chosen ${singular}`,
|
|
113
119
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
114
120
|
className: "flex items-center justify-end gap-1.5",
|
|
115
|
-
children: [
|
|
116
|
-
/* @__PURE__ */
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
onClick: () => setChosen(stepSelection(selection, -1)),
|
|
121
|
-
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
122
|
-
className: "size-4",
|
|
121
|
+
children: [
|
|
122
|
+
approximate.has(row.key) && /* @__PURE__ */ jsxs("span", {
|
|
123
|
+
className: "mr-auto flex items-center gap-1 rounded-full border border-border bg-background px-2 py-1 text-xs",
|
|
124
|
+
children: [/* @__PURE__ */ jsx(CircleDashed, {
|
|
125
|
+
className: "size-3.5",
|
|
123
126
|
"aria-hidden": true
|
|
124
|
-
})
|
|
125
|
-
}),
|
|
126
|
-
/* @__PURE__ */ jsxs("span", {
|
|
127
|
-
className: "rounded-full border border-border bg-background px-2 py-1 text-xs tabular-nums",
|
|
128
|
-
"aria-live": "polite",
|
|
129
|
-
children: [
|
|
130
|
-
selection.index + 1,
|
|
131
|
-
" / ",
|
|
132
|
-
selection.keys.length
|
|
133
|
-
]
|
|
127
|
+
}), "Approximate position"]
|
|
134
128
|
}),
|
|
129
|
+
selection.keys.length > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
130
|
+
/* @__PURE__ */ jsx("button", {
|
|
131
|
+
type: "button",
|
|
132
|
+
"aria-label": "Previous",
|
|
133
|
+
className: arrow,
|
|
134
|
+
onClick: () => setChosen(stepSelection(selection, -1)),
|
|
135
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
136
|
+
className: "size-4",
|
|
137
|
+
"aria-hidden": true
|
|
138
|
+
})
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ jsxs("span", {
|
|
141
|
+
className: "rounded-full border border-border bg-background px-2 py-1 text-xs tabular-nums",
|
|
142
|
+
"aria-live": "polite",
|
|
143
|
+
children: [
|
|
144
|
+
selection.index + 1,
|
|
145
|
+
" / ",
|
|
146
|
+
selection.keys.length
|
|
147
|
+
]
|
|
148
|
+
}),
|
|
149
|
+
/* @__PURE__ */ jsx("button", {
|
|
150
|
+
type: "button",
|
|
151
|
+
"aria-label": "Next",
|
|
152
|
+
className: arrow,
|
|
153
|
+
onClick: () => setChosen(stepSelection(selection, 1)),
|
|
154
|
+
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
155
|
+
className: "size-4",
|
|
156
|
+
"aria-hidden": true
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
] }),
|
|
135
160
|
/* @__PURE__ */ jsx("button", {
|
|
136
161
|
type: "button",
|
|
137
|
-
"aria-label": "
|
|
162
|
+
"aria-label": "Close",
|
|
138
163
|
className: arrow,
|
|
139
|
-
onClick: () => setChosen(
|
|
140
|
-
children: /* @__PURE__ */ jsx(
|
|
164
|
+
onClick: () => setChosen(void 0),
|
|
165
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
141
166
|
className: "size-4",
|
|
142
167
|
"aria-hidden": true
|
|
143
168
|
})
|
|
144
169
|
})
|
|
145
|
-
]
|
|
146
|
-
type: "button",
|
|
147
|
-
"aria-label": "Close",
|
|
148
|
-
className: arrow,
|
|
149
|
-
onClick: () => setChosen(void 0),
|
|
150
|
-
children: /* @__PURE__ */ jsx(X, {
|
|
151
|
-
className: "size-4",
|
|
152
|
-
"aria-hidden": true
|
|
153
|
-
})
|
|
154
|
-
})]
|
|
170
|
+
]
|
|
155
171
|
}), /* @__PURE__ */ jsx("div", {
|
|
156
172
|
className: "shadow-lg",
|
|
157
173
|
children: /* @__PURE__ */ jsx(RecordCard, {
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { recordPosition } from "../map/record-position.js";
|
|
2
|
+
import { isApproximate, positionArea } from "../map/approximate-position.js";
|
|
2
3
|
//#region src/app/displays/map-groups.ts
|
|
3
|
-
/** The rows with a position, in their order; a row without one is not on the map. */
|
|
4
|
+
/** The rows with a position, in their order, approximate ones marked with their area; a row without one is not on the map. */
|
|
4
5
|
const mapPoints = (rows, fields) => rows.flatMap((row) => {
|
|
5
6
|
const position = recordPosition(row.record, fields);
|
|
6
|
-
|
|
7
|
+
if (!position) return [];
|
|
8
|
+
if (!isApproximate(row.record, fields)) return [{
|
|
7
9
|
key: row.key,
|
|
8
10
|
...position
|
|
9
|
-
}]
|
|
11
|
+
}];
|
|
12
|
+
const area = positionArea(row.record, fields, position);
|
|
13
|
+
return [{
|
|
14
|
+
key: row.key,
|
|
15
|
+
...position,
|
|
16
|
+
approximate: true,
|
|
17
|
+
...area && { area }
|
|
18
|
+
}];
|
|
10
19
|
});
|
|
11
20
|
/**
|
|
12
21
|
* The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/app/displays/measured-heights.ts
|
|
2
|
+
/** How many card widths the list remembers a measured height for. */
|
|
3
|
+
const remembered = 8;
|
|
4
|
+
/**
|
|
5
|
+
* `heights` with a card `height` tall measured at `width`, kept at the taller of it and the height measured there
|
|
6
|
+
* before, and `heights` itself when that changes nothing. A width the list comes back to, as with and without a
|
|
7
|
+
* scrollbar, keeps its height instead of falling back to a guess: a guess shorter than the cards shrinks the list, which
|
|
8
|
+
* can take the scrollbar away, widen the cards back, and measure them taller again, every frame.
|
|
9
|
+
*/
|
|
10
|
+
const withMeasuredHeight = (heights, width, height) => {
|
|
11
|
+
const current = heights.get(width);
|
|
12
|
+
if (current !== void 0 && current >= height) return heights;
|
|
13
|
+
const next = new Map(heights);
|
|
14
|
+
next.delete(width);
|
|
15
|
+
next.set(width, height);
|
|
16
|
+
for (const oldest of next.keys()) {
|
|
17
|
+
if (next.size <= remembered) break;
|
|
18
|
+
next.delete(oldest);
|
|
19
|
+
}
|
|
20
|
+
return next;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { withMeasuredHeight };
|
|
@@ -99,7 +99,7 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
|
|
|
99
99
|
})]
|
|
100
100
|
}),
|
|
101
101
|
subtitle !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
102
|
-
className: "truncate text-muted-foreground",
|
|
102
|
+
className: view?.fields[subtitle]?.multiline ? "line-clamp-3 whitespace-pre-line break-words text-muted-foreground" : "truncate text-muted-foreground",
|
|
103
103
|
children: shownValue(subtitle)
|
|
104
104
|
}),
|
|
105
105
|
shownAmount !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
@@ -155,7 +155,7 @@ const RecordCard = ({ model, view, display, row, swipe = true }) => {
|
|
|
155
155
|
cover,
|
|
156
156
|
swipe,
|
|
157
157
|
children: card
|
|
158
|
-
}) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })), favorite && /* @__PURE__ */ jsx(FavoriteButton, {
|
|
158
|
+
}, row.key) : card(/* @__PURE__ */ jsx(CardImage, { value: cover })), favorite && /* @__PURE__ */ jsx(FavoriteButton, {
|
|
159
159
|
favorite,
|
|
160
160
|
recordKey: row.key,
|
|
161
161
|
target: recordKey(model, row.record),
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
2
|
import { usePagedRows } from "../../data-grid/use-paged-rows.js";
|
|
3
3
|
import { useVirtualRows } from "../../data-grid/use-virtual-rows.js";
|
|
4
|
+
import { createFlipGuard } from "../../lib/flip-guard.js";
|
|
4
5
|
import { anchoredTop, cardColumns, cardWidth, recordRange } from "./card-layout.js";
|
|
6
|
+
import { withMeasuredHeight } from "./measured-heights.js";
|
|
5
7
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
6
8
|
import { jsx } from "react/jsx-runtime";
|
|
7
9
|
//#region src/app/displays/virtual-cards.tsx
|
|
@@ -9,7 +11,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
9
11
|
* Cards in rows as wide as the list, scrolled like the table by the page or the nearest scrolling ancestor: the list is
|
|
10
12
|
* as tall as all its rows, only the rows in view are drawn, and the records are loaded in the table's pages, so a
|
|
11
13
|
* change of width keeps the loaded pages and the first record in view. Every card in a width is as tall as the tallest
|
|
12
|
-
* drawn so far.
|
|
14
|
+
* drawn so far at that width.
|
|
13
15
|
*/
|
|
14
16
|
const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, children }) => {
|
|
15
17
|
const { total } = source;
|
|
@@ -17,11 +19,8 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
|
|
|
17
19
|
const [width, setWidth] = useState(0);
|
|
18
20
|
const columns = cardColumns(width);
|
|
19
21
|
const itemWidth = cardWidth(width, columns);
|
|
20
|
-
const [measured, setMeasured] = useState(
|
|
21
|
-
|
|
22
|
-
height: 0
|
|
23
|
-
});
|
|
24
|
-
const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.width === itemWidth ? measured.height : 0));
|
|
22
|
+
const [measured, setMeasured] = useState(() => /* @__PURE__ */ new Map());
|
|
23
|
+
const itemHeight = Math.ceil(Math.max(minHeight(itemWidth), measured.get(itemWidth) ?? 0));
|
|
25
24
|
const rowHeight = itemHeight + 12;
|
|
26
25
|
const layout = {
|
|
27
26
|
columns,
|
|
@@ -33,7 +32,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
|
|
|
33
32
|
});
|
|
34
33
|
const changed = anchor.current.top > 0 && (anchor.current.layout.columns !== columns || anchor.current.layout.rowHeight !== rowHeight);
|
|
35
34
|
const settle = (top) => changed ? anchoredTop(anchor.current.top, anchor.current.layout, layout) : top;
|
|
36
|
-
const ready = itemWidth > 0 && measured.
|
|
35
|
+
const ready = itemWidth > 0 && measured.has(itemWidth);
|
|
37
36
|
const scroll = useVirtualRows({
|
|
38
37
|
total: Math.ceil(total / columns),
|
|
39
38
|
rowHeight,
|
|
@@ -56,23 +55,22 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
|
|
|
56
55
|
loadRows: source.loadRows,
|
|
57
56
|
latencyMs: 0
|
|
58
57
|
}, range.start, range.end);
|
|
58
|
+
const [guard] = useState(() => createFlipGuard());
|
|
59
59
|
useLayoutEffect(() => {
|
|
60
60
|
const body = scroll.ref.current;
|
|
61
61
|
if (!body) return;
|
|
62
|
-
const
|
|
62
|
+
const update = () => setWidth(guard(body.clientWidth, performance.now()));
|
|
63
|
+
const observer = new ResizeObserver(update);
|
|
63
64
|
observer.observe(body);
|
|
64
|
-
|
|
65
|
+
update();
|
|
65
66
|
return () => observer.disconnect();
|
|
66
|
-
}, []);
|
|
67
|
+
}, [guard]);
|
|
67
68
|
const widthRef = useRef(itemWidth);
|
|
68
69
|
widthRef.current = itemWidth;
|
|
69
70
|
const [observer] = useState(() => new ResizeObserver((entries) => {
|
|
70
71
|
const tallest = Math.max(...entries.map((entry) => entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height));
|
|
71
72
|
const at = widthRef.current;
|
|
72
|
-
setMeasured((current) => current
|
|
73
|
-
width: at,
|
|
74
|
-
height: Math.max(tallest, current.width === at ? current.height : 0)
|
|
75
|
-
});
|
|
73
|
+
setMeasured((current) => withMeasuredHeight(current, at, tallest));
|
|
76
74
|
}));
|
|
77
75
|
useLayoutEffect(() => () => observer.disconnect(), [observer]);
|
|
78
76
|
const observe = useCallback((item) => {
|
package/dist/app/field-editor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Input } from "../primitives/input.js";
|
|
3
3
|
import { Switch } from "../primitives/switch.js";
|
|
4
|
+
import { Textarea } from "../primitives/textarea.js";
|
|
4
5
|
import { displayValue } from "../live/display-value.js";
|
|
5
6
|
import { AppLink } from "./use-app-address.js";
|
|
6
7
|
import { RelationPicker } from "./relation-picker.js";
|
|
@@ -68,8 +69,9 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
68
69
|
}) : label
|
|
69
70
|
});
|
|
70
71
|
}
|
|
72
|
+
const multiline = Boolean(hints?.multiline) && field.type === "text";
|
|
71
73
|
if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
|
|
72
|
-
className: cn("py-1.5 text-[13px] tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground"),
|
|
74
|
+
className: cn("py-1.5 text-[13px] tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
|
|
73
75
|
children: displayValue(field, stored, hints)
|
|
74
76
|
});
|
|
75
77
|
if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
|
|
@@ -84,6 +86,12 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
84
86
|
children: enumLabel(option, hints?.valueLabels)
|
|
85
87
|
}, option))]
|
|
86
88
|
});
|
|
89
|
+
if (multiline) return /* @__PURE__ */ jsx(Textarea, {
|
|
90
|
+
invalid,
|
|
91
|
+
placeholder,
|
|
92
|
+
value: String(value),
|
|
93
|
+
onChange: (event) => onChange(event.target.value)
|
|
94
|
+
});
|
|
87
95
|
return /* @__PURE__ */ jsx(Input, {
|
|
88
96
|
invalid,
|
|
89
97
|
leading: hints?.prefix ? /* @__PURE__ */ jsx("span", {
|
package/dist/app/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
</script>
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
|
-
<!-- The page reaches under the iPhone home indicator, which the bottom bar keeps clear of; the sides keep clear of the notch. -->
|
|
20
|
-
<div id="root" class="h-
|
|
19
|
+
<!-- As tall as the screen shows, browser toolbars aside, so a page that fills it, such as a map, keeps the bottom bar in view. The page reaches under the iPhone home indicator, which the bottom bar keeps clear of; the sides keep clear of the notch. -->
|
|
20
|
+
<div id="root" class="h-dvh" style="padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right)"></div>
|
|
21
21
|
<script type="module" src="./main"></script>
|
|
22
22
|
</body>
|
|
23
23
|
</html>
|
package/dist/app/list-page.js
CHANGED
|
@@ -45,8 +45,8 @@ import { SavedSearches } from "./searches/saved-searches.js";
|
|
|
45
45
|
import { useMemo, useState } from "react";
|
|
46
46
|
import { Plus, SlidersHorizontal } from "lucide-react";
|
|
47
47
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
48
|
-
import { tableDisplay } from "@protobase/schema/internal";
|
|
49
48
|
import { createWhere } from "@protobase/schema";
|
|
49
|
+
import { tableDisplay } from "@protobase/schema/internal";
|
|
50
50
|
//#region src/app/list-page.tsx
|
|
51
51
|
const where = createWhere();
|
|
52
52
|
const searchText = (extras) => extras.find((clause) => clause.kind === "search");
|
|
@@ -141,7 +141,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
141
141
|
orderBy
|
|
142
142
|
});
|
|
143
143
|
const display = shownDisplay(view, address.display);
|
|
144
|
-
const pageScroll =
|
|
144
|
+
const pageScroll = display?.kind !== "map";
|
|
145
145
|
usePageScroll(pageScroll);
|
|
146
146
|
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
147
147
|
const scroll = useListScroll();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { numeric } from "./record-position.js";
|
|
2
|
+
//#region src/app/map/approximate-position.ts
|
|
3
|
+
const earthRadius = 6371008.8;
|
|
4
|
+
const circleCorners = 64;
|
|
5
|
+
/** Whether the record's position is approximate, as its field states; without one, or a value that says neither, it is not. */
|
|
6
|
+
const isApproximate = (record, fields) => {
|
|
7
|
+
if (fields.approximate !== void 0) return record[fields.approximate] === true;
|
|
8
|
+
if (fields.exact !== void 0) return record[fields.exact] === false;
|
|
9
|
+
return false;
|
|
10
|
+
};
|
|
11
|
+
/** The polygon of `metres` around `center`, its corners as far from it along the globe. */
|
|
12
|
+
const circleArea = (center, metres) => {
|
|
13
|
+
const distance = metres / earthRadius;
|
|
14
|
+
const latitude = center.latitude * Math.PI / 180;
|
|
15
|
+
const longitude = center.longitude * Math.PI / 180;
|
|
16
|
+
return {
|
|
17
|
+
type: "Polygon",
|
|
18
|
+
coordinates: [Array.from({ length: 65 }, (_, index) => {
|
|
19
|
+
const bearing = index % circleCorners / circleCorners * 2 * Math.PI;
|
|
20
|
+
const to = Math.asin(Math.sin(latitude) * Math.cos(distance) + Math.cos(latitude) * Math.sin(distance) * Math.cos(bearing));
|
|
21
|
+
return [(longitude + Math.atan2(Math.sin(bearing) * Math.sin(distance) * Math.cos(latitude), Math.cos(distance) - Math.sin(latitude) * Math.sin(to))) * 180 / Math.PI, to * 180 / Math.PI];
|
|
22
|
+
})]
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
const isPosition = (value) => Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" && Math.abs(value[0]) <= 180 && Math.abs(value[1]) <= 90;
|
|
26
|
+
const isLine = (value, least) => Array.isArray(value) && value.length >= least && value.every(isPosition);
|
|
27
|
+
const isPolygon = (value) => Array.isArray(value) && value.length > 0 && value.every((ring) => isLine(ring, 4));
|
|
28
|
+
const isLines = (value) => Array.isArray(value) && value.length > 0 && value.every((line) => isLine(line, 2));
|
|
29
|
+
const parsed = (value) => {
|
|
30
|
+
if (typeof value !== "string") return value;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(value);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof SyntaxError) return void 0;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** The GeoJSON polygon or line a value holds, as a geometry or a feature of one; none for anything else, such as a point or text that is no GeoJSON. */
|
|
39
|
+
const areaGeometry = (value) => {
|
|
40
|
+
const data = parsed(value);
|
|
41
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
42
|
+
const geometry = data.type === "Feature" ? data.geometry : data;
|
|
43
|
+
if (typeof geometry !== "object" || geometry === null) return void 0;
|
|
44
|
+
const { type, coordinates } = geometry;
|
|
45
|
+
if (type === "Polygon" && isPolygon(coordinates)) return {
|
|
46
|
+
type,
|
|
47
|
+
coordinates
|
|
48
|
+
};
|
|
49
|
+
if (type === "MultiPolygon" && Array.isArray(coordinates) && coordinates.length > 0 && coordinates.every(isPolygon)) return {
|
|
50
|
+
type,
|
|
51
|
+
coordinates
|
|
52
|
+
};
|
|
53
|
+
if (type === "LineString" && isLine(coordinates, 2)) return {
|
|
54
|
+
type,
|
|
55
|
+
coordinates
|
|
56
|
+
};
|
|
57
|
+
if (type === "MultiLineString" && isLines(coordinates)) return {
|
|
58
|
+
type,
|
|
59
|
+
coordinates
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* What the map draws around the record's approximate position: the area its field holds, else a circle of the radius
|
|
64
|
+
* its field holds, else one of the display's default radius. An exact position, or one whose record states none of
|
|
65
|
+
* them on a display without a default, has none.
|
|
66
|
+
*/
|
|
67
|
+
const positionArea = (record, fields, position) => {
|
|
68
|
+
if (!isApproximate(record, fields)) return void 0;
|
|
69
|
+
const area = fields.area === void 0 ? void 0 : areaGeometry(record[fields.area]);
|
|
70
|
+
if (area) return area;
|
|
71
|
+
const radius = fields.radius === void 0 ? void 0 : numeric(record[fields.radius]);
|
|
72
|
+
if (radius !== void 0 && Number.isFinite(radius) && radius > 0) return circleArea(position, radius);
|
|
73
|
+
return fields.defaultRadius === void 0 ? void 0 : circleArea(position, fields.defaultRadius);
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { areaGeometry, circleArea, isApproximate, positionArea };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/app/map/area-features.ts
|
|
2
|
+
const worldPixels = 512;
|
|
3
|
+
const mercatorLimit = 85.051129;
|
|
4
|
+
const fadeFrom = 16;
|
|
5
|
+
const zooms = Array.from({ length: 23 }, (_, zoom) => zoom);
|
|
6
|
+
const positionsOf = (geometry) => {
|
|
7
|
+
if (geometry.type === "LineString") return geometry.coordinates;
|
|
8
|
+
if (geometry.type === "MultiPolygon") return geometry.coordinates.flat(2);
|
|
9
|
+
return geometry.coordinates.flat();
|
|
10
|
+
};
|
|
11
|
+
const mercatorY = (latitude) => {
|
|
12
|
+
const radians = Math.max(-85.051129, Math.min(mercatorLimit, latitude)) * Math.PI / 180;
|
|
13
|
+
return .5 - Math.log(Math.tan(Math.PI / 4 + radians / 2)) / (2 * Math.PI);
|
|
14
|
+
};
|
|
15
|
+
/** How many pixels across the area is drawn at zoom 0, the larger of its width and height; twice as many each zoom level in. */
|
|
16
|
+
const zoomZeroSpan = (geometry) => {
|
|
17
|
+
const positions = positionsOf(geometry);
|
|
18
|
+
const longitudes = positions.map(([longitude]) => longitude);
|
|
19
|
+
const ys = positions.map(([, latitude]) => mercatorY(latitude));
|
|
20
|
+
const width = (Math.max(...longitudes) - Math.min(...longitudes)) / 360 * worldPixels;
|
|
21
|
+
const height = (Math.max(...ys) - Math.min(...ys)) * worldPixels;
|
|
22
|
+
return Math.max(width, height);
|
|
23
|
+
};
|
|
24
|
+
/** The areas of the points that have one, those of the chosen records last so they are drawn on top. */
|
|
25
|
+
const areaFeatures = (points, selected) => {
|
|
26
|
+
const features = points.flatMap((point) => {
|
|
27
|
+
if (!point.area) return [];
|
|
28
|
+
const kind = point.area.type === "LineString" || point.area.type === "MultiLineString" ? "line" : "polygon";
|
|
29
|
+
return [{
|
|
30
|
+
type: "Feature",
|
|
31
|
+
geometry: point.area,
|
|
32
|
+
properties: {
|
|
33
|
+
key: point.key,
|
|
34
|
+
kind,
|
|
35
|
+
span: zoomZeroSpan(point.area),
|
|
36
|
+
chosen: selected?.includes(point.key) ?? false
|
|
37
|
+
}
|
|
38
|
+
}];
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
type: "FeatureCollection",
|
|
42
|
+
features: [...features.filter((feature) => !feature.properties.chosen), ...features.filter((feature) => feature.properties.chosen)]
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* An opacity of `full` for an area drawn large enough to mean something at the zoom, fading in as it grows to that
|
|
47
|
+
* size: at each whole zoom level its share of `full` follows from its span, and MapLibre blends between the levels.
|
|
48
|
+
*/
|
|
49
|
+
const fadeIn = (full) => [
|
|
50
|
+
"interpolate",
|
|
51
|
+
["linear"],
|
|
52
|
+
["zoom"],
|
|
53
|
+
...zooms.flatMap((zoom) => [zoom, [
|
|
54
|
+
"*",
|
|
55
|
+
full,
|
|
56
|
+
[
|
|
57
|
+
"min",
|
|
58
|
+
1,
|
|
59
|
+
[
|
|
60
|
+
"max",
|
|
61
|
+
0,
|
|
62
|
+
[
|
|
63
|
+
"/",
|
|
64
|
+
[
|
|
65
|
+
"-",
|
|
66
|
+
[
|
|
67
|
+
"*",
|
|
68
|
+
["get", "span"],
|
|
69
|
+
2 ** zoom
|
|
70
|
+
],
|
|
71
|
+
fadeFrom
|
|
72
|
+
],
|
|
73
|
+
32
|
|
74
|
+
]
|
|
75
|
+
]
|
|
76
|
+
]
|
|
77
|
+
]])
|
|
78
|
+
];
|
|
79
|
+
//#endregion
|
|
80
|
+
export { areaFeatures, fadeIn, zoomZeroSpan };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as maplibre from "maplibre-gl";
|
|
2
|
+
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
|
|
3
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
4
|
+
import { Protocol } from "pmtiles";
|
|
5
|
+
//#region src/app/map/maplibre.ts
|
|
6
|
+
maplibre.setWorkerUrl(workerUrl);
|
|
7
|
+
maplibre.addProtocol("pmtiles", new Protocol().tile);
|
|
8
|
+
/** MapLibre, set up to run its worker and read PMTiles archives: what a map draws with. */
|
|
9
|
+
const mapLib = maplibre;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { mapLib };
|