@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.
Files changed (48) hide show
  1. package/dist/app/app.d.ts +2 -2
  2. package/dist/app/app.js +12 -9
  3. package/dist/app/current-address.d.ts +1 -0
  4. package/dist/app/current-address.js +2 -1
  5. package/dist/app/custom-section.js +1 -1
  6. package/dist/app/displays/custom-display.js +1 -1
  7. package/dist/app/displays/map-display.js +1 -1
  8. package/dist/app/displays/record-card.js +1 -1
  9. package/dist/app/gallery/gallery-items.js +37 -0
  10. package/dist/app/gallery/gallery-layout.js +17 -0
  11. package/dist/app/gallery/gallery-section.js +124 -0
  12. package/dist/app/gallery/lightbox.js +121 -0
  13. package/dist/app/gallery/photo-details.js +49 -0
  14. package/dist/app/gallery/use-gallery-photos.js +38 -0
  15. package/dist/app/pages/merge-ui.d.ts +1 -1
  16. package/dist/app/pages/merge-ui.js +12 -2
  17. package/dist/app/pages/project-ui.d.ts +24 -4
  18. package/dist/app/pages/project-ui.js +3 -3
  19. package/dist/app/pages/render-node.js +1 -1
  20. package/dist/app/record-lines.js +2 -2
  21. package/dist/app/record-mode/record-mode.js +31 -0
  22. package/dist/app/record-page.js +109 -28
  23. package/dist/app/use-live-save.js +4 -1
  24. package/dist/assistant-dock/widget-view.js +1 -1
  25. package/dist/auth/auth-gate.js +7 -1
  26. package/dist/auth/auth-provider.js +10 -0
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +4 -4
  29. package/dist/package.js +1 -1
  30. package/dist/plugins/before-sign-in.js +49 -0
  31. package/dist/plugins/plugin-gate.js +8 -8
  32. package/dist/plugins/plugin-source.js +32 -0
  33. package/dist/plugins/plugin-ui.js +5 -3
  34. package/dist/plugins/use-plugin-loads.js +3 -4
  35. package/dist/record-view/spec-table.js +25 -0
  36. package/dist/shell/assets/app-DYl-j-Nf.js +66 -0
  37. package/dist/shell/assets/index-BLnOMDD8.css +2 -0
  38. package/dist/shell/assets/{index-BXEkf4-O.js → index-Bes91xE3.js} +1 -1
  39. package/dist/shell/assets/{maplibre-DCGyNbM2.js → maplibre-BDe9GhVw.js} +2 -2
  40. package/dist/shell/assets/{record-map-DEn8Ulev.js → record-map-CY2LFq1z.js} +1 -1
  41. package/dist/shell/assets/{records-map-DdLiVxEp.js → records-map-B_DHVvmx.js} +1 -1
  42. package/dist/shell/assets/shared-protobase-ui-Bb2W4Il8.js +1 -0
  43. package/dist/shell/index.html +4 -4
  44. package/dist/shell/protobase.shell.json +1 -1
  45. package/package.json +4 -4
  46. package/dist/shell/assets/app-j50iAbLC.js +0 -66
  47. package/dist/shell/assets/index-D0AzKE47.css +0 -2
  48. 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 `/meta` names are loaded, for stories and tests; the page's `fetch` and `import()` by default. */
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, pluginLoader }) => {
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(AuthGate, {
278
- workspace,
279
- children: /* @__PURE__ */ jsx(Authenticated, {
277
+ children: /* @__PURE__ */ jsx(PluginSourceProvider, {
278
+ ...pluginLoader && { loader: pluginLoader },
279
+ children: /* @__PURE__ */ jsx(AuthGate, {
280
280
  workspace,
281
- ...rest
281
+ children: /* @__PURE__ */ jsx(Authenticated, {
282
+ workspace,
283
+ ...rest
284
+ })
282
285
  })
283
286
  })
284
287
  })
@@ -19,6 +19,7 @@ export type CurrentAddress = ListAddress | {
19
19
  kind: 'record';
20
20
  resource: string;
21
21
  key: string;
22
+ edit?: true;
22
23
  } | {
23
24
  kind: 'create';
24
25
  resource: string;
@@ -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 plugin's
51
- * `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in a
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 plugin's
7
- * `protobase.ui.tsx`: a plugin is a UI config the app loads at runtime, its components exported by name as in a
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. */