@protobase/ui 0.18.0 → 0.19.1
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/app.d.ts +2 -2
- package/dist/app/app.js +12 -9
- package/dist/app/current-address.d.ts +1 -0
- package/dist/app/current-address.js +2 -1
- package/dist/app/custom-section.js +1 -1
- package/dist/app/displays/custom-display.js +1 -1
- package/dist/app/displays/map-display.js +1 -1
- package/dist/app/displays/record-card.js +1 -1
- package/dist/app/gallery/gallery-items.js +37 -0
- package/dist/app/gallery/gallery-layout.js +17 -0
- package/dist/app/gallery/gallery-section.js +124 -0
- package/dist/app/gallery/lightbox.js +121 -0
- package/dist/app/gallery/photo-details.js +49 -0
- package/dist/app/gallery/use-gallery-photos.js +38 -0
- package/dist/app/pages/merge-ui.d.ts +1 -1
- package/dist/app/pages/merge-ui.js +12 -2
- package/dist/app/pages/project-ui.d.ts +24 -4
- package/dist/app/pages/project-ui.js +3 -3
- package/dist/app/pages/render-node.js +1 -1
- package/dist/app/record-lines.js +2 -2
- package/dist/app/record-mode/record-mode.js +31 -0
- package/dist/app/record-page.js +109 -28
- package/dist/app/use-live-save.js +4 -1
- package/dist/assistant-dock/widget-view.js +1 -1
- package/dist/auth/auth-gate.js +7 -1
- package/dist/auth/auth-provider.js +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/package.js +1 -1
- package/dist/plugins/before-sign-in.js +49 -0
- package/dist/plugins/plugin-gate.js +8 -8
- package/dist/plugins/plugin-source.js +32 -0
- package/dist/plugins/plugin-ui.js +5 -3
- package/dist/plugins/use-plugin-loads.js +3 -4
- package/dist/record-view/spec-table.js +25 -0
- package/dist/shell/assets/app-DYl-j-Nf.js +66 -0
- package/dist/shell/assets/index-BLnOMDD8.css +2 -0
- package/dist/shell/assets/{index-BXEkf4-O.js → index-Bes91xE3.js} +1 -1
- package/dist/shell/assets/{maplibre-DCGyNbM2.js → maplibre-BDe9GhVw.js} +2 -2
- package/dist/shell/assets/{record-map-DEn8Ulev.js → record-map-CY2LFq1z.js} +1 -1
- package/dist/shell/assets/{records-map-DdLiVxEp.js → records-map-B_DHVvmx.js} +1 -1
- package/dist/shell/assets/shared-protobase-ui-Bb2W4Il8.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-j50iAbLC.js +0 -66
- package/dist/shell/assets/index-D0AzKE47.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-zMOavpbt.js +0 -1
package/dist/app/app.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type AppProps = {
|
|
|
25
25
|
assistant?: AssistantClient;
|
|
26
26
|
/** A ready-made client of the runtime endpoint, for stories and tests; used only when `/meta` names one. */
|
|
27
27
|
runtime?: RuntimeClient;
|
|
28
|
-
/** How the plugins
|
|
28
|
+
/** How the plugins are loaded, for stories and tests; the page's `fetch` and `import()` by default. */
|
|
29
29
|
pluginLoader?: PluginLoader;
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
@@ -33,5 +33,5 @@ export type AppProps = {
|
|
|
33
33
|
* then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its
|
|
34
34
|
* parent, in the theme this browser chose, light, dark or the system's.
|
|
35
35
|
*/
|
|
36
|
-
export declare const App: ({ auth, workspace, basePath, initialUrl, ...rest }: AppProps) => import("react").JSX.Element;
|
|
36
|
+
export declare const App: ({ auth, workspace, basePath, initialUrl, pluginLoader, ...rest }: AppProps) => import("react").JSX.Element;
|
|
37
37
|
//#endregion
|
package/dist/app/app.js
CHANGED
|
@@ -7,6 +7,9 @@ import { ApiProvider } from "../data/api-provider.js";
|
|
|
7
7
|
import { Router, useRouter } from "./router.js";
|
|
8
8
|
import { useSignInAddress } from "../auth/use-sign-in-address.js";
|
|
9
9
|
import { managesOrganization, organizationMenuItems, organizationPageTitles } from "../organizations/organization-paths.js";
|
|
10
|
+
import { startErrorReporting } from "./error-reporting.js";
|
|
11
|
+
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
12
|
+
import { PluginSourceProvider } from "../plugins/plugin-source.js";
|
|
10
13
|
import { AuthGate } from "../auth/auth-gate.js";
|
|
11
14
|
import { isAdmin, userToShell } from "../auth/user-to-shell.js";
|
|
12
15
|
import { useRecord } from "../data/use-record.js";
|
|
@@ -14,7 +17,6 @@ import { recordTitle } from "../live/model-helpers.js";
|
|
|
14
17
|
import { humanize } from "../live/naming.js";
|
|
15
18
|
import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
|
|
16
19
|
import { ToastProvider } from "../toasts/toast-provider.js";
|
|
17
|
-
import { startErrorReporting } from "./error-reporting.js";
|
|
18
20
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
19
21
|
import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
|
|
20
22
|
import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
|
|
@@ -22,7 +24,6 @@ import { breadcrumbFor } from "./breadcrumb-for.js";
|
|
|
22
24
|
import { sourceStatusOf } from "../data/with-sources.js";
|
|
23
25
|
import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
|
|
24
26
|
import { useAppAddress } from "./use-app-address.js";
|
|
25
|
-
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
26
27
|
import { CreatePage } from "./create/create-page.js";
|
|
27
28
|
import { ListPage } from "./list-page.js";
|
|
28
29
|
import { MembersPage } from "../organizations/members-page.js";
|
|
@@ -244,7 +245,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
244
245
|
]
|
|
245
246
|
});
|
|
246
247
|
};
|
|
247
|
-
const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, assistant, runtime
|
|
248
|
+
const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, assistant, runtime }) => {
|
|
248
249
|
const { state, session, markSignedOut } = useAuth();
|
|
249
250
|
return /* @__PURE__ */ jsx(ApiProvider, {
|
|
250
251
|
baseUrl,
|
|
@@ -254,7 +255,6 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
|
|
|
254
255
|
onUnauthenticated: markSignedOut,
|
|
255
256
|
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(PluginGate, {
|
|
256
257
|
ui,
|
|
257
|
-
...pluginLoader && { loader: pluginLoader },
|
|
258
258
|
children: /* @__PURE__ */ jsx(Routes, {
|
|
259
259
|
workspace,
|
|
260
260
|
sidebarMode,
|
|
@@ -269,16 +269,19 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
|
|
|
269
269
|
* then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its
|
|
270
270
|
* parent, in the theme this browser chose, light, dark or the system's.
|
|
271
271
|
*/
|
|
272
|
-
const App = ({ auth, workspace, basePath, initialUrl, ...rest }) => /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(AuthProvider, {
|
|
272
|
+
const App = ({ auth, workspace, basePath, initialUrl, pluginLoader, ...rest }) => /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(AuthProvider, {
|
|
273
273
|
session: auth,
|
|
274
274
|
children: /* @__PURE__ */ jsx(Router, {
|
|
275
275
|
basePath,
|
|
276
276
|
initialUrl,
|
|
277
|
-
children: /* @__PURE__ */ jsx(
|
|
278
|
-
|
|
279
|
-
children: /* @__PURE__ */ jsx(
|
|
277
|
+
children: /* @__PURE__ */ jsx(PluginSourceProvider, {
|
|
278
|
+
...pluginLoader && { loader: pluginLoader },
|
|
279
|
+
children: /* @__PURE__ */ jsx(AuthGate, {
|
|
280
280
|
workspace,
|
|
281
|
-
|
|
281
|
+
children: /* @__PURE__ */ jsx(Authenticated, {
|
|
282
|
+
workspace,
|
|
283
|
+
...rest
|
|
284
|
+
})
|
|
282
285
|
})
|
|
283
286
|
})
|
|
284
287
|
})
|
|
@@ -31,7 +31,8 @@ const currentAddress = (read, defaults) => {
|
|
|
31
31
|
if (read?.kind === "record") return {
|
|
32
32
|
kind: "record",
|
|
33
33
|
resource: read.resource,
|
|
34
|
-
key: encodeKey(read.key)
|
|
34
|
+
key: encodeKey(read.key),
|
|
35
|
+
...read.edit && { edit: true }
|
|
35
36
|
};
|
|
36
37
|
if (read?.kind !== "list") return read;
|
|
37
38
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useProjectUi } from "./pages/project-ui.js";
|
|
2
1
|
import { Problem } from "./pages/problem.js";
|
|
3
2
|
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
3
|
+
import { useProjectUi } from "./pages/project-ui.js";
|
|
4
4
|
import { Section } from "../record-view/section.js";
|
|
5
5
|
import { SidebarCard } from "../record-view/record-sidebar.js";
|
|
6
6
|
import { RecordScope } from "./pages/record-scope.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useProjectUi } from "../pages/project-ui.js";
|
|
2
1
|
import { Problem } from "../pages/problem.js";
|
|
3
2
|
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
3
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
4
|
import { ListDisplayProvider } from "./display-context.js";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { unmarkFieldRefs } from "@protobase/schema/internal";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { Problem } from "../pages/problem.js";
|
|
3
4
|
import { humanize } from "../../live/naming.js";
|
|
4
5
|
import { mapCards } from "./list-displays.js";
|
|
5
6
|
import { useAdminMeta } from "../meta-gate.js";
|
|
6
7
|
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
-
import { Problem } from "../pages/problem.js";
|
|
8
8
|
import { useDebounced } from "../../lib/use-debounced.js";
|
|
9
9
|
import { RecordCard } from "./record-card.js";
|
|
10
10
|
import { boundsAround, worldArea } from "../map/map-area.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Badge } from "../../primitives/badge.js";
|
|
2
|
+
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
2
3
|
import { recordKey } from "../../live/model-helpers.js";
|
|
3
4
|
import { humanize } from "../../live/naming.js";
|
|
4
5
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
@@ -6,7 +7,6 @@ import { displayValue } from "../../live/display-value.js";
|
|
|
6
7
|
import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
|
|
7
8
|
import { AppLink } from "../use-app-address.js";
|
|
8
9
|
import { shownWidget } from "../field-types/shown-widget.js";
|
|
9
|
-
import { CustomBoundary } from "../pages/custom-boundary.js";
|
|
10
10
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
11
11
|
import { namedIcon } from "../named-icon.js";
|
|
12
12
|
import { FavoriteButton } from "../saved/favorite-button.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isStoredFile, shownImage } from "../../live/file-values.js";
|
|
2
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/app/gallery/gallery-items.ts
|
|
4
|
+
const isPicture = (value) => shownImage(value, {
|
|
5
|
+
width: 0,
|
|
6
|
+
height: 0
|
|
7
|
+
}) !== void 0;
|
|
8
|
+
const keyOf = (holder, row) => {
|
|
9
|
+
const parts = holder.primaryKey.map((name) => row[name]);
|
|
10
|
+
if (parts.some((part) => part === null || part === void 0)) return void 0;
|
|
11
|
+
return encodeKey(parts);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The photos a gallery steps through: the record's own `cover` first, then the `image` of each of the photo records
|
|
15
|
+
* `rows`, less the one the cover already shows, whose record the cover takes; empty values, missing files and files
|
|
16
|
+
* that are no image are left out.
|
|
17
|
+
*/
|
|
18
|
+
const galleryItems = (cover, rows, image, holder) => {
|
|
19
|
+
const photos = image === void 0 ? [] : rows.flatMap((row) => {
|
|
20
|
+
const file = row[image];
|
|
21
|
+
if (!isStoredFile(file) || !isPicture(file)) return [];
|
|
22
|
+
const key = holder && keyOf(holder, row);
|
|
23
|
+
return [{
|
|
24
|
+
file,
|
|
25
|
+
...key !== void 0 && { key }
|
|
26
|
+
}];
|
|
27
|
+
});
|
|
28
|
+
if (!isStoredFile(cover) || !isPicture(cover)) return photos;
|
|
29
|
+
const same = (item) => isStoredFile(item.file) && item.file.uri === cover.uri;
|
|
30
|
+
const key = photos.find(same)?.key;
|
|
31
|
+
return [{
|
|
32
|
+
file: cover,
|
|
33
|
+
...key !== void 0 && { key }
|
|
34
|
+
}, ...photos.filter((item) => !same(item))];
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { galleryItems };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many of `count` photos a gallery draws as tiles, and how many more its last tile counts ("+N"): five at most,
|
|
3
|
+
* three of four so the large tile keeps its two rows beside two wide ones, every photo when there are fewer.
|
|
4
|
+
*/
|
|
5
|
+
const galleryTiles = (count) => {
|
|
6
|
+
const shown = count >= 5 ? 5 : count === 4 ? 3 : count;
|
|
7
|
+
return {
|
|
8
|
+
shown,
|
|
9
|
+
more: count - shown
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/** A swipe's step: a mostly sideways drag of 40 pixels or more moves to the next photo leftwards, the previous rightwards. */
|
|
13
|
+
const swipeStep = (dx, dy) => Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy) ? 0 : dx < 0 ? 1 : -1;
|
|
14
|
+
/** A key's step: the right arrow moves to the next photo, the left arrow to the previous one. */
|
|
15
|
+
const keyStep = (key) => key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { galleryTiles, keyStep, swipeStep };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { shownImage } from "../../live/file-values.js";
|
|
5
|
+
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { FileImage } from "../../live/file-image.js";
|
|
8
|
+
import { Section } from "../../record-view/section.js";
|
|
9
|
+
import { galleryTiles } from "./gallery-layout.js";
|
|
10
|
+
import { Lightbox } from "./lightbox.js";
|
|
11
|
+
import { PhotoDetails } from "./photo-details.js";
|
|
12
|
+
import { useGalleryPhotos } from "./use-gallery-photos.js";
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { Images } from "lucide-react";
|
|
15
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
+
//#region src/app/gallery/gallery-section.tsx
|
|
17
|
+
const spans = {
|
|
18
|
+
1: ["md:col-span-4 md:row-span-2"],
|
|
19
|
+
2: ["md:col-span-2 md:row-span-2", "md:col-span-2 md:row-span-2"],
|
|
20
|
+
3: [
|
|
21
|
+
"md:col-span-2 md:row-span-2",
|
|
22
|
+
"md:col-span-2",
|
|
23
|
+
"md:col-span-2"
|
|
24
|
+
],
|
|
25
|
+
5: [
|
|
26
|
+
"md:col-span-2 md:row-span-2",
|
|
27
|
+
"",
|
|
28
|
+
"",
|
|
29
|
+
"",
|
|
30
|
+
""
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
const Tile = ({ photo, large }) => {
|
|
34
|
+
const image = shownImage(photo, large ? {
|
|
35
|
+
width: 960,
|
|
36
|
+
height: 720
|
|
37
|
+
} : {
|
|
38
|
+
width: 480,
|
|
39
|
+
height: 360
|
|
40
|
+
});
|
|
41
|
+
return image ? /* @__PURE__ */ jsx(FileImage, {
|
|
42
|
+
image,
|
|
43
|
+
loading: "lazy",
|
|
44
|
+
className: "size-full object-cover transition-transform duration-300 group-hover/tile:scale-[1.02]"
|
|
45
|
+
}) : null;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* A record's photos as tiles, one large and the next ones smaller, the last counting those not drawn ("+N"); a mobile
|
|
49
|
+
* 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
|
+
* without photos has no gallery.
|
|
52
|
+
*/
|
|
53
|
+
const GallerySection = ({ item, record, recordKey }) => {
|
|
54
|
+
const { views } = useAdminMeta();
|
|
55
|
+
const photos = useGalleryPhotos(item, record, recordKey);
|
|
56
|
+
const photoPermissions = usePermissions(photos.holder?.name ?? "");
|
|
57
|
+
const [open, setOpen] = useState();
|
|
58
|
+
if (photos.isPending) return /* @__PURE__ */ jsx(Section, {
|
|
59
|
+
title: item.title,
|
|
60
|
+
children: /* @__PURE__ */ jsx(Skeleton, { className: "aspect-[4/3] w-full rounded-lg md:aspect-[2/1]" })
|
|
61
|
+
});
|
|
62
|
+
if (photos.error) return /* @__PURE__ */ jsx(Section, {
|
|
63
|
+
title: item.title,
|
|
64
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
65
|
+
title: "Could not load the photos",
|
|
66
|
+
error: photos.error
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
const count = photos.items.length;
|
|
70
|
+
if (count === 0) return null;
|
|
71
|
+
const { shown, more } = galleryTiles(count);
|
|
72
|
+
const holder = photos.holder;
|
|
73
|
+
const holderView = holder && views[holder.name];
|
|
74
|
+
const readable = holder !== void 0 && photoPermissions.read !== false;
|
|
75
|
+
const details = (index) => {
|
|
76
|
+
const key = photos.items[index]?.key;
|
|
77
|
+
return readable && key !== void 0 ? /* @__PURE__ */ jsx(PhotoDetails, {
|
|
78
|
+
model: holder,
|
|
79
|
+
view: holderView,
|
|
80
|
+
photoKey: key
|
|
81
|
+
}, key) : void 0;
|
|
82
|
+
};
|
|
83
|
+
return /* @__PURE__ */ jsxs(Section, {
|
|
84
|
+
title: item.title,
|
|
85
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
86
|
+
className: "grid aspect-[4/3] grid-cols-4 grid-rows-2 gap-1.5 overflow-hidden rounded-lg md:aspect-[2/1]",
|
|
87
|
+
children: photos.items.slice(0, shown).map((photo, index) => {
|
|
88
|
+
const last = index === shown - 1 && more > 0;
|
|
89
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
90
|
+
type: "button",
|
|
91
|
+
"aria-label": last ? `Show all ${count} photos` : `Show photo ${index + 1} of ${count}`,
|
|
92
|
+
onClick: () => setOpen(index),
|
|
93
|
+
className: cn("group/tile relative overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary", index === 0 ? "col-span-4 row-span-2" : "max-md:hidden", spans[shown][index]),
|
|
94
|
+
children: [
|
|
95
|
+
/* @__PURE__ */ jsx(Tile, {
|
|
96
|
+
photo: photo.file,
|
|
97
|
+
large: index === 0
|
|
98
|
+
}),
|
|
99
|
+
last && /* @__PURE__ */ jsx("span", {
|
|
100
|
+
className: "absolute inset-0 flex items-center justify-center bg-backdrop/55 text-lg font-semibold text-white",
|
|
101
|
+
"aria-hidden": true,
|
|
102
|
+
children: `+${more}`
|
|
103
|
+
}),
|
|
104
|
+
index === 0 && count > 1 && /* @__PURE__ */ jsxs("span", {
|
|
105
|
+
className: "absolute bottom-2 right-2 flex items-center gap-1.5 rounded-full bg-background/85 px-2.5 py-1 text-xs font-medium tabular-nums text-foreground md:hidden",
|
|
106
|
+
"aria-hidden": true,
|
|
107
|
+
children: [/* @__PURE__ */ jsx(Images, { className: "size-3.5" }), count]
|
|
108
|
+
})
|
|
109
|
+
]
|
|
110
|
+
}, index);
|
|
111
|
+
})
|
|
112
|
+
}), open !== void 0 && /* @__PURE__ */ jsx(Lightbox, {
|
|
113
|
+
title: item.title,
|
|
114
|
+
photos: photos.items.map((photo) => photo.file),
|
|
115
|
+
index: open,
|
|
116
|
+
onIndex: setOpen,
|
|
117
|
+
onClose: () => setOpen(void 0),
|
|
118
|
+
details,
|
|
119
|
+
detailsLabel: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
|
|
120
|
+
})]
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { GallerySection };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { useFocusTrap } from "../../lib/use-focus-trap.js";
|
|
3
|
+
import { shownImage } from "../../live/file-values.js";
|
|
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";
|
|
9
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/app/gallery/lightbox.tsx
|
|
11
|
+
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
|
+
/**
|
|
13
|
+
* 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.
|
|
16
|
+
*/
|
|
17
|
+
const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabel = "Details" }) => {
|
|
18
|
+
const panel = useRef(null);
|
|
19
|
+
const start = useRef(void 0);
|
|
20
|
+
const [showDetails, setShowDetails] = useState(false);
|
|
21
|
+
useFocusTrap(panel, true, onClose);
|
|
22
|
+
const count = photos.length;
|
|
23
|
+
const step = (by) => by !== 0 && onIndex(shownPhoto(index + by, count));
|
|
24
|
+
const image = shownImage(photos[index], {
|
|
25
|
+
width: 1600,
|
|
26
|
+
height: 1200
|
|
27
|
+
});
|
|
28
|
+
const shownDetails = details?.(index);
|
|
29
|
+
const key = (event) => {
|
|
30
|
+
const by = keyStep(event.key);
|
|
31
|
+
if (by === 0) return;
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
step(by);
|
|
34
|
+
};
|
|
35
|
+
const down = (event) => {
|
|
36
|
+
if (event.pointerType === "touch") start.current = {
|
|
37
|
+
x: event.clientX,
|
|
38
|
+
y: event.clientY
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const up = (event) => {
|
|
42
|
+
if (!start.current) return;
|
|
43
|
+
step(swipeStep(event.clientX - start.current.x, event.clientY - start.current.y));
|
|
44
|
+
start.current = void 0;
|
|
45
|
+
};
|
|
46
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
47
|
+
ref: panel,
|
|
48
|
+
tabIndex: -1,
|
|
49
|
+
role: "dialog",
|
|
50
|
+
"aria-modal": "true",
|
|
51
|
+
"aria-label": title,
|
|
52
|
+
onKeyDown: key,
|
|
53
|
+
className: "fixed inset-0 z-[90] flex flex-col bg-backdrop outline-none",
|
|
54
|
+
children: [
|
|
55
|
+
/* @__PURE__ */ jsxs("div", {
|
|
56
|
+
className: "flex shrink-0 items-center justify-between gap-2 p-3",
|
|
57
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
58
|
+
className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
|
|
59
|
+
"aria-live": "polite",
|
|
60
|
+
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
|
+
})]
|
|
83
|
+
})]
|
|
84
|
+
}),
|
|
85
|
+
/* @__PURE__ */ jsxs("div", {
|
|
86
|
+
className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
|
|
87
|
+
onPointerDown: down,
|
|
88
|
+
onPointerUp: up,
|
|
89
|
+
onPointerCancel: () => start.current = void 0,
|
|
90
|
+
children: [image && /* @__PURE__ */ jsx(FileImage, {
|
|
91
|
+
image,
|
|
92
|
+
className: "max-h-full max-w-full select-none object-contain"
|
|
93
|
+
}, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
94
|
+
type: "button",
|
|
95
|
+
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2"),
|
|
96
|
+
"aria-label": "Previous photo",
|
|
97
|
+
onClick: () => step(-1),
|
|
98
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
99
|
+
className: "size-5",
|
|
100
|
+
"aria-hidden": true
|
|
101
|
+
})
|
|
102
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
103
|
+
type: "button",
|
|
104
|
+
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2"),
|
|
105
|
+
"aria-label": "Next photo",
|
|
106
|
+
onClick: () => step(1),
|
|
107
|
+
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
108
|
+
className: "size-5",
|
|
109
|
+
"aria-hidden": true
|
|
110
|
+
})
|
|
111
|
+
})] })]
|
|
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
|
+
]
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
export { Lightbox };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { useRecord } from "../../data/use-record.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { AppLink } from "../use-app-address.js";
|
|
5
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
6
|
+
import { fieldAccess } from "../permissions.js";
|
|
7
|
+
import { SpecTable } from "../../record-view/spec-table.js";
|
|
8
|
+
import { FieldValue } from "../pages/field-value.js";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
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
|
+
/**
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
const PhotoDetails = ({ model, view, photoKey }) => {
|
|
20
|
+
const record = useRecord(model.name, photoKey);
|
|
21
|
+
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
22
|
+
if (record.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" });
|
|
23
|
+
if (record.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
24
|
+
title: `Could not load the ${singular}`,
|
|
25
|
+
error: record.error
|
|
26
|
+
});
|
|
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
|
+
});
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { PhotoDetails };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { useClient } from "../../data/api-provider.js";
|
|
3
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
4
|
+
import { galleryItems } from "./gallery-items.js";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
7
|
+
//#region src/app/gallery/use-gallery-photos.ts
|
|
8
|
+
const where = createWhere();
|
|
9
|
+
/**
|
|
10
|
+
* A gallery's photos: the record's own image, then the photo records that point at it, read with the list API under
|
|
11
|
+
* the user's own access, so its row filters and readable fields apply. Without the photos' resource in `/meta`, the
|
|
12
|
+
* user cannot list them, and the record's image is all there is.
|
|
13
|
+
*/
|
|
14
|
+
const useGalleryPhotos = (item, record, recordKey) => {
|
|
15
|
+
const client = useClient();
|
|
16
|
+
const { resources } = useAdminMeta();
|
|
17
|
+
const holder = item.photos ? resources[item.photos.resource] : void 0;
|
|
18
|
+
const filter = item.photos ? printFilter(where.eq(item.photos.field, recordKey)) : "";
|
|
19
|
+
const sort = item.photos?.sort ?? "";
|
|
20
|
+
const rows = useQuery({
|
|
21
|
+
queryKey: keys.list(holder?.name ?? "", filter, sort, 500, "gallery"),
|
|
22
|
+
enabled: holder !== void 0,
|
|
23
|
+
queryFn: async () => (await client.list(holder.name, {
|
|
24
|
+
filter,
|
|
25
|
+
...sort && { orderBy: sort },
|
|
26
|
+
pageSize: 500
|
|
27
|
+
})).items
|
|
28
|
+
});
|
|
29
|
+
const cover = item.image === void 0 ? void 0 : record[item.image];
|
|
30
|
+
return {
|
|
31
|
+
holder,
|
|
32
|
+
items: galleryItems(cover, rows.data ?? [], item.photos?.image, holder),
|
|
33
|
+
isPending: holder !== void 0 && rows.isPending,
|
|
34
|
+
error: rows.error
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { useGalleryPhotos };
|
|
@@ -3,7 +3,7 @@ import { ProjectUi } from "./project-ui.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
5
5
|
* handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
6
|
-
* is drawn, the extended configs' first.
|
|
6
|
+
* and `beforeSignIn` component is drawn, the extended configs' first.
|
|
7
7
|
*/
|
|
8
8
|
export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
|
|
9
9
|
//#endregion
|
|
@@ -25,10 +25,18 @@ const slot = (slots) => {
|
|
|
25
25
|
const All = () => slots.map((Slot, index) => /* @__PURE__ */ jsx(Slot, {}, index));
|
|
26
26
|
return All;
|
|
27
27
|
};
|
|
28
|
+
const around = (wrappers) => {
|
|
29
|
+
if (wrappers.length <= 1) return wrappers[0];
|
|
30
|
+
const All = ({ children, ...given }) => wrappers.reduceRight((inner, Wrapper) => /* @__PURE__ */ jsx(Wrapper, {
|
|
31
|
+
...given,
|
|
32
|
+
children: inner
|
|
33
|
+
}), children);
|
|
34
|
+
return All;
|
|
35
|
+
};
|
|
28
36
|
/**
|
|
29
37
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
30
38
|
* handlers and field types are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
31
|
-
* is drawn, the extended configs' first.
|
|
39
|
+
* and `beforeSignIn` component is drawn, the extended configs' first.
|
|
32
40
|
*/
|
|
33
41
|
const mergeUi = (...uis) => {
|
|
34
42
|
const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
|
|
@@ -37,6 +45,7 @@ const mergeUi = (...uis) => {
|
|
|
37
45
|
const fields = union("The field type", named, (ui) => ui.fields);
|
|
38
46
|
const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
|
|
39
47
|
const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
|
|
48
|
+
const beforeSignIn = around(named.flatMap(({ ui }) => ui.beforeSignIn ? [ui.beforeSignIn] : []));
|
|
40
49
|
const name = uis.at(-1)?.name;
|
|
41
50
|
return {
|
|
42
51
|
...name && { name },
|
|
@@ -46,7 +55,8 @@ const mergeUi = (...uis) => {
|
|
|
46
55
|
...(actionSlot || rightPanel) && { shell: {
|
|
47
56
|
...actionSlot && { actions: actionSlot },
|
|
48
57
|
...rightPanel && { rightPanel }
|
|
49
|
-
} }
|
|
58
|
+
} },
|
|
59
|
+
...beforeSignIn && { beforeSignIn }
|
|
50
60
|
};
|
|
51
61
|
};
|
|
52
62
|
//#endregion
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FieldWidget } from "../field-types/field-widget.js";
|
|
2
|
-
import { ComponentType } from "react";
|
|
2
|
+
import { ComponentType, ReactNode } from "react";
|
|
3
3
|
import { Client } from "@protobase/client";
|
|
4
4
|
import { AppAddress } from "@protobase/schema";
|
|
5
5
|
//#region src/app/pages/project-ui.d.ts
|
|
@@ -19,6 +19,21 @@ export type ActionContext = {
|
|
|
19
19
|
navigate: (to: AppAddress) => void;
|
|
20
20
|
};
|
|
21
21
|
export type ActionHandler = (context: ActionContext) => void | Promise<void>;
|
|
22
|
+
/** What a plugin's `beforeSignIn` component gets from the app, drawn around the pages someone signed out sees. */
|
|
23
|
+
export type BeforeSignInProps = {
|
|
24
|
+
/** What the app shows someone signed out: the sign-in page, or the app a visitor browses. */
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Enters the app with a session something other than the sign-in page started, such as an endpoint that traded a
|
|
28
|
+
* one-time code for the session cookie: reads the session again and shows the app signed in. Resolves `false`, and
|
|
29
|
+
* changes nothing, when there is no session.
|
|
30
|
+
*/
|
|
31
|
+
signedIn: () => Promise<boolean>;
|
|
32
|
+
/** The query parameters of the page's address, such as a one-time code a link carries. */
|
|
33
|
+
params: URLSearchParams;
|
|
34
|
+
/** Takes these query parameters, such as a used one-time code, out of the address, in place of it in the tab's history. */
|
|
35
|
+
removeParams: (names: string[]) => void;
|
|
36
|
+
};
|
|
22
37
|
/**
|
|
23
38
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
24
39
|
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
|
|
@@ -38,6 +53,11 @@ export type ProjectUi = {
|
|
|
38
53
|
actions?: ComponentType;
|
|
39
54
|
rightPanel?: ComponentType;
|
|
40
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Drawn around the pages someone signed out sees, only for a plugin the app loads before sign-in (`beforeSignIn` in
|
|
58
|
+
* `options.plugins`): its code runs for them, and it can sign them in (see `BeforeSignInProps`).
|
|
59
|
+
*/
|
|
60
|
+
beforeSignIn?: ComponentType<BeforeSignInProps>;
|
|
41
61
|
};
|
|
42
62
|
/** A `protobase.ui.tsx` module as `import * as` gives it: components exported by name, the rest as the default export. */
|
|
43
63
|
export type UiModule = {
|
|
@@ -47,9 +67,9 @@ export type UiModule = {
|
|
|
47
67
|
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
48
68
|
export declare const defineUi: (ui: ProjectUi) => ProjectUi;
|
|
49
69
|
/**
|
|
50
|
-
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell })` in a
|
|
51
|
-
* `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in
|
|
52
|
-
* project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
70
|
+
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell, beforeSignIn })` in a
|
|
71
|
+
* plugin's `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in
|
|
72
|
+
* a project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
53
73
|
*/
|
|
54
74
|
export declare const definePlugin: (plugin: ProjectUi) => ProjectUi;
|
|
55
75
|
//#endregion
|
|
@@ -3,9 +3,9 @@ import { createContext, useContext } from "react";
|
|
|
3
3
|
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
4
4
|
const defineUi = (ui) => ui;
|
|
5
5
|
/**
|
|
6
|
-
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell })` in a
|
|
7
|
-
* `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in
|
|
8
|
-
* project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
6
|
+
* Typed identity, for `export default definePlugin({ name, components, actions, fields, shell, beforeSignIn })` in a
|
|
7
|
+
* plugin's `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in
|
|
8
|
+
* a project's. A project's own protobase.ui.tsx is one too, which `protobase build` builds as the plugin `app`.
|
|
9
9
|
*/
|
|
10
10
|
const definePlugin = (plugin) => plugin;
|
|
11
11
|
/** The project's UI, merged with the UI configs it extends and the plugins loaded; `PluginGate` or `ProjectUiProvider` provides it. */
|