@protobase/ui 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -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 };
@@ -14,7 +14,7 @@ const toLine = (record) => ({
14
14
  unitPriceCents: Math.round(Number(record.unitPrice) * 100)
15
15
  });
16
16
  /** The lines of a record. Reordering only changes `draft`; the record's Save writes it, in one batch. */
17
- const RecordLines = ({ lines, draft, vatRate, saving }) => {
17
+ const RecordLines = ({ lines, draft, vatRate, saving, locked = false }) => {
18
18
  const canReorder = can(usePermissions(lines.name), "update");
19
19
  return /* @__PURE__ */ jsx(Section, {
20
20
  title: "Lines",
@@ -29,7 +29,7 @@ const RecordLines = ({ lines, draft, vatRate, saving }) => {
29
29
  onReorder: (next) => draft.setOrder(next.map((line) => line.id)),
30
30
  vatRate,
31
31
  readOnly: true,
32
- reorderable: canReorder && !saving
32
+ reorderable: canReorder && !saving && !locked
33
33
  })
34
34
  })
35
35
  })
@@ -0,0 +1,31 @@
1
+ //#region src/app/record-mode/record-mode.ts
2
+ /**
3
+ * What the record page shows a user, by the view's `mode`, whether they may change the record and whether its address
4
+ * asks for the form (`edit`). Always editable (`edit`, the default) shows the form, locked to a user who may not change
5
+ * it, and ignores `edit`; `read` and `locked` show the read view or the locked form until the address asks for the
6
+ * form, which only a user who may change the record gets. `offersEdit` is whether the page links to the form;
7
+ * `editing` whether the record is being edited, which decides the help shown only while editing.
8
+ */
9
+ const recordShown = (mode, canUpdate, edit) => {
10
+ if (mode === void 0 || mode === "edit") return {
11
+ shown: canUpdate ? "form" : "locked",
12
+ offersEdit: false,
13
+ editing: canUpdate
14
+ };
15
+ if (canUpdate && edit) return {
16
+ shown: "form",
17
+ offersEdit: false,
18
+ editing: true
19
+ };
20
+ return {
21
+ shown: mode,
22
+ offersEdit: canUpdate,
23
+ editing: false
24
+ };
25
+ };
26
+ /** Whether a help line shows: always, unless it, or else the view, says only while editing and the record is not. */
27
+ const helpShown = (own, view, editing) => (own ?? view?.helpWhen ?? "always") === "always" || editing;
28
+ /** A field's help line on the record page, when it shows. */
29
+ const fieldHelp = (hints, view, editing) => hints?.help !== void 0 && helpShown(hints.helpWhen, view, editing) ? hints.help : void 0;
30
+ //#endregion
31
+ export { fieldHelp, helpShown, recordShown };