@protobase/ui 0.13.0 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/create/create-page.js +1 -0
- package/dist/app/current-address.d.ts +3 -2
- package/dist/app/current-address.js +2 -1
- package/dist/app/delete/use-delete-flow.js +2 -1
- package/dist/app/displays/card-carousel.js +4 -1
- package/dist/app/displays/card-photos.js +6 -3
- package/dist/app/displays/map-display.js +83 -23
- package/dist/app/displays/map-groups.js +1 -8
- package/dist/app/displays/record-card.js +8 -6
- package/dist/app/displays/use-map-records.js +44 -0
- package/dist/app/embedded-list/embedded-list.js +3 -1
- package/dist/app/file-field.js +16 -13
- package/dist/app/list-page.js +6 -2
- package/dist/app/map/map-area.js +53 -0
- package/dist/app/map/records-map.js +91 -38
- package/dist/app/pages/field-value.js +22 -9
- package/dist/app/use-record-editor.js +2 -1
- package/dist/data/api-provider.d.ts +2 -0
- package/dist/data/query-keys.js +9 -0
- package/dist/data/use-update-record.js +1 -0
- package/dist/data-grid/cells.js +8 -5
- package/dist/live/file-image.js +35 -0
- package/dist/live/file-values.js +17 -1
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CfuMqqAY.js +66 -0
- package/dist/shell/assets/index-CYWfU_c1.css +2 -0
- package/dist/shell/assets/{index-D7IcSr6n.js → index-DCTyUhH-.js} +1 -1
- package/dist/shell/assets/{maplibre-gl-Bt-3iOM-.js → maplibre-gl-DP9hPAtI.js} +1 -1
- package/dist/shell/assets/{record-map-DL4b9dZO.js → record-map-Dgn4pFlE.js} +2 -2
- package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +5 -4
- package/dist/shell/assets/app-DLjaMVJD.js +0 -66
- package/dist/shell/assets/index-BYPphaEm.css +0 -2
- package/dist/shell/assets/records-map-D0EOO5kZ.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
|
|
1
|
+
import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
|
|
2
2
|
//#region src/app/current-address.d.ts
|
|
3
|
-
/** A list as the app shows it: every value there, the view's default where the address names none. */
|
|
3
|
+
/** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
|
|
4
4
|
export type ListAddress = {
|
|
5
5
|
kind: 'list';
|
|
6
6
|
resource: string;
|
|
@@ -8,6 +8,7 @@ export type ListAddress = {
|
|
|
8
8
|
filter: string;
|
|
9
9
|
orderBy: string;
|
|
10
10
|
filterLayout: FilterLayout;
|
|
11
|
+
map?: MapView;
|
|
11
12
|
};
|
|
12
13
|
/** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
|
|
13
14
|
export type CurrentAddress = ListAddress | {
|
|
@@ -35,7 +35,8 @@ const changedList = (read, change, defaults) => {
|
|
|
35
35
|
...next.filter && { filter: next.filter },
|
|
36
36
|
...differs("display") && { display: next.display },
|
|
37
37
|
...differs("orderBy") && { orderBy: next.orderBy },
|
|
38
|
-
...differs("filterLayout") && { filterLayout: next.filterLayout }
|
|
38
|
+
...differs("filterLayout") && { filterLayout: next.filterLayout },
|
|
39
|
+
...next.map && { map: next.map }
|
|
39
40
|
};
|
|
40
41
|
};
|
|
41
42
|
//#endregion
|
|
@@ -25,7 +25,8 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
|
|
|
25
25
|
for (const prefix of [
|
|
26
26
|
"page",
|
|
27
27
|
"list",
|
|
28
|
-
"first-page"
|
|
28
|
+
"first-page",
|
|
29
|
+
"map"
|
|
29
30
|
]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
|
|
30
31
|
for (const prefix of [
|
|
31
32
|
"facets",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { keys } from "../../data/query-keys.js";
|
|
3
3
|
import { useClient } from "../../data/api-provider.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
5
|
import { cardPhotoList, cardPhotosRequest, shownPhoto } from "./card-photos.js";
|
|
5
6
|
import { useState } from "react";
|
|
6
7
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
@@ -17,7 +18,9 @@ const CardCarousel = ({ photos, recordKey, cover, children }) => {
|
|
|
17
18
|
const client = useClient();
|
|
18
19
|
const [opened, setOpened] = useState(false);
|
|
19
20
|
const [step, setStep] = useState(0);
|
|
20
|
-
const
|
|
21
|
+
const target = useAdminMeta().resources[photos.resource];
|
|
22
|
+
const placeholder = target?.fields[photos.image]?.file?.placeholder;
|
|
23
|
+
const request = cardPhotosRequest(photos, recordKey, placeholder !== void 0 && target?.fields[placeholder] ? placeholder : void 0);
|
|
21
24
|
const loaded = useQuery({
|
|
22
25
|
queryKey: keys.list(photos.resource, request.filter, request.orderBy ?? "", request.pageSize, `card-photos:${photos.image}`),
|
|
23
26
|
enabled: opened,
|
|
@@ -2,11 +2,14 @@ import { isStoredFile } from "../../live/file-values.js";
|
|
|
2
2
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
3
3
|
//#region src/app/displays/card-photos.ts
|
|
4
4
|
const where = createWhere();
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The list request for a card's photos: the records pointing at the card's, in the display's order, with only their
|
|
7
|
+
* image and its placeholder field, when it has one.
|
|
8
|
+
*/
|
|
9
|
+
const cardPhotosRequest = (photos, recordKey, placeholder) => ({
|
|
7
10
|
filter: printFilter(where.eq(photos.field, recordKey)),
|
|
8
11
|
...photos.sort !== void 0 && { orderBy: photos.sort },
|
|
9
|
-
fields: [photos.image],
|
|
12
|
+
fields: placeholder === void 0 ? [photos.image] : [photos.image, placeholder],
|
|
10
13
|
pageSize: 100
|
|
11
14
|
});
|
|
12
15
|
/** The files a card steps through: its own image first, then the loaded photos less that same file; empty values are left out. */
|
|
@@ -1,48 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
3
|
import { mapCards } from "./list-displays.js";
|
|
4
4
|
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
5
6
|
import { Problem } from "../pages/problem.js";
|
|
6
7
|
import { RecordCard } from "./record-card.js";
|
|
7
|
-
import {
|
|
8
|
+
import { useDebounced } from "../../lib/use-debounced.js";
|
|
9
|
+
import { boundsAround, worldArea } from "../map/map-area.js";
|
|
8
10
|
import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
|
|
11
|
+
import { useMapRecords } from "./use-map-records.js";
|
|
9
12
|
import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
|
|
10
13
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
11
14
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
15
|
//#region src/app/displays/map-display.tsx
|
|
13
16
|
const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
|
|
17
|
+
const settleMs = 250;
|
|
14
18
|
const arrow = "flex size-8 items-center justify-center rounded-full border border-border bg-background hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30";
|
|
15
19
|
/**
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* The matching records in the map's view, drawn with the map style `/meta` names: the records themselves when few are
|
|
21
|
+
* in view or the map is close, else a count for each part of it, which zooms in when chosen. The map asks again as it
|
|
22
|
+
* pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
|
|
23
|
+
* its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
|
|
24
|
+
* elsewhere clears it.
|
|
19
25
|
*/
|
|
20
|
-
const MapDisplay = ({ model, view, display,
|
|
26
|
+
const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true }) => {
|
|
21
27
|
const style = useAdminMeta().maps?.style;
|
|
22
28
|
const [chosen, setChosen] = useState();
|
|
29
|
+
const [area, setArea] = useState();
|
|
30
|
+
const settled = useDebounced(area, settleMs);
|
|
31
|
+
const [fitting, setFitting] = useState(at === void 0);
|
|
32
|
+
const records = useMapRecords({
|
|
33
|
+
model,
|
|
34
|
+
display,
|
|
35
|
+
fields,
|
|
36
|
+
filter,
|
|
37
|
+
orderBy,
|
|
38
|
+
area: fitting ? worldArea : settled
|
|
39
|
+
});
|
|
40
|
+
const loaded = records.isPlaceholderData ? void 0 : records.data;
|
|
41
|
+
const rows = records.data?.rows ?? [];
|
|
42
|
+
const buckets = records.data?.buckets ?? [];
|
|
43
|
+
const points = mapPoints(rows, display);
|
|
44
|
+
const fit = fitting && loaded ? boundsAround(mapPoints(loaded.rows, display), loaded.buckets) : void 0;
|
|
23
45
|
const cards = mapCards(view, display);
|
|
24
46
|
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
25
|
-
const selection = keptSelection(chosen, list.rows);
|
|
26
|
-
const row = selection && list.rows.find((candidate) => candidate.key === selection.keys[selection.index]);
|
|
27
47
|
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
48
|
+
const selection = keptSelection(chosen, rows);
|
|
49
|
+
const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
|
|
28
50
|
const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
|
|
51
|
+
const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
|
|
29
52
|
const overlay = useRef(null);
|
|
30
53
|
const [covered, setCovered] = useState(0);
|
|
31
54
|
useLayoutEffect(() => setCovered(overlay.current?.offsetHeight ?? 0), [row?.key]);
|
|
32
55
|
if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
|
|
41
|
-
children: [/* @__PURE__ */ jsx(Suspense, {
|
|
56
|
+
const empty = loaded?.total === 0;
|
|
57
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
58
|
+
className: "flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]",
|
|
59
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
60
|
+
className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
|
|
61
|
+
children: [
|
|
62
|
+
/* @__PURE__ */ jsx(Suspense, {
|
|
42
63
|
fallback: null,
|
|
43
64
|
children: /* @__PURE__ */ jsx(RecordsMap, {
|
|
44
65
|
style,
|
|
45
|
-
|
|
66
|
+
initial: at,
|
|
67
|
+
points,
|
|
68
|
+
buckets,
|
|
69
|
+
fit,
|
|
70
|
+
onFitted: () => setFitting(false),
|
|
71
|
+
onMove: (next, center) => {
|
|
72
|
+
setArea(next);
|
|
73
|
+
onMove?.(center);
|
|
74
|
+
},
|
|
46
75
|
selected: selection?.keys,
|
|
47
76
|
covered: selection ? covered : 0,
|
|
48
77
|
onSelect: (keys) => setChosen({
|
|
@@ -50,9 +79,30 @@ const MapDisplay = ({ model, view, display, list, footer = true }) => {
|
|
|
50
79
|
index: 0
|
|
51
80
|
}),
|
|
52
81
|
onClear: () => setChosen(void 0),
|
|
53
|
-
markerLabel
|
|
82
|
+
markerLabel,
|
|
83
|
+
bucketLabel
|
|
84
|
+
})
|
|
85
|
+
}),
|
|
86
|
+
records.error && /* @__PURE__ */ jsx("div", {
|
|
87
|
+
className: "absolute inset-x-3 top-3 z-10",
|
|
88
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
89
|
+
title: "Could not load rows",
|
|
90
|
+
error: records.error
|
|
54
91
|
})
|
|
55
|
-
}),
|
|
92
|
+
}),
|
|
93
|
+
empty && /* @__PURE__ */ jsx("div", {
|
|
94
|
+
className: "pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center",
|
|
95
|
+
children: /* @__PURE__ */ jsxs("p", {
|
|
96
|
+
className: "pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background px-4 py-1.5 text-[13px] text-muted-foreground shadow",
|
|
97
|
+
role: "status",
|
|
98
|
+
children: [fitting ? `No ${plural} match.` : `No ${plural} in this part of the map.`, !fitting && /* @__PURE__ */ jsx(Button, {
|
|
99
|
+
size: "sm",
|
|
100
|
+
onClick: () => setFitting(true),
|
|
101
|
+
children: "Show all"
|
|
102
|
+
})]
|
|
103
|
+
})
|
|
104
|
+
}),
|
|
105
|
+
selection && row && /* @__PURE__ */ jsx("div", {
|
|
56
106
|
ref: overlay,
|
|
57
107
|
className: "pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center",
|
|
58
108
|
children: /* @__PURE__ */ jsxs("div", {
|
|
@@ -111,10 +161,20 @@ const MapDisplay = ({ model, view, display, list, footer = true }) => {
|
|
|
111
161
|
})
|
|
112
162
|
})]
|
|
113
163
|
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
})
|
|
164
|
+
})
|
|
165
|
+
]
|
|
166
|
+
}), footer && /* @__PURE__ */ jsx("p", {
|
|
167
|
+
className: "py-3 text-xs text-muted-foreground",
|
|
168
|
+
children: inView(records.data, plural)
|
|
169
|
+
})]
|
|
117
170
|
});
|
|
118
171
|
};
|
|
172
|
+
/** How many of the matching records are in view, and how many the map shows when it cannot show them all. */
|
|
173
|
+
const inView = (data, plural) => {
|
|
174
|
+
if (!data) return "\xA0";
|
|
175
|
+
const total = data.total.toLocaleString("en-IE");
|
|
176
|
+
if (data.buckets.length === 0 && data.rows.length < data.total) return `${data.rows.length.toLocaleString("en-IE")} of ${total} ${plural} in view`;
|
|
177
|
+
return `${total} ${plural} in view`;
|
|
178
|
+
};
|
|
119
179
|
//#endregion
|
|
120
180
|
export { MapDisplay };
|
|
@@ -8,13 +8,6 @@ const mapPoints = (rows, fields) => rows.flatMap((row) => {
|
|
|
8
8
|
...position
|
|
9
9
|
}] : [];
|
|
10
10
|
});
|
|
11
|
-
/** The south-west and north-east corners around the points, as `[longitude, latitude]`, or none without points. */
|
|
12
|
-
const pointBounds = (points) => {
|
|
13
|
-
if (points.length === 0) return void 0;
|
|
14
|
-
const longitudes = points.map((point) => point.longitude);
|
|
15
|
-
const latitudes = points.map((point) => point.latitude);
|
|
16
|
-
return [[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]];
|
|
17
|
-
};
|
|
18
11
|
/**
|
|
19
12
|
* The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
|
|
20
13
|
* of it, else starts its own. A marker is at its first point, so markers do not move as others join them.
|
|
@@ -52,4 +45,4 @@ const stepSelection = (selection, step) => ({
|
|
|
52
45
|
index: ((selection.index + step) % selection.keys.length + selection.keys.length) % selection.keys.length
|
|
53
46
|
});
|
|
54
47
|
//#endregion
|
|
55
|
-
export { groupPoints, keptSelection, mapPoints,
|
|
48
|
+
export { groupPoints, keptSelection, mapPoints, stepSelection };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Badge } from "../../primitives/badge.js";
|
|
2
2
|
import { humanize } from "../../live/naming.js";
|
|
3
3
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
|
-
import {
|
|
4
|
+
import { shownImage } from "../../live/file-values.js";
|
|
5
5
|
import { displayValue } from "../../live/display-value.js";
|
|
6
6
|
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
7
7
|
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { FileImage } from "../../live/file-image.js";
|
|
8
9
|
import { shownWidget } from "../field-types/shown-widget.js";
|
|
9
10
|
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
10
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
@@ -14,13 +15,14 @@ import { CardCarousel } from "./card-carousel.js";
|
|
|
14
15
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
15
16
|
//#region src/app/displays/record-card.tsx
|
|
16
17
|
const CardImage = ({ value }) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const image = shownImage(value, {
|
|
19
|
+
width: 320,
|
|
20
|
+
height: 240
|
|
21
|
+
});
|
|
19
22
|
return /* @__PURE__ */ jsx("span", {
|
|
20
23
|
className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
|
|
21
|
-
children:
|
|
22
|
-
|
|
23
|
-
alt: "",
|
|
24
|
+
children: image && /* @__PURE__ */ jsx(FileImage, {
|
|
25
|
+
image,
|
|
24
26
|
loading: "lazy",
|
|
25
27
|
className: "size-full object-cover"
|
|
26
28
|
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { withLabels } from "../../live/relation-labels.js";
|
|
4
|
+
import { displayRow } from "./list-displays.js";
|
|
5
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
6
|
+
import { areaFilter, requestZoom } from "../map/map-area.js";
|
|
7
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
//#region src/app/displays/use-map-records.ts
|
|
9
|
+
/**
|
|
10
|
+
* The records in the map's area through the list API's `:map`, labelled for the card, or buckets of them when it holds
|
|
11
|
+
* many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
|
|
12
|
+
* left is never shown.
|
|
13
|
+
*/
|
|
14
|
+
const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
|
|
15
|
+
const client = useClient();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
const { resources, views } = useAdminMeta();
|
|
18
|
+
const position = {
|
|
19
|
+
latitude: display.latitude,
|
|
20
|
+
longitude: display.longitude
|
|
21
|
+
};
|
|
22
|
+
const inside = area ? areaFilter(filter, position, area.bounds) : "";
|
|
23
|
+
const zoom = area ? requestZoom(area.zoom) : 0;
|
|
24
|
+
return useQuery({
|
|
25
|
+
queryKey: keys.map(model.name, `${position.latitude},${position.longitude}`, zoom, inside, orderBy),
|
|
26
|
+
enabled: area !== void 0,
|
|
27
|
+
placeholderData: keepPreviousData,
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const result = await client.map(model.name, {
|
|
30
|
+
...position,
|
|
31
|
+
zoom,
|
|
32
|
+
filter: inside,
|
|
33
|
+
orderBy
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
rows: (await withLabels(queryClient, client, resources, views, model, fields, result.items)).map((record) => displayRow(model, record)),
|
|
37
|
+
buckets: result.buckets,
|
|
38
|
+
total: result.totalSize
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { useMapRecords };
|
|
@@ -125,7 +125,9 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
|
|
|
125
125
|
model,
|
|
126
126
|
view,
|
|
127
127
|
display: shown,
|
|
128
|
-
|
|
128
|
+
filter,
|
|
129
|
+
orderBy,
|
|
130
|
+
fields,
|
|
129
131
|
footer: false
|
|
130
132
|
}) : /* @__PURE__ */ jsx(CustomDisplay, {
|
|
131
133
|
display: shown,
|
package/dist/app/file-field.js
CHANGED
|
@@ -2,21 +2,27 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { Button } from "../primitives/button.js";
|
|
3
3
|
import { useClient } from "../data/api-provider.js";
|
|
4
4
|
import { humanize } from "../live/naming.js";
|
|
5
|
-
import { acceptOf, fileShown, formatSize, isPreviewable } from "../live/file-values.js";
|
|
5
|
+
import { acceptOf, fileShown, formatSize, isPreviewable, shownImage } from "../live/file-values.js";
|
|
6
6
|
import { ErrorBanner } from "./error-banner.js";
|
|
7
|
+
import { FileImage } from "../live/file-image.js";
|
|
7
8
|
import { useRef, useState } from "react";
|
|
8
9
|
import { FileText, Paperclip, Upload } from "lucide-react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
import { useMutation } from "@tanstack/react-query";
|
|
11
12
|
//#region src/app/file-field.tsx
|
|
12
|
-
const Thumbnail = ({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const Thumbnail = ({ value }) => {
|
|
14
|
+
const image = shownImage(value, {
|
|
15
|
+
width: 40,
|
|
16
|
+
height: 40
|
|
17
|
+
});
|
|
18
|
+
return image ? /* @__PURE__ */ jsx(FileImage, {
|
|
19
|
+
image,
|
|
20
|
+
className: "size-10 shrink-0 rounded border border-border object-cover"
|
|
21
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
22
|
+
className: "flex size-10 shrink-0 items-center justify-center rounded border border-border bg-muted text-muted-foreground",
|
|
23
|
+
children: /* @__PURE__ */ jsx(FileText, { className: "size-4" })
|
|
24
|
+
});
|
|
25
|
+
};
|
|
20
26
|
/** The file as it is: thumbnail or icon, name (a link while it has a URL), type and size, and what the server corrected. */
|
|
21
27
|
const FileSummary = ({ value }) => {
|
|
22
28
|
const shown = fileShown(value);
|
|
@@ -32,10 +38,7 @@ const FileSummary = ({ value }) => {
|
|
|
32
38
|
const corrected = "corrected" in shown ? shown.corrected : void 0;
|
|
33
39
|
return /* @__PURE__ */ jsxs("div", {
|
|
34
40
|
className: "flex min-w-0 items-center gap-3 py-1",
|
|
35
|
-
children: [/* @__PURE__ */ jsx(Thumbnail, {
|
|
36
|
-
url,
|
|
37
|
-
type: shown.type
|
|
38
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
41
|
+
children: [/* @__PURE__ */ jsx(Thumbnail, { value }), /* @__PURE__ */ jsxs("div", {
|
|
39
42
|
className: "min-w-0 text-[13px]",
|
|
40
43
|
children: [url && !url.startsWith("blob:") ? /* @__PURE__ */ jsx("a", {
|
|
41
44
|
href: url,
|
package/dist/app/list-page.js
CHANGED
|
@@ -99,7 +99,7 @@ const LoadedList = ({ model, address }) => {
|
|
|
99
99
|
orderBy
|
|
100
100
|
});
|
|
101
101
|
const display = shownDisplay(view, address.display);
|
|
102
|
-
const displayRows = useDisplayRows(model, rows.page, rows.key, display
|
|
102
|
+
const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
|
|
103
103
|
const scroll = useListScroll();
|
|
104
104
|
const titleField = recordTitleField(model, view);
|
|
105
105
|
const target = (row) => ({
|
|
@@ -273,7 +273,11 @@ const LoadedList = ({ model, address }) => {
|
|
|
273
273
|
model,
|
|
274
274
|
view,
|
|
275
275
|
display,
|
|
276
|
-
|
|
276
|
+
filter: parsed.ok ? filterText : "",
|
|
277
|
+
orderBy,
|
|
278
|
+
fields: loaded,
|
|
279
|
+
at: address.map,
|
|
280
|
+
onMove: (map) => set({ map })
|
|
277
281
|
});
|
|
278
282
|
return /* @__PURE__ */ jsx(CustomDisplay, {
|
|
279
283
|
display,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/map/map-area.ts
|
|
3
|
+
const where = createWhere();
|
|
4
|
+
/** The whole world from as far out as a map goes: what a map asks for before it knows where its records are. */
|
|
5
|
+
const worldArea = {
|
|
6
|
+
bounds: {
|
|
7
|
+
south: -90,
|
|
8
|
+
west: -180,
|
|
9
|
+
north: 90,
|
|
10
|
+
east: 180
|
|
11
|
+
},
|
|
12
|
+
zoom: 0
|
|
13
|
+
};
|
|
14
|
+
const maxZoom = 22;
|
|
15
|
+
const down = (degrees) => Math.floor(degrees * 1e6) / 1e6;
|
|
16
|
+
const up = (degrees) => Math.ceil(degrees * 1e6) / 1e6;
|
|
17
|
+
const wrapped = (longitude) => ((longitude + 180) % 360 + 360) % 360 - 180;
|
|
18
|
+
/**
|
|
19
|
+
* The AIP-160 filter for the records inside `bounds`: between its latitudes, and between its longitudes on the globe.
|
|
20
|
+
* A view across the antimeridian takes the longitudes on both sides of it, and one around the whole world takes them all.
|
|
21
|
+
*/
|
|
22
|
+
const boundsFilter = (fields, bounds) => {
|
|
23
|
+
const latitude = [where.gte(fields.latitude, down(Math.max(bounds.south, -90))), where.lte(fields.latitude, up(Math.min(bounds.north, 90)))];
|
|
24
|
+
if (bounds.east - bounds.west >= 360) return where.and(...latitude);
|
|
25
|
+
const west = where.gte(fields.longitude, down(wrapped(bounds.west)));
|
|
26
|
+
const east = where.lte(fields.longitude, up(wrapped(bounds.east)));
|
|
27
|
+
return wrapped(bounds.west) <= wrapped(bounds.east) ? where.and(...latitude, west, east) : where.and(...latitude, where.or(west, east));
|
|
28
|
+
};
|
|
29
|
+
/** The list's filter, its search included, narrowed to the records inside the map's bounds. */
|
|
30
|
+
const areaFilter = (listFilter, fields, bounds) => {
|
|
31
|
+
const inside = printFilter(boundsFilter(fields, bounds));
|
|
32
|
+
return listFilter.trim() === "" ? inside : `(${listFilter}) AND ${inside}`;
|
|
33
|
+
};
|
|
34
|
+
/** The zoom the records are asked for at: the whole zoom level the map is at. */
|
|
35
|
+
const requestZoom = (zoom) => Math.min(Math.max(Math.floor(zoom), 0), maxZoom);
|
|
36
|
+
/** The bounds around the records and the buckets, or none without either. */
|
|
37
|
+
const boundsAround = (points, buckets) => {
|
|
38
|
+
const corners = [...points.map((point) => ({
|
|
39
|
+
south: point.latitude,
|
|
40
|
+
west: point.longitude,
|
|
41
|
+
north: point.latitude,
|
|
42
|
+
east: point.longitude
|
|
43
|
+
})), ...buckets.map((bucket) => bucket.bounds)];
|
|
44
|
+
if (corners.length === 0) return void 0;
|
|
45
|
+
return {
|
|
46
|
+
south: Math.min(...corners.map((corner) => corner.south)),
|
|
47
|
+
west: Math.min(...corners.map((corner) => corner.west)),
|
|
48
|
+
north: Math.max(...corners.map((corner) => corner.north)),
|
|
49
|
+
east: Math.max(...corners.map((corner) => corner.east))
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
export { areaFilter, boundsAround, boundsFilter, requestZoom, worldArea };
|