@protobase/ui 0.19.2 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/gallery/detail-fields.js +12 -0
- package/dist/app/gallery/gallery-layout.js +3 -1
- package/dist/app/gallery/gallery-section.js +7 -4
- package/dist/app/gallery/lightbox.js +38 -44
- package/dist/app/gallery/photo-details.js +21 -28
- package/dist/app/pages/record-actions.js +32 -6
- package/dist/app/record-mode/record-mode.js +12 -7
- package/dist/app/record-page.js +27 -38
- package/dist/format/date.js +2 -15
- package/dist/package.js +1 -1
- package/dist/record-view/action-rail.js +22 -11
- package/dist/record-view/record-header.js +5 -5
- package/dist/shell/assets/app-ChEqQ9ca.js +66 -0
- package/dist/shell/assets/{index-Crd6_WsY.js → index-5frCs7fQ.js} +1 -1
- package/dist/shell/assets/index-D-4IHhqX.css +2 -0
- package/dist/shell/assets/{maplibre-Gu5hHDDK.js → maplibre-D8plsjP2.js} +1 -1
- package/dist/shell/assets/{record-map-DQ0oyZff.js → record-map-BDJTOwZo.js} +1 -1
- package/dist/shell/assets/{records-map-Dp97bL3p.js → records-map-C-XKw6m1.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-DvvdY2OO.js → shared-protobase-ui-Ceamz_7l.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-Rbouc_gR.js +0 -66
- package/dist/shell/assets/index-DaFzUWmg.css +0 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/app/gallery/detail-fields.ts
|
|
2
|
+
/**
|
|
3
|
+
* The fields of a photo's details: those its record page shows in its sections, or without a layout every field but its
|
|
4
|
+
* key and tenant, less sensitive ones and those the gallery already shows: `omit`, such as the photo's file and the
|
|
5
|
+
* field pointing at the gallery's record.
|
|
6
|
+
*/
|
|
7
|
+
const detailFields = (model, view, omit) => {
|
|
8
|
+
const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
|
|
9
|
+
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 && !omit.includes(name));
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { detailFields };
|
|
@@ -13,5 +13,7 @@ const galleryTiles = (count) => {
|
|
|
13
13
|
const swipeStep = (dx, dy) => Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy) ? 0 : dx < 0 ? 1 : -1;
|
|
14
14
|
/** A key's step: the right arrow moves to the next photo, the left arrow to the previous one. */
|
|
15
15
|
const keyStep = (key) => key === "ArrowRight" ? 1 : key === "ArrowLeft" ? -1 : 0;
|
|
16
|
+
/** The photo a step from `index` lands on among `count`: it stops at the first and the last rather than wrapping around. */
|
|
17
|
+
const steppedPhoto = (index, by, count) => Math.min(Math.max(index + by, 0), Math.max(count - 1, 0));
|
|
16
18
|
//#endregion
|
|
17
|
-
export { galleryTiles, keyStep, swipeStep };
|
|
19
|
+
export { galleryTiles, keyStep, steppedPhoto, swipeStep };
|
|
@@ -8,6 +8,7 @@ import { FileImage } from "../../live/file-image.js";
|
|
|
8
8
|
import { Section } from "../../record-view/section.js";
|
|
9
9
|
import { galleryTiles } from "./gallery-layout.js";
|
|
10
10
|
import { Lightbox } from "./lightbox.js";
|
|
11
|
+
import { detailFields } from "./detail-fields.js";
|
|
11
12
|
import { PhotoDetails } from "./photo-details.js";
|
|
12
13
|
import { useGalleryPhotos } from "./use-gallery-photos.js";
|
|
13
14
|
import { useState } from "react";
|
|
@@ -47,7 +48,7 @@ const Tile = ({ photo, large }) => {
|
|
|
47
48
|
/**
|
|
48
49
|
* A record's photos as tiles, one large and the next ones smaller, the last counting those not drawn ("+N"); a mobile
|
|
49
50
|
* 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
|
|
51
|
+
* record of a resource the user may read has its details beside it there, as the record API answers them. A record
|
|
51
52
|
* without photos has no gallery.
|
|
52
53
|
*/
|
|
53
54
|
const GallerySection = ({ item, record, recordKey }) => {
|
|
@@ -71,13 +72,15 @@ const GallerySection = ({ item, record, recordKey }) => {
|
|
|
71
72
|
const { shown, more } = galleryTiles(count);
|
|
72
73
|
const holder = photos.holder;
|
|
73
74
|
const holderView = holder && views[holder.name];
|
|
74
|
-
const
|
|
75
|
+
const fields = holder ? detailFields(holder, holderView, [item.photos?.image, item.photos?.field].filter((name) => name !== void 0)) : [];
|
|
76
|
+
const readable = holder !== void 0 && photoPermissions.read !== false && fields.length > 0;
|
|
75
77
|
const details = (index) => {
|
|
76
78
|
const key = photos.items[index]?.key;
|
|
77
79
|
return readable && key !== void 0 ? /* @__PURE__ */ jsx(PhotoDetails, {
|
|
78
80
|
model: holder,
|
|
79
81
|
view: holderView,
|
|
80
|
-
photoKey: key
|
|
82
|
+
photoKey: key,
|
|
83
|
+
fields
|
|
81
84
|
}, key) : void 0;
|
|
82
85
|
};
|
|
83
86
|
return /* @__PURE__ */ jsxs(Section, {
|
|
@@ -116,7 +119,7 @@ const GallerySection = ({ item, record, recordKey }) => {
|
|
|
116
119
|
onIndex: setOpen,
|
|
117
120
|
onClose: () => setOpen(void 0),
|
|
118
121
|
details,
|
|
119
|
-
|
|
122
|
+
detailsTitle: holder && `${holderView?.names?.singular ?? humanize(holder.name)} details`
|
|
120
123
|
})]
|
|
121
124
|
});
|
|
122
125
|
};
|
|
@@ -2,25 +2,27 @@ import { cn } from "../../lib/cn.js";
|
|
|
2
2
|
import { useFocusTrap } from "../../lib/use-focus-trap.js";
|
|
3
3
|
import { shownImage } from "../../live/file-values.js";
|
|
4
4
|
import { FileImage } from "../../live/file-image.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ChevronLeft, ChevronRight, Info, X } from "lucide-react";
|
|
5
|
+
import { keyStep, steppedPhoto, swipeStep } from "./gallery-layout.js";
|
|
6
|
+
import { useRef } from "react";
|
|
7
|
+
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
9
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
9
|
//#region src/app/gallery/lightbox.tsx
|
|
11
10
|
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
11
|
/**
|
|
13
12
|
* 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
|
|
15
|
-
* read,
|
|
13
|
+
* that step through them, stopping at the first and the last, a sideways swipe on a touch screen, and, for a photo with details the user may
|
|
14
|
+
* read, those details in a sidebar beside it, or below it on a mobile screen, where the dialog scrolls down to them. The
|
|
15
|
+
* page does not scroll behind it, and focus returns to what opened it.
|
|
16
16
|
*/
|
|
17
|
-
const Lightbox = ({ title, photos, index, onIndex, onClose, details,
|
|
17
|
+
const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsTitle = "Details" }) => {
|
|
18
18
|
const panel = useRef(null);
|
|
19
19
|
const start = useRef(void 0);
|
|
20
|
-
const [showDetails, setShowDetails] = useState(false);
|
|
21
20
|
useFocusTrap(panel, true, onClose);
|
|
22
21
|
const count = photos.length;
|
|
23
|
-
const step = (by) =>
|
|
22
|
+
const step = (by) => {
|
|
23
|
+
const next = steppedPhoto(index, by, count);
|
|
24
|
+
if (next !== index) onIndex(next);
|
|
25
|
+
};
|
|
24
26
|
const image = shownImage(photos[index], {
|
|
25
27
|
width: 1600,
|
|
26
28
|
height: 1200
|
|
@@ -50,39 +52,26 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
50
52
|
"aria-modal": "true",
|
|
51
53
|
"aria-label": title,
|
|
52
54
|
onKeyDown: key,
|
|
53
|
-
className: "fixed inset-0 z-[90] flex flex-col bg-backdrop outline-none",
|
|
54
|
-
children: [
|
|
55
|
-
|
|
55
|
+
className: "fixed inset-0 z-[90] flex flex-col overflow-y-auto overscroll-contain bg-backdrop outline-none md:flex-row",
|
|
56
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
57
|
+
className: cn("flex shrink-0 flex-col md:sticky md:top-0 md:h-dvh md:min-w-0 md:flex-1", shownDetails === void 0 ? "h-dvh" : "h-[85svh]"),
|
|
58
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
56
59
|
className: "flex shrink-0 items-center justify-between gap-2 p-3",
|
|
57
60
|
children: [/* @__PURE__ */ jsx("span", {
|
|
58
61
|
className: "rounded-full bg-background/85 px-3 py-1 text-[13px] font-medium tabular-nums text-foreground",
|
|
59
62
|
"aria-live": "polite",
|
|
60
63
|
children: `${index + 1} / ${count}`
|
|
61
|
-
}), /* @__PURE__ */
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
})]
|
|
64
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
65
|
+
type: "button",
|
|
66
|
+
className: control,
|
|
67
|
+
"aria-label": "Close",
|
|
68
|
+
onClick: onClose,
|
|
69
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
70
|
+
className: "size-4",
|
|
71
|
+
"aria-hidden": true
|
|
72
|
+
})
|
|
83
73
|
})]
|
|
84
|
-
}),
|
|
85
|
-
/* @__PURE__ */ jsxs("div", {
|
|
74
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
86
75
|
className: "relative flex min-h-0 flex-1 touch-pan-y items-center justify-center px-3 pb-3 md:px-16",
|
|
87
76
|
onPointerDown: down,
|
|
88
77
|
onPointerUp: up,
|
|
@@ -92,8 +81,9 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
92
81
|
className: "max-h-full max-w-full select-none object-contain"
|
|
93
82
|
}, image.url), count > 1 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
94
83
|
type: "button",
|
|
95
|
-
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2"),
|
|
84
|
+
className: cn(control, "absolute left-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
96
85
|
"aria-label": "Previous photo",
|
|
86
|
+
"aria-disabled": index === 0,
|
|
97
87
|
onClick: () => step(-1),
|
|
98
88
|
children: /* @__PURE__ */ jsx(ChevronLeft, {
|
|
99
89
|
className: "size-5",
|
|
@@ -101,20 +91,24 @@ const Lightbox = ({ title, photos, index, onIndex, onClose, details, detailsLabe
|
|
|
101
91
|
})
|
|
102
92
|
}), /* @__PURE__ */ jsx("button", {
|
|
103
93
|
type: "button",
|
|
104
|
-
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2"),
|
|
94
|
+
className: cn(control, "absolute right-3 top-1/2 -translate-y-1/2 aria-disabled:cursor-default aria-disabled:opacity-40"),
|
|
105
95
|
"aria-label": "Next photo",
|
|
96
|
+
"aria-disabled": index === count - 1,
|
|
106
97
|
onClick: () => step(1),
|
|
107
98
|
children: /* @__PURE__ */ jsx(ChevronRight, {
|
|
108
99
|
className: "size-5",
|
|
109
100
|
"aria-hidden": true
|
|
110
101
|
})
|
|
111
102
|
})] })]
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
})]
|
|
104
|
+
}), shownDetails !== void 0 && /* @__PURE__ */ jsxs("aside", {
|
|
105
|
+
"aria-label": detailsTitle,
|
|
106
|
+
className: "shrink-0 border-t border-border bg-background p-5 text-foreground md:min-h-dvh md:w-[360px] md:border-l md:border-t-0",
|
|
107
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
108
|
+
className: "mb-3 text-[15px] font-semibold",
|
|
109
|
+
children: detailsTitle
|
|
110
|
+
}), shownDetails]
|
|
111
|
+
})]
|
|
118
112
|
});
|
|
119
113
|
};
|
|
120
114
|
//#endregion
|
|
@@ -6,17 +6,13 @@ import { ErrorBanner } from "../error-banner.js";
|
|
|
6
6
|
import { fieldAccess } from "../permissions.js";
|
|
7
7
|
import { SpecTable } from "../../record-view/spec-table.js";
|
|
8
8
|
import { FieldValue } from "../pages/field-value.js";
|
|
9
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
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
11
|
/**
|
|
16
|
-
* A photo's record, as the record API answers the user, so the server decides what they may read:
|
|
17
|
-
*
|
|
12
|
+
* A photo's record, as the record API answers the user, so the server decides what they may read: its `fields` (see
|
|
13
|
+
* `detailFields`), as label/value rows, and a link to its page.
|
|
18
14
|
*/
|
|
19
|
-
const PhotoDetails = ({ model, view, photoKey }) => {
|
|
15
|
+
const PhotoDetails = ({ model, view, photoKey, fields }) => {
|
|
20
16
|
const record = useRecord(model.name, photoKey);
|
|
21
17
|
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
22
18
|
if (record.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" });
|
|
@@ -24,26 +20,23 @@ const PhotoDetails = ({ model, view, photoKey }) => {
|
|
|
24
20
|
title: `Could not load the ${singular}`,
|
|
25
21
|
error: record.error
|
|
26
22
|
});
|
|
27
|
-
const shown =
|
|
28
|
-
return /* @__PURE__ */ jsxs(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
children: `Open ${singular}`
|
|
45
|
-
})]
|
|
46
|
-
});
|
|
23
|
+
const shown = fields.filter((name) => fieldAccess(record.data.permissions, name) !== "hidden");
|
|
24
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SpecTable, { rows: shown.map((name) => ({
|
|
25
|
+
label: view?.fields[name]?.label ?? humanize(name),
|
|
26
|
+
value: /* @__PURE__ */ jsx(FieldValue, {
|
|
27
|
+
model,
|
|
28
|
+
field: model.fields[name],
|
|
29
|
+
value: record.data.record[name]
|
|
30
|
+
})
|
|
31
|
+
})) }), /* @__PURE__ */ jsx(AppLink, {
|
|
32
|
+
to: {
|
|
33
|
+
kind: "record",
|
|
34
|
+
resource: model.name,
|
|
35
|
+
key: photoKey
|
|
36
|
+
},
|
|
37
|
+
className: "mt-3 inline-block text-[13px] font-medium text-primary-text hover:underline",
|
|
38
|
+
children: `Open ${singular}`
|
|
39
|
+
})] });
|
|
47
40
|
};
|
|
48
41
|
//#endregion
|
|
49
42
|
export { PhotoDetails };
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { useAppAddress } from "../use-app-address.js";
|
|
1
|
+
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
2
2
|
import { ActionRail } from "../../record-view/action-rail.js";
|
|
3
3
|
import { ConfirmAction } from "./confirm-action.js";
|
|
4
4
|
import { useActionRunner } from "./use-run-action.js";
|
|
5
5
|
import { actionIcon } from "../action-icon.js";
|
|
6
6
|
import { useState } from "react";
|
|
7
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
7
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
9
|
//#region src/app/pages/record-actions.tsx
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
|
|
12
|
+
* actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
|
|
13
|
+
* `onDelete`, unless a named action deletes already.
|
|
14
|
+
*/
|
|
15
|
+
const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
|
|
11
16
|
const { go } = useAppAddress();
|
|
12
17
|
const scope = {
|
|
13
18
|
model,
|
|
@@ -21,14 +26,35 @@ const RecordActions = ({ model, stored, recordKey }) => {
|
|
|
21
26
|
resource: model.name
|
|
22
27
|
}, { replace: true }));
|
|
23
28
|
const [asking, setAsking] = useState();
|
|
24
|
-
|
|
25
|
-
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions: runner.actions.map((action) => ({
|
|
29
|
+
const named = runner.actions.map((action) => ({
|
|
26
30
|
name: action.label,
|
|
27
31
|
icon: actionIcon(action.icon),
|
|
28
32
|
blocked: runner.blocked(action),
|
|
29
33
|
running: runner.running === action.name,
|
|
34
|
+
...action.run?.kind === "delete" && { tone: "danger" },
|
|
30
35
|
onRun: () => action.confirm ? setAsking(action) : void runner.run(action)
|
|
31
|
-
}))
|
|
36
|
+
}));
|
|
37
|
+
const deletes = runner.actions.some((action) => action.run?.kind === "delete");
|
|
38
|
+
const actions = [
|
|
39
|
+
...edit ? [{
|
|
40
|
+
name: "Edit",
|
|
41
|
+
icon: Pencil,
|
|
42
|
+
render: ({ className, children }) => /* @__PURE__ */ jsx(AppLink, {
|
|
43
|
+
to: edit,
|
|
44
|
+
className,
|
|
45
|
+
children
|
|
46
|
+
})
|
|
47
|
+
}] : [],
|
|
48
|
+
...named,
|
|
49
|
+
...onDelete && !deletes ? [{
|
|
50
|
+
name: "Delete",
|
|
51
|
+
icon: Trash2,
|
|
52
|
+
tone: "danger",
|
|
53
|
+
onRun: onDelete
|
|
54
|
+
}] : []
|
|
55
|
+
];
|
|
56
|
+
if (actions.length === 0) return null;
|
|
57
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
|
|
32
58
|
action: asking,
|
|
33
59
|
label: asking.label,
|
|
34
60
|
danger: asking.run?.kind === "delete",
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
//#region src/app/record-mode/record-mode.ts
|
|
2
2
|
/**
|
|
3
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`).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `editing` whether the record is being edited, which decides the help shown only while editing.
|
|
4
|
+
* asks for the form (`edit`). A user who may not change the record gets the read view in every mode, at any address.
|
|
5
|
+
* To one who may, always editable (`edit`, the default) shows the form and ignores `edit`; `read` and `locked` show the
|
|
6
|
+
* read view or the locked form until the address asks for the form. `offersEdit` is whether the page links to the
|
|
7
|
+
* form; `editing` whether the record is being edited, which decides the help shown only while editing.
|
|
8
8
|
*/
|
|
9
9
|
const recordShown = (mode, canUpdate, edit) => {
|
|
10
|
+
if (!canUpdate) return {
|
|
11
|
+
shown: "read",
|
|
12
|
+
offersEdit: false,
|
|
13
|
+
editing: false
|
|
14
|
+
};
|
|
10
15
|
if (mode === void 0 || mode === "edit") return {
|
|
11
|
-
shown:
|
|
16
|
+
shown: "form",
|
|
12
17
|
offersEdit: false,
|
|
13
|
-
editing:
|
|
18
|
+
editing: true
|
|
14
19
|
};
|
|
15
|
-
if (
|
|
20
|
+
if (edit) return {
|
|
16
21
|
shown: "form",
|
|
17
22
|
offersEdit: false,
|
|
18
23
|
editing: true
|
package/dist/app/record-page.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { inApp } from "../app-shell/breadcrumb.js";
|
|
3
3
|
import { useAuth } from "../auth/auth-provider.js";
|
|
4
|
-
import { formatRelativeTime } from "../format/date.js";
|
|
5
4
|
import { Button, buttonClass } from "../primitives/button.js";
|
|
6
5
|
import { keys } from "../data/query-keys.js";
|
|
7
6
|
import { useClient } from "../data/api-provider.js";
|
|
@@ -42,7 +41,7 @@ import { useRecordEditor } from "./use-record-editor.js";
|
|
|
42
41
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
43
42
|
import { RelatedSection } from "./related-section.js";
|
|
44
43
|
import { Fragment, useMemo } from "react";
|
|
45
|
-
import { Check
|
|
44
|
+
import { Check } from "lucide-react";
|
|
46
45
|
import { isApiError } from "@protobase/client";
|
|
47
46
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
48
47
|
import { useQueryClient } from "@tanstack/react-query";
|
|
@@ -50,9 +49,8 @@ import { enumLabel } from "@protobase/schema";
|
|
|
50
49
|
//#region src/app/record-page.tsx
|
|
51
50
|
const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
|
|
52
51
|
/**
|
|
53
|
-
* The view's layout, or one section with the editable fields when the view has none.
|
|
54
|
-
*
|
|
55
|
-
* view, which shows the record rather than what can be changed.
|
|
52
|
+
* The view's layout, or one section with the editable fields when the view has none. The read view, which is all a
|
|
53
|
+
* user who may only read the resource gets, shows every field: the record rather than what can be changed.
|
|
56
54
|
*/
|
|
57
55
|
const layoutFor = (model, view, read) => {
|
|
58
56
|
if (view && view.layout.length > 0) return view.layout;
|
|
@@ -148,9 +146,10 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
148
146
|
const { fields: fieldTypes } = useProjectUi();
|
|
149
147
|
const layout = layoutFor(model, view, shown === "read");
|
|
150
148
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
151
|
-
const
|
|
149
|
+
const hasStatus = model.fields.status?.type === "enum";
|
|
150
|
+
const statusValue = stored.record.status;
|
|
151
|
+
const status = hasStatus && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
|
|
152
152
|
const updatedAt = auditField(model, "updated");
|
|
153
|
-
const updated = updatedAt && stored.record[updatedAt] ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record[updatedAt]))) / 1e3)) : void 0;
|
|
154
153
|
const hint = (name) => view?.fields[name];
|
|
155
154
|
const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
|
|
156
155
|
const fieldView = (name) => {
|
|
@@ -251,30 +250,18 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
251
250
|
const save = async () => {
|
|
252
251
|
if (await editor.save() && leavesForm) go(readAddress, { replace: true });
|
|
253
252
|
};
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
snapshot: stored.record
|
|
262
|
-
}),
|
|
263
|
-
children: "Delete"
|
|
264
|
-
});
|
|
265
|
-
const saveControls = offersEdit ? /* @__PURE__ */ jsxs(Fragment$1, { children: [deleteButton, /* @__PURE__ */ jsxs(AppLink, {
|
|
266
|
-
to: editAddress,
|
|
267
|
-
className: cn(buttonClass({ variant: "primary" }), "max-md:min-h-11 max-md:flex-1"),
|
|
268
|
-
children: [/* @__PURE__ */ jsx(Pencil, {
|
|
269
|
-
className: "size-3.5",
|
|
270
|
-
"aria-hidden": true
|
|
271
|
-
}), "Edit"]
|
|
272
|
-
})] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
253
|
+
const startDelete = canDelete ? () => void remove.start({
|
|
254
|
+
key: recordKey,
|
|
255
|
+
title,
|
|
256
|
+
etag: stored.etag,
|
|
257
|
+
snapshot: stored.record
|
|
258
|
+
}) : void 0;
|
|
259
|
+
const formControls = shown === "form" && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
273
260
|
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
274
261
|
className: "text-xs text-muted-foreground",
|
|
275
262
|
children: "Unsaved changes"
|
|
276
263
|
}),
|
|
277
|
-
editor.dirty &&
|
|
264
|
+
editor.dirty && !leavesForm && /* @__PURE__ */ jsx(Button, {
|
|
278
265
|
className: "max-md:min-h-11",
|
|
279
266
|
disabled: editor.saving,
|
|
280
267
|
onClick: editor.discard,
|
|
@@ -286,8 +273,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
286
273
|
onClick: (event) => inApp(event) && editor.discard(),
|
|
287
274
|
children: "Cancel"
|
|
288
275
|
}),
|
|
289
|
-
|
|
290
|
-
canUpdate && /* @__PURE__ */ jsxs(Button, {
|
|
276
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
291
277
|
variant: "primary",
|
|
292
278
|
className: "max-md:min-h-11 max-md:flex-1",
|
|
293
279
|
loading: editor.saving,
|
|
@@ -306,16 +292,17 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
306
292
|
className: "mx-auto max-w-[1100px] px-4 pb-8 pt-6 md:px-10 md:py-8",
|
|
307
293
|
children: [
|
|
308
294
|
/* @__PURE__ */ jsx(RecordHeader, {
|
|
309
|
-
collection: view?.names?.plural ?? humanize(model.name),
|
|
310
295
|
title,
|
|
311
|
-
status: {
|
|
312
|
-
label:
|
|
313
|
-
tone:
|
|
296
|
+
status: status ? {
|
|
297
|
+
label: enumLabel(status, hint("status")?.valueLabels),
|
|
298
|
+
tone: toneFor(status)
|
|
299
|
+
} : hasStatus ? void 0 : {
|
|
300
|
+
label: singular,
|
|
301
|
+
tone: "neutral"
|
|
314
302
|
},
|
|
315
|
-
lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
|
|
316
303
|
actions: /* @__PURE__ */ jsx("div", {
|
|
317
304
|
className: "hidden items-center gap-4 md:flex",
|
|
318
|
-
children:
|
|
305
|
+
children: formControls
|
|
319
306
|
})
|
|
320
307
|
}),
|
|
321
308
|
editor.message && /* @__PURE__ */ jsx("div", {
|
|
@@ -411,7 +398,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
411
398
|
/* @__PURE__ */ jsx(RecordActions, {
|
|
412
399
|
model,
|
|
413
400
|
stored,
|
|
414
|
-
recordKey
|
|
401
|
+
recordKey,
|
|
402
|
+
edit: offersEdit ? editAddress : void 0,
|
|
403
|
+
onDelete: startDelete
|
|
415
404
|
}),
|
|
416
405
|
firstSidebar === -1 && summary,
|
|
417
406
|
layout.map((item, index) => {
|
|
@@ -448,9 +437,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
448
437
|
]
|
|
449
438
|
})
|
|
450
439
|
}),
|
|
451
|
-
|
|
440
|
+
formControls && /* @__PURE__ */ jsx("div", {
|
|
452
441
|
className: "sticky bottom-[var(--pb-sticky-bottom,0px)] z-20 flex shrink-0 items-center gap-2 border-t bg-background p-3 md:hidden",
|
|
453
|
-
children:
|
|
442
|
+
children: formControls
|
|
454
443
|
})
|
|
455
444
|
]
|
|
456
445
|
});
|
package/dist/format/date.js
CHANGED
|
@@ -19,19 +19,6 @@ const time = new Intl.DateTimeFormat("en-GB", {
|
|
|
19
19
|
const formatDate = (ms) => day.format(ms);
|
|
20
20
|
const formatDayShort = (ms) => dayShort.format(ms);
|
|
21
21
|
const formatDateTime = (ms) => `${day.format(ms)}, ${time.format(ms)}`;
|
|
22
|
-
|
|
23
|
-
const units = [
|
|
24
|
-
["year", 31536e3],
|
|
25
|
-
["month", 2592e3],
|
|
26
|
-
["day", 86400],
|
|
27
|
-
["hour", 3600],
|
|
28
|
-
["minute", 60]
|
|
29
|
-
];
|
|
30
|
-
/** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
|
|
31
|
-
const formatRelativeTime = (seconds) => {
|
|
32
|
-
const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
|
|
33
|
-
if (unit === "second" && seconds < 45) return "just now";
|
|
34
|
-
return relative.format(-Math.floor(seconds / size), unit);
|
|
35
|
-
};
|
|
22
|
+
new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
36
23
|
//#endregion
|
|
37
|
-
export { formatDate, formatDateTime, formatDayShort
|
|
24
|
+
export { formatDate, formatDateTime, formatDayShort };
|
package/dist/package.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
3
|
//#region src/record-view/action-rail.tsx
|
|
4
|
+
const itemClass = "flex min-h-11 items-center gap-2 rounded-md border border-border-strong px-3 text-left hover:bg-surface disabled:cursor-default disabled:opacity-60 md:min-h-0 md:w-full md:items-start md:gap-3 md:rounded-none md:border-0 md:px-4 md:py-3";
|
|
3
5
|
/** Named actions with one-line descriptions; on phones a row of buttons, from `md` a list. */
|
|
4
6
|
const ActionRail = ({ actions }) => /* @__PURE__ */ jsxs("div", {
|
|
5
7
|
className: "overflow-hidden rounded-lg border border-border bg-background",
|
|
@@ -8,20 +10,29 @@ const ActionRail = ({ actions }) => /* @__PURE__ */ jsxs("div", {
|
|
|
8
10
|
children: "Actions"
|
|
9
11
|
}), /* @__PURE__ */ jsx("ul", {
|
|
10
12
|
className: "flex flex-wrap gap-2 p-2 md:block md:divide-y md:divide-border md:p-0",
|
|
11
|
-
children: actions.map(({ name, description, icon: Icon, onRun, blocked, running }) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
children: [/* @__PURE__ */ jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground md:mt-0.5" }), /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("span", {
|
|
18
|
-
className: "block text-[13px] font-medium text-foreground",
|
|
13
|
+
children: actions.map(({ name, description, icon: Icon, onRun, blocked, running, tone, render }) => {
|
|
14
|
+
const children = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Icon, {
|
|
15
|
+
className: cn("size-4 shrink-0 md:mt-0.5", tone === "danger" ? "text-danger-text" : "text-muted-foreground"),
|
|
16
|
+
"aria-hidden": true
|
|
17
|
+
}), /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("span", {
|
|
18
|
+
className: cn("block text-[13px] font-medium", tone === "danger" ? "text-danger-text" : "text-foreground"),
|
|
19
19
|
children: name
|
|
20
20
|
}), description && /* @__PURE__ */ jsx("span", {
|
|
21
21
|
className: "mt-0.5 hidden text-xs leading-snug text-muted-foreground md:block",
|
|
22
22
|
children: description
|
|
23
|
-
})] })]
|
|
24
|
-
|
|
23
|
+
})] })] });
|
|
24
|
+
return /* @__PURE__ */ jsx("li", { children: render ? render({
|
|
25
|
+
className: itemClass,
|
|
26
|
+
children
|
|
27
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
28
|
+
type: "button",
|
|
29
|
+
onClick: onRun,
|
|
30
|
+
disabled: Boolean(blocked) || running,
|
|
31
|
+
title: blocked,
|
|
32
|
+
className: itemClass,
|
|
33
|
+
children
|
|
34
|
+
}) }, name);
|
|
35
|
+
})
|
|
25
36
|
})]
|
|
26
37
|
});
|
|
27
38
|
//#endregion
|
|
@@ -5,21 +5,21 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
const RecordHeader = ({ collection, title, status, lastEditedBy, presence = [], actions }) => /* @__PURE__ */ jsxs("header", {
|
|
6
6
|
className: "flex flex-col gap-4 border-b border-border pb-6 md:flex-row md:items-end md:justify-between md:gap-6",
|
|
7
7
|
children: [/* @__PURE__ */ jsxs("div", { children: [
|
|
8
|
-
/* @__PURE__ */ jsx("p", {
|
|
9
|
-
className: "text-xs text-muted-foreground",
|
|
8
|
+
collection && /* @__PURE__ */ jsx("p", {
|
|
9
|
+
className: "mb-1 text-xs text-muted-foreground",
|
|
10
10
|
children: collection
|
|
11
11
|
}),
|
|
12
12
|
/* @__PURE__ */ jsxs("div", {
|
|
13
|
-
className: "
|
|
13
|
+
className: "flex flex-wrap items-center gap-x-3 gap-y-1",
|
|
14
14
|
children: [/* @__PURE__ */ jsx("h1", {
|
|
15
15
|
className: "text-xl font-semibold tracking-tight text-foreground md:text-2xl",
|
|
16
16
|
children: title
|
|
17
|
-
}), /* @__PURE__ */ jsx(Badge, {
|
|
17
|
+
}), status && /* @__PURE__ */ jsx(Badge, {
|
|
18
18
|
tone: status.tone,
|
|
19
19
|
children: status.label
|
|
20
20
|
})]
|
|
21
21
|
}),
|
|
22
|
-
/* @__PURE__ */ jsx("p", {
|
|
22
|
+
lastEditedBy && /* @__PURE__ */ jsx("p", {
|
|
23
23
|
className: "mt-1.5 text-xs text-muted-foreground",
|
|
24
24
|
children: lastEditedBy.name ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
25
25
|
"Last edited by ",
|