@protobase/ui 0.17.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.
- package/dist/app/current-address.d.ts +1 -0
- package/dist/app/current-address.js +2 -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/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/package.js +1 -1
- package/dist/record-view/spec-table.js +25 -0
- package/dist/shell/assets/app-BS7ufaBT.js +66 -0
- package/dist/shell/assets/index-BLnOMDD8.css +2 -0
- package/dist/shell/assets/{index-DNc-xM_v.js → index-CLXqLf4p.js} +1 -1
- package/dist/shell/assets/{maplibre-1qVldvCU.js → maplibre-r-WEXdlV.js} +1 -1
- package/dist/shell/assets/{record-map-BPHuPMlK.js → record-map-Bvls7qdn.js} +1 -1
- package/dist/shell/assets/{records-map-BORhj6IH.js → records-map-iNBfciYy.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-CHRI5o6a.js → shared-protobase-ui-9PLdWR62.js} +1 -1
- 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-BSKdnId2.js +0 -66
- package/dist/shell/assets/index-D0AzKE47.css +0 -2
|
@@ -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 };
|
package/dist/app/record-lines.js
CHANGED
|
@@ -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 };
|