vestrycms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +383 -2
- package/dist/config/fields.d.ts +30 -0
- package/dist/config/fields.js +75 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +18 -0
- package/dist/config/types.d.ts +412 -0
- package/dist/config/types.js +10 -0
- package/dist/core/api.d.ts +67 -0
- package/dist/core/api.js +543 -0
- package/dist/core/auth.d.ts +1597 -0
- package/dist/core/auth.js +264 -0
- package/dist/core/cache.d.ts +27 -0
- package/dist/core/cache.js +52 -0
- package/dist/core/entries.d.ts +19 -0
- package/dist/core/entries.js +127 -0
- package/dist/core/index-types.d.ts +20 -0
- package/dist/core/index-types.js +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/mail.d.ts +28 -0
- package/dist/core/mail.js +62 -0
- package/dist/core/paths.d.ts +35 -0
- package/dist/core/paths.js +148 -0
- package/dist/core/roles.d.ts +24 -0
- package/dist/core/roles.js +15 -0
- package/dist/core/service.d.ts +102 -0
- package/dist/core/service.js +478 -0
- package/dist/core/source.d.ts +82 -0
- package/dist/core/source.js +75 -0
- package/dist/core/sources/github-app.d.ts +26 -0
- package/dist/core/sources/github-app.js +161 -0
- package/dist/core/sources/github.d.ts +40 -0
- package/dist/core/sources/github.js +278 -0
- package/dist/core/sources/local.d.ts +16 -0
- package/dist/core/sources/local.js +98 -0
- package/dist/core/values.d.ts +22 -0
- package/dist/core/values.js +123 -0
- package/dist/env.d.ts +19 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +167 -0
- package/dist/integration/build-index.d.ts +8 -0
- package/dist/integration/build-index.js +67 -0
- package/dist/integration/client-deps.d.ts +10 -0
- package/dist/integration/client-deps.js +43 -0
- package/dist/integration/dev-middleware.d.ts +6 -0
- package/dist/integration/dev-middleware.js +48 -0
- package/dist/integration/formatter.d.ts +7 -0
- package/dist/integration/formatter.js +56 -0
- package/dist/mdx/expressions.d.ts +14 -0
- package/dist/mdx/expressions.js +141 -0
- package/dist/mdx/index.d.ts +4 -0
- package/dist/mdx/index.js +4 -0
- package/dist/mdx/parse.d.ts +6 -0
- package/dist/mdx/parse.js +401 -0
- package/dist/mdx/serialize.d.ts +9 -0
- package/dist/mdx/serialize.js +485 -0
- package/dist/mdx/types.d.ts +42 -0
- package/dist/mdx/types.js +6 -0
- package/dist/pages/admin.astro +27 -0
- package/dist/pages/api.d.ts +3 -0
- package/dist/pages/api.js +31 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +70 -0
- package/dist/ui/api.d.ts +83 -0
- package/dist/ui/api.js +97 -0
- package/dist/ui/auth-client.d.ts +27 -0
- package/dist/ui/auth-client.js +29 -0
- package/dist/ui/components/AssetPicker.d.ts +27 -0
- package/dist/ui/components/AssetPicker.js +97 -0
- package/dist/ui/components/Icon.d.ts +11 -0
- package/dist/ui/components/Icon.js +46 -0
- package/dist/ui/components/Layout.d.ts +15 -0
- package/dist/ui/components/Layout.js +87 -0
- package/dist/ui/components/editor/EditorContext.d.ts +15 -0
- package/dist/ui/components/editor/EditorContext.js +9 -0
- package/dist/ui/components/editor/NodeViews.d.ts +4 -0
- package/dist/ui/components/editor/NodeViews.js +127 -0
- package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
- package/dist/ui/components/editor/RichTextEditor.js +230 -0
- package/dist/ui/components/editor/extensions.d.ts +30 -0
- package/dist/ui/components/editor/extensions.js +248 -0
- package/dist/ui/components/editor/schema.d.ts +12 -0
- package/dist/ui/components/editor/schema.js +37 -0
- package/dist/ui/components/fields/CodeInput.d.ts +10 -0
- package/dist/ui/components/fields/CodeInput.js +8 -0
- package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
- package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
- package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
- package/dist/ui/components/fields/FieldRenderer.js +299 -0
- package/dist/ui/components/fields/ImageTools.d.ts +31 -0
- package/dist/ui/components/fields/ImageTools.js +296 -0
- package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
- package/dist/ui/components/fields/MarkdownField.js +16 -0
- package/dist/ui/components/fields/TreeInput.d.ts +13 -0
- package/dist/ui/components/fields/TreeInput.js +221 -0
- package/dist/ui/components/ui.d.ts +63 -0
- package/dist/ui/components/ui.js +113 -0
- package/dist/ui/context.d.ts +17 -0
- package/dist/ui/context.js +13 -0
- package/dist/ui/lib/format.d.ts +5 -0
- package/dist/ui/lib/format.js +11 -0
- package/dist/ui/lib/utils.d.ts +24 -0
- package/dist/ui/lib/utils.js +255 -0
- package/dist/ui/main.d.ts +1 -0
- package/dist/ui/main.js +11 -0
- package/dist/ui/pages/Assets.d.ts +1 -0
- package/dist/ui/pages/Assets.js +69 -0
- package/dist/ui/pages/CollectionList.d.ts +6 -0
- package/dist/ui/pages/CollectionList.js +115 -0
- package/dist/ui/pages/Dashboard.d.ts +1 -0
- package/dist/ui/pages/Dashboard.js +57 -0
- package/dist/ui/pages/EntryEditor.d.ts +3 -0
- package/dist/ui/pages/EntryEditor.js +313 -0
- package/dist/ui/pages/Login.d.ts +6 -0
- package/dist/ui/pages/Login.js +88 -0
- package/dist/ui/pages/Users.d.ts +1 -0
- package/dist/ui/pages/Users.js +102 -0
- package/dist/ui/styles.css +2554 -0
- package/package.json +123 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Crosshair, Maximize2, RotateCcw } from "lucide-react";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
4
|
+
import { useVestry } from "../../context.js";
|
|
5
|
+
import { cx } from "../../lib/utils.js";
|
|
6
|
+
import { Button } from "../ui.js";
|
|
7
|
+
/** Resolve the sibling image field's value to a URL the browser can load. */
|
|
8
|
+
function useImageSource(imagePath, pendingUploads) {
|
|
9
|
+
const { api } = useVestry();
|
|
10
|
+
if (typeof imagePath !== "string" || !imagePath)
|
|
11
|
+
return null;
|
|
12
|
+
const pending = pendingUploads?.[imagePath];
|
|
13
|
+
if (pending)
|
|
14
|
+
return pending.previewUrl;
|
|
15
|
+
return /^https?:\/\//.test(imagePath) ? imagePath : api.fileUrl(imagePath);
|
|
16
|
+
}
|
|
17
|
+
/** Load the image, track its natural size, and fit it into the available width. */
|
|
18
|
+
function useFittedImage(src, maxHeight) {
|
|
19
|
+
const containerRef = useRef(null);
|
|
20
|
+
const [natural, setNatural] = useState(null);
|
|
21
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
22
|
+
const [error, setError] = useState(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setNatural(null);
|
|
25
|
+
setError(false);
|
|
26
|
+
if (!src)
|
|
27
|
+
return;
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.onload = () => setNatural({ width: img.naturalWidth, height: img.naturalHeight });
|
|
30
|
+
img.onerror = () => setError(true);
|
|
31
|
+
img.src = src;
|
|
32
|
+
}, [src]);
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
const el = containerRef.current;
|
|
35
|
+
if (!el)
|
|
36
|
+
return;
|
|
37
|
+
const update = () => setContainerWidth(el.clientWidth);
|
|
38
|
+
update();
|
|
39
|
+
const ro = new ResizeObserver(update);
|
|
40
|
+
ro.observe(el);
|
|
41
|
+
return () => ro.disconnect();
|
|
42
|
+
}, [src]);
|
|
43
|
+
let fitted = null;
|
|
44
|
+
if (natural && containerWidth > 0) {
|
|
45
|
+
const scale = Math.min(containerWidth / natural.width, maxHeight / natural.height, 1);
|
|
46
|
+
fitted = {
|
|
47
|
+
natural,
|
|
48
|
+
display: {
|
|
49
|
+
width: Math.round(natural.width * scale),
|
|
50
|
+
height: Math.round(natural.height * scale),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { containerRef, fitted, error };
|
|
55
|
+
}
|
|
56
|
+
/** Dragging a handle must not start a text selection elsewhere on the page. */
|
|
57
|
+
const preventSelection = (e) => e.preventDefault();
|
|
58
|
+
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
59
|
+
/** Largest rectangle of the given aspect that fits the image, centred. */
|
|
60
|
+
export function defaultCrop(natural, aspect) {
|
|
61
|
+
if (!aspect)
|
|
62
|
+
return { x: 0, y: 0, width: natural.width, height: natural.height };
|
|
63
|
+
let width = natural.width;
|
|
64
|
+
let height = Math.round(width / aspect);
|
|
65
|
+
if (height > natural.height) {
|
|
66
|
+
height = natural.height;
|
|
67
|
+
width = Math.round(height * aspect);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
x: Math.round((natural.width - width) / 2),
|
|
71
|
+
y: Math.round((natural.height - height) / 2),
|
|
72
|
+
width,
|
|
73
|
+
height,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function normalizeCrop(rect, natural, aspect, minSize) {
|
|
77
|
+
let width = clamp(Math.round(rect.width), minSize, natural.width);
|
|
78
|
+
let height = clamp(Math.round(rect.height), minSize, natural.height);
|
|
79
|
+
if (aspect) {
|
|
80
|
+
height = Math.round(width / aspect);
|
|
81
|
+
if (height > natural.height) {
|
|
82
|
+
height = natural.height;
|
|
83
|
+
width = Math.round(height * aspect);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const x = clamp(Math.round(rect.x), 0, natural.width - width);
|
|
87
|
+
const y = clamp(Math.round(rect.y), 0, natural.height - height);
|
|
88
|
+
return { x, y, width, height };
|
|
89
|
+
}
|
|
90
|
+
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
91
|
+
export function CropInput({ field, value, onChange, siblings, pendingUploads, }) {
|
|
92
|
+
const src = useImageSource(siblings?.[field.image], pendingUploads);
|
|
93
|
+
const { containerRef, fitted, error } = useFittedImage(src, 440);
|
|
94
|
+
const minSize = field.minSize ?? 16;
|
|
95
|
+
const aspect = field.aspect;
|
|
96
|
+
const rect = isCropRect(value) ? value : null;
|
|
97
|
+
const drag = useRef(null);
|
|
98
|
+
// Once the image is known, seed a default crop or clamp an out-of-range one.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!fitted)
|
|
101
|
+
return;
|
|
102
|
+
if (!rect) {
|
|
103
|
+
onChange(defaultCrop(fitted.natural, aspect));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const fixed = normalizeCrop(rect, fitted.natural, aspect, minSize);
|
|
107
|
+
if (fixed.x !== rect.x ||
|
|
108
|
+
fixed.y !== rect.y ||
|
|
109
|
+
fixed.width !== rect.width ||
|
|
110
|
+
fixed.height !== rect.height)
|
|
111
|
+
onChange(fixed);
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
+
}, [fitted?.natural.width, fitted?.natural.height]);
|
|
114
|
+
const scale = fitted ? fitted.display.width / fitted.natural.width : 1;
|
|
115
|
+
const onPointerDown = (mode) => (e) => {
|
|
116
|
+
if (!rect || !fitted)
|
|
117
|
+
return;
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
121
|
+
drag.current = {
|
|
122
|
+
mode,
|
|
123
|
+
start: { x: e.clientX, y: e.clientY },
|
|
124
|
+
origin: rect,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const onPointerMove = (e) => {
|
|
128
|
+
const d = drag.current;
|
|
129
|
+
if (!d || !fitted)
|
|
130
|
+
return;
|
|
131
|
+
const dx = (e.clientX - d.start.x) / scale;
|
|
132
|
+
const dy = (e.clientY - d.start.y) / scale;
|
|
133
|
+
const o = d.origin;
|
|
134
|
+
let next;
|
|
135
|
+
if (d.mode === "move") {
|
|
136
|
+
next = { ...o, x: o.x + dx, y: o.y + dy };
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
let { x, y, width, height } = o;
|
|
140
|
+
if (d.mode.includes("e"))
|
|
141
|
+
width = o.width + dx;
|
|
142
|
+
if (d.mode.includes("s"))
|
|
143
|
+
height = o.height + dy;
|
|
144
|
+
if (d.mode.includes("w")) {
|
|
145
|
+
width = o.width - dx;
|
|
146
|
+
x = o.x + dx;
|
|
147
|
+
}
|
|
148
|
+
if (d.mode.includes("n")) {
|
|
149
|
+
height = o.height - dy;
|
|
150
|
+
y = o.y + dy;
|
|
151
|
+
}
|
|
152
|
+
if (aspect) {
|
|
153
|
+
// Drive the locked dimension from whichever one the handle moves most.
|
|
154
|
+
const horizontal = d.mode === "e" || d.mode === "w";
|
|
155
|
+
const vertical = d.mode === "n" || d.mode === "s";
|
|
156
|
+
if (vertical || (!horizontal && Math.abs(dy) > Math.abs(dx)))
|
|
157
|
+
width = height * aspect;
|
|
158
|
+
else
|
|
159
|
+
height = width / aspect;
|
|
160
|
+
if (d.mode.includes("w"))
|
|
161
|
+
x = o.x + o.width - width;
|
|
162
|
+
if (d.mode.includes("n"))
|
|
163
|
+
y = o.y + o.height - height;
|
|
164
|
+
}
|
|
165
|
+
next = {
|
|
166
|
+
x,
|
|
167
|
+
y,
|
|
168
|
+
width: Math.max(minSize, width),
|
|
169
|
+
height: Math.max(minSize, height),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
onChange(normalizeCrop(next, fitted.natural, aspect, minSize));
|
|
173
|
+
};
|
|
174
|
+
const onPointerUp = () => {
|
|
175
|
+
drag.current = null;
|
|
176
|
+
};
|
|
177
|
+
const onKeyDown = (e) => {
|
|
178
|
+
if (!rect || !fitted)
|
|
179
|
+
return;
|
|
180
|
+
const step = e.shiftKey ? 10 : 1;
|
|
181
|
+
const moves = {
|
|
182
|
+
ArrowLeft: [-step, 0],
|
|
183
|
+
ArrowRight: [step, 0],
|
|
184
|
+
ArrowUp: [0, -step],
|
|
185
|
+
ArrowDown: [0, step],
|
|
186
|
+
};
|
|
187
|
+
const m = moves[e.key];
|
|
188
|
+
if (!m)
|
|
189
|
+
return;
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
onChange(normalizeCrop({ ...rect, x: rect.x + m[0], y: rect.y + m[1] }, fitted.natural, aspect, minSize));
|
|
192
|
+
};
|
|
193
|
+
if (!src)
|
|
194
|
+
return (_jsxs("div", { className: "v-image-tool__empty", children: ["Choose an image in \u201C", field.image, "\u201D first."] }));
|
|
195
|
+
if (error)
|
|
196
|
+
return (_jsx("div", { className: "v-image-tool__empty", children: "The image could not be loaded." }));
|
|
197
|
+
const disp = rect && fitted
|
|
198
|
+
? {
|
|
199
|
+
left: rect.x * scale,
|
|
200
|
+
top: rect.y * scale,
|
|
201
|
+
width: rect.width * scale,
|
|
202
|
+
height: rect.height * scale,
|
|
203
|
+
}
|
|
204
|
+
: null;
|
|
205
|
+
return (_jsxs("div", { className: "v-image-tool", children: [_jsxs("div", { ref: containerRef, className: "v-image-tool__stage", children: [fitted && (_jsxs("div", { className: "v-crop", style: {
|
|
206
|
+
width: fitted.display.width,
|
|
207
|
+
height: fitted.display.height,
|
|
208
|
+
}, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, children: [_jsx("img", { src: src, alt: "", draggable: false, style: {
|
|
209
|
+
width: fitted.display.width,
|
|
210
|
+
height: fitted.display.height,
|
|
211
|
+
} }), disp && (_jsxs(_Fragment, { children: [_jsx("div", { className: "v-crop__shade", style: { top: 0, left: 0, right: 0, height: disp.top } }), _jsx("div", { className: "v-crop__shade", style: {
|
|
212
|
+
top: disp.top + disp.height,
|
|
213
|
+
left: 0,
|
|
214
|
+
right: 0,
|
|
215
|
+
bottom: 0,
|
|
216
|
+
} }), _jsx("div", { className: "v-crop__shade", style: {
|
|
217
|
+
top: disp.top,
|
|
218
|
+
left: 0,
|
|
219
|
+
width: disp.left,
|
|
220
|
+
height: disp.height,
|
|
221
|
+
} }), _jsx("div", { className: "v-crop__shade", style: {
|
|
222
|
+
top: disp.top,
|
|
223
|
+
left: disp.left + disp.width,
|
|
224
|
+
right: 0,
|
|
225
|
+
height: disp.height,
|
|
226
|
+
} }), _jsxs("div", { className: "v-crop__box", style: {
|
|
227
|
+
left: disp.left,
|
|
228
|
+
top: disp.top,
|
|
229
|
+
width: disp.width,
|
|
230
|
+
height: disp.height,
|
|
231
|
+
}, tabIndex: 0, role: "slider", "aria-label": "Crop area", onPointerDown: onPointerDown("move"), onMouseDown: preventSelection, onKeyDown: onKeyDown, children: [_jsx("div", { className: "v-crop__grid" }), HANDLES.map((h) => (_jsx("div", { className: cx("v-crop__handle", `v-crop__handle--${h}`), onPointerDown: onPointerDown(h), onMouseDown: preventSelection }, h)))] })] }))] })), !fitted && _jsx("div", { className: "v-image-tool__loading", children: "Loading image\u2026" })] }), _jsxs("div", { className: "v-image-tool__bar", children: [_jsxs("span", { className: "v-mono v-muted", children: [rect
|
|
232
|
+
? `${rect.width} × ${rect.height} px at ${rect.x}, ${rect.y}`
|
|
233
|
+
: "—", fitted &&
|
|
234
|
+
` · image ${fitted.natural.width} × ${fitted.natural.height}`, aspect && ` · ${formatAspect(aspect)}`] }), _jsx("span", { className: "v-grow" }), _jsxs(Button, { size: "xs", onClick: () => fitted && onChange(defaultCrop(fitted.natural, aspect)), disabled: !fitted, title: "Largest crop, centred", children: [_jsx(Maximize2, {}), " Fit"] })] })] }));
|
|
235
|
+
}
|
|
236
|
+
function isCropRect(v) {
|
|
237
|
+
return (typeof v === "object" &&
|
|
238
|
+
v !== null &&
|
|
239
|
+
["x", "y", "width", "height"].every((k) => typeof v[k] === "number"));
|
|
240
|
+
}
|
|
241
|
+
function formatAspect(aspect) {
|
|
242
|
+
for (const [w, h] of [
|
|
243
|
+
[1, 1],
|
|
244
|
+
[4, 3],
|
|
245
|
+
[3, 4],
|
|
246
|
+
[3, 2],
|
|
247
|
+
[2, 3],
|
|
248
|
+
[16, 9],
|
|
249
|
+
[9, 16],
|
|
250
|
+
[8, 9],
|
|
251
|
+
[21, 9],
|
|
252
|
+
[1.91, 1],
|
|
253
|
+
]) {
|
|
254
|
+
if (Math.abs(w / h - aspect) < 0.005)
|
|
255
|
+
return `${w}:${h}`;
|
|
256
|
+
}
|
|
257
|
+
return `${aspect.toFixed(3)}:1`;
|
|
258
|
+
}
|
|
259
|
+
export function FocalPointInput({ field, value, onChange, siblings, pendingUploads, }) {
|
|
260
|
+
const src = useImageSource(siblings?.[field.image], pendingUploads);
|
|
261
|
+
const { containerRef, fitted, error } = useFittedImage(src, 360);
|
|
262
|
+
const point = isFocalPoint(value) ? value : { x: 50, y: 50 };
|
|
263
|
+
const dragging = useRef(false);
|
|
264
|
+
const setFromEvent = useCallback((e) => {
|
|
265
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
266
|
+
const x = clamp(((e.clientX - r.left) / r.width) * 100, 0, 100);
|
|
267
|
+
const y = clamp(((e.clientY - r.top) / r.height) * 100, 0, 100);
|
|
268
|
+
onChange({ x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 });
|
|
269
|
+
}, [onChange]);
|
|
270
|
+
if (!src)
|
|
271
|
+
return (_jsxs("div", { className: "v-image-tool__empty", children: ["Choose an image in \u201C", field.image, "\u201D first."] }));
|
|
272
|
+
if (error)
|
|
273
|
+
return (_jsx("div", { className: "v-image-tool__empty", children: "The image could not be loaded." }));
|
|
274
|
+
const previews = field.previews ?? ["16:9", "1:1", "3:4"];
|
|
275
|
+
const position = `${point.x}% ${point.y}%`;
|
|
276
|
+
return (_jsxs("div", { className: "v-image-tool", children: [_jsx("div", { ref: containerRef, className: "v-image-tool__stage", children: fitted ? (_jsxs("div", { className: "v-focal", style: {
|
|
277
|
+
width: fitted.display.width,
|
|
278
|
+
height: fitted.display.height,
|
|
279
|
+
}, onMouseDown: preventSelection, onPointerDown: (e) => {
|
|
280
|
+
dragging.current = true;
|
|
281
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
282
|
+
setFromEvent(e);
|
|
283
|
+
}, onPointerMove: (e) => dragging.current && setFromEvent(e), onPointerUp: () => (dragging.current = false), onPointerCancel: () => (dragging.current = false), children: [_jsx("img", { src: src, alt: "", draggable: false, style: {
|
|
284
|
+
width: fitted.display.width,
|
|
285
|
+
height: fitted.display.height,
|
|
286
|
+
} }), _jsx("div", { className: "v-focal__line v-focal__line--v", style: { left: `${point.x}%` } }), _jsx("div", { className: "v-focal__line v-focal__line--h", style: { top: `${point.y}%` } }), _jsx("div", { className: "v-focal__marker", style: { left: `${point.x}%`, top: `${point.y}%` }, role: "img", "aria-label": `Focal point at ${point.x}%, ${point.y}%`, children: _jsx(Crosshair, {}) })] })) : (_jsx("div", { className: "v-image-tool__loading", children: "Loading image\u2026" })) }), _jsxs("div", { className: "v-image-tool__bar", children: [_jsxs("span", { className: "v-mono v-muted", children: [point.x, "% ", point.y, "%"] }), _jsx("div", { className: "v-focal__previews", children: previews.map((p) => {
|
|
287
|
+
const [w, h] = p.split(":").map(Number);
|
|
288
|
+
return (_jsx("div", { className: "v-focal__preview", style: { aspectRatio: `${w} / ${h}` }, title: `${p} preview`, children: _jsx("img", { src: src, alt: "", style: { objectPosition: position } }) }, p));
|
|
289
|
+
}) }), _jsx("span", { className: "v-grow" }), _jsxs(Button, { size: "xs", onClick: () => onChange({ x: 50, y: 50 }), title: "Back to centre", children: [_jsx(RotateCcw, {}), " Centre"] })] })] }));
|
|
290
|
+
}
|
|
291
|
+
function isFocalPoint(v) {
|
|
292
|
+
return (typeof v === "object" &&
|
|
293
|
+
v !== null &&
|
|
294
|
+
typeof v.x === "number" &&
|
|
295
|
+
typeof v.y === "number");
|
|
296
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A Markdown-valued frontmatter field (e.g. a hero tagline) edited with the rich text editor. */
|
|
2
|
+
export declare function MarkdownField({ value, onChange, components, placeholder, }: {
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (v: string) => void;
|
|
5
|
+
components?: string[];
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { useVestry } from "../../context.js";
|
|
4
|
+
import { RichTextEditor } from "../editor/RichTextEditor.js";
|
|
5
|
+
/** A Markdown-valued frontmatter field (e.g. a hero tagline) edited with the rich text editor. */
|
|
6
|
+
export function MarkdownField({ value, onChange, components, placeholder, }) {
|
|
7
|
+
const { config } = useVestry();
|
|
8
|
+
const env = useMemo(() => ({
|
|
9
|
+
components: Object.fromEntries((components ?? [])
|
|
10
|
+
.map((n) => [n, config.components?.[n]])
|
|
11
|
+
.filter(([, c]) => c)),
|
|
12
|
+
toRepoPath: () => null,
|
|
13
|
+
toDocPath: (p) => p,
|
|
14
|
+
}), [components, config.components]);
|
|
15
|
+
return (_jsx(RichTextEditor, { value: value, onChange: onChange, components: components ?? [], env: env, placeholder: placeholder, compact: true }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TreeField } from "../../../config/types.js";
|
|
2
|
+
import { type ValidationError } from "../../lib/utils.js";
|
|
3
|
+
import { type FieldContext } from "./FieldRenderer.js";
|
|
4
|
+
type NodeData = Record<string, unknown>;
|
|
5
|
+
export declare function TreeInput({ field, value, onChange, errors, ctx, path, }: {
|
|
6
|
+
field: TreeField;
|
|
7
|
+
value: unknown;
|
|
8
|
+
onChange: (v: NodeData[]) => void;
|
|
9
|
+
errors?: ValidationError[];
|
|
10
|
+
ctx?: FieldContext;
|
|
11
|
+
path: string;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DndContext, DragOverlay, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
|
|
3
|
+
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
|
|
4
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
5
|
+
import { ChevronDown, ChevronRight, CornerDownRight, GripVertical, Pencil, Plus, Trash2, } from "lucide-react";
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { createPortal } from "react-dom";
|
|
8
|
+
import { cx, defaultValues, fieldLabel, } from "../../lib/utils.js";
|
|
9
|
+
import { Button } from "../ui.js";
|
|
10
|
+
import { FieldSet } from "./FieldRenderer.js";
|
|
11
|
+
const INDENT = 28;
|
|
12
|
+
let nextId = 0;
|
|
13
|
+
const newId = () => `node-${++nextId}`;
|
|
14
|
+
function annotate(value, key) {
|
|
15
|
+
if (!Array.isArray(value))
|
|
16
|
+
return [];
|
|
17
|
+
return value.map((raw) => {
|
|
18
|
+
const node = raw && typeof raw === "object" ? raw : {};
|
|
19
|
+
const { [key]: children, ...data } = node;
|
|
20
|
+
return { id: newId(), data, children: annotate(children, key) };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function strip(nodes, key) {
|
|
24
|
+
return nodes.map((n) => n.children.length
|
|
25
|
+
? { ...n.data, [key]: strip(n.children, key) }
|
|
26
|
+
: { ...n.data });
|
|
27
|
+
}
|
|
28
|
+
function flatten(nodes, collapsed, parentId = null, depth = 0) {
|
|
29
|
+
return nodes.flatMap((n) => [
|
|
30
|
+
{ id: n.id, data: n.data, depth, parentId, childCount: n.children.length },
|
|
31
|
+
...(collapsed.has(n.id)
|
|
32
|
+
? []
|
|
33
|
+
: flatten(n.children, collapsed, n.id, depth + 1)),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
function findNode(nodes, id) {
|
|
37
|
+
for (const n of nodes) {
|
|
38
|
+
if (n.id === id)
|
|
39
|
+
return n;
|
|
40
|
+
const inner = findNode(n.children, id);
|
|
41
|
+
if (inner)
|
|
42
|
+
return inner;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function mapTree(nodes, fn) {
|
|
47
|
+
return nodes.map((n) => fn({ ...n, children: mapTree(n.children, fn) }));
|
|
48
|
+
}
|
|
49
|
+
function removeNode(nodes, id) {
|
|
50
|
+
return nodes
|
|
51
|
+
.filter((n) => n.id !== id)
|
|
52
|
+
.map((n) => ({ ...n, children: removeNode(n.children, id) }));
|
|
53
|
+
}
|
|
54
|
+
function insertNode(nodes, parentId, index, node) {
|
|
55
|
+
if (parentId === null) {
|
|
56
|
+
const next = [...nodes];
|
|
57
|
+
next.splice(index, 0, node);
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
return nodes.map((n) => n.id === parentId
|
|
61
|
+
? { ...n, children: insertNode(n.children, null, index, node) }
|
|
62
|
+
: { ...n, children: insertNode(n.children, parentId, index, node) });
|
|
63
|
+
}
|
|
64
|
+
function descendantIds(node) {
|
|
65
|
+
return node.children.flatMap((c) => [c.id, ...descendantIds(c)]);
|
|
66
|
+
}
|
|
67
|
+
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
68
|
+
/** Where the dragged item would land: depth and parent, from its horizontal offset. */
|
|
69
|
+
function getProjection(items, activeId, overId, offsetX, maxDepth) {
|
|
70
|
+
const overIndex = items.findIndex((i) => i.id === overId);
|
|
71
|
+
const activeIndex = items.findIndex((i) => i.id === activeId);
|
|
72
|
+
const active = items[activeIndex];
|
|
73
|
+
if (!active || overIndex === -1)
|
|
74
|
+
return null;
|
|
75
|
+
const moved = arrayMove(items, activeIndex, overIndex);
|
|
76
|
+
const previous = moved[overIndex - 1];
|
|
77
|
+
const next = moved[overIndex + 1];
|
|
78
|
+
const projected = active.depth + Math.round(offsetX / INDENT);
|
|
79
|
+
const upper = Math.min(previous ? previous.depth + 1 : 0, maxDepth - 1);
|
|
80
|
+
const lower = next ? next.depth : 0;
|
|
81
|
+
const depth = clamp(projected, Math.min(lower, upper), upper);
|
|
82
|
+
let parentId = null;
|
|
83
|
+
if (depth > 0 && previous) {
|
|
84
|
+
if (depth === previous.depth)
|
|
85
|
+
parentId = previous.parentId;
|
|
86
|
+
else if (depth > previous.depth)
|
|
87
|
+
parentId = previous.id;
|
|
88
|
+
else {
|
|
89
|
+
const ancestor = moved
|
|
90
|
+
.slice(0, overIndex)
|
|
91
|
+
.reverse()
|
|
92
|
+
.find((i) => i.depth === depth);
|
|
93
|
+
parentId = ancestor?.parentId ?? null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { depth, parentId, overIndex, moved };
|
|
97
|
+
}
|
|
98
|
+
export function TreeInput({ field, value, onChange, errors, ctx, path, }) {
|
|
99
|
+
const key = field.childrenKey ?? "children";
|
|
100
|
+
const maxDepth = field.maxDepth ?? 99;
|
|
101
|
+
const labelKey = field.itemLabel ?? "title";
|
|
102
|
+
const detailKey = field.itemDetail ??
|
|
103
|
+
Object.keys(field.fields).find((k) => k !== labelKey && field.fields[k].type === "text");
|
|
104
|
+
const [tree, setTree] = useState(() => annotate(value, key));
|
|
105
|
+
const lastEmitted = useRef(JSON.stringify(strip(tree, key)));
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const incoming = JSON.stringify(Array.isArray(value) ? value : []);
|
|
108
|
+
if (incoming !== lastEmitted.current) {
|
|
109
|
+
lastEmitted.current = incoming;
|
|
110
|
+
setTree(annotate(value, key));
|
|
111
|
+
}
|
|
112
|
+
}, [value, key]);
|
|
113
|
+
const commit = (next) => {
|
|
114
|
+
setTree(next);
|
|
115
|
+
const plain = strip(next, key);
|
|
116
|
+
lastEmitted.current = JSON.stringify(plain);
|
|
117
|
+
onChange(plain);
|
|
118
|
+
};
|
|
119
|
+
const [collapsed, setCollapsed] = useState(new Set());
|
|
120
|
+
const [editing, setEditing] = useState(null);
|
|
121
|
+
const [activeId, setActiveId] = useState(null);
|
|
122
|
+
const [overId, setOverId] = useState(null);
|
|
123
|
+
const [offsetX, setOffsetX] = useState(0);
|
|
124
|
+
const flat = useMemo(() => flatten(tree, collapsed), [tree, collapsed]);
|
|
125
|
+
// While dragging, the active node's descendants travel with it and are hidden.
|
|
126
|
+
const visible = useMemo(() => {
|
|
127
|
+
if (!activeId)
|
|
128
|
+
return flat;
|
|
129
|
+
const node = findNode(tree, activeId);
|
|
130
|
+
const hidden = new Set(node ? descendantIds(node) : []);
|
|
131
|
+
return flat.filter((i) => !hidden.has(i.id));
|
|
132
|
+
}, [flat, activeId, tree]);
|
|
133
|
+
const projection = activeId && overId
|
|
134
|
+
? getProjection(visible, activeId, overId, offsetX, maxDepth)
|
|
135
|
+
: null;
|
|
136
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
137
|
+
const onDragEnd = (e) => {
|
|
138
|
+
const active = String(e.active.id);
|
|
139
|
+
setActiveId(null);
|
|
140
|
+
setOverId(null);
|
|
141
|
+
setOffsetX(0);
|
|
142
|
+
if (!projection || !e.over)
|
|
143
|
+
return;
|
|
144
|
+
const node = findNode(tree, active);
|
|
145
|
+
if (!node)
|
|
146
|
+
return;
|
|
147
|
+
const { parentId, moved, overIndex } = projection;
|
|
148
|
+
const siblingsBefore = moved
|
|
149
|
+
.slice(0, overIndex)
|
|
150
|
+
.filter((i) => i.parentId === parentId && i.id !== active).length;
|
|
151
|
+
const without = removeNode(tree, active);
|
|
152
|
+
commit(insertNode(without, parentId, siblingsBefore, node));
|
|
153
|
+
if (parentId)
|
|
154
|
+
setCollapsed((c) => {
|
|
155
|
+
const n = new Set(c);
|
|
156
|
+
n.delete(parentId);
|
|
157
|
+
return n;
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const update = (id, data) => commit(mapTree(tree, (n) => (n.id === id ? { ...n, data } : n)));
|
|
161
|
+
const remove = (id) => {
|
|
162
|
+
commit(removeNode(tree, id));
|
|
163
|
+
if (editing === id)
|
|
164
|
+
setEditing(null);
|
|
165
|
+
};
|
|
166
|
+
const add = (parentId) => {
|
|
167
|
+
const node = {
|
|
168
|
+
id: newId(),
|
|
169
|
+
data: defaultValues(field.fields),
|
|
170
|
+
children: [],
|
|
171
|
+
};
|
|
172
|
+
const parent = parentId ? findNode(tree, parentId) : null;
|
|
173
|
+
commit(insertNode(tree, parentId, parent ? parent.children.length : tree.length, node));
|
|
174
|
+
if (parentId)
|
|
175
|
+
setCollapsed((c) => {
|
|
176
|
+
const n = new Set(c);
|
|
177
|
+
n.delete(parentId);
|
|
178
|
+
return n;
|
|
179
|
+
});
|
|
180
|
+
setEditing(node.id);
|
|
181
|
+
};
|
|
182
|
+
const toggle = (id) => setCollapsed((c) => {
|
|
183
|
+
const n = new Set(c);
|
|
184
|
+
if (n.has(id))
|
|
185
|
+
n.delete(id);
|
|
186
|
+
else
|
|
187
|
+
n.add(id);
|
|
188
|
+
return n;
|
|
189
|
+
});
|
|
190
|
+
const labelOf = (data) => {
|
|
191
|
+
const v = data[labelKey];
|
|
192
|
+
return typeof v === "string" && v.trim() ? v : "Untitled";
|
|
193
|
+
};
|
|
194
|
+
const detailOf = (data) => detailKey && typeof data[detailKey] === "string"
|
|
195
|
+
? data[detailKey]
|
|
196
|
+
: "";
|
|
197
|
+
const activeNode = activeId ? findNode(tree, activeId) : null;
|
|
198
|
+
return (_jsxs("div", { className: "v-tree", children: [_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragStart: (e) => {
|
|
199
|
+
setActiveId(String(e.active.id));
|
|
200
|
+
setOverId(String(e.active.id));
|
|
201
|
+
setEditing(null);
|
|
202
|
+
}, onDragMove: (e) => setOffsetX(e.delta.x), onDragOver: (e) => setOverId(e.over ? String(e.over.id) : null), onDragEnd: onDragEnd, onDragCancel: () => {
|
|
203
|
+
setActiveId(null);
|
|
204
|
+
setOverId(null);
|
|
205
|
+
setOffsetX(0);
|
|
206
|
+
}, children: [_jsxs(SortableContext, { items: visible.map((i) => i.id), strategy: verticalListSortingStrategy, children: [visible.length === 0 && (_jsx("div", { className: "v-tree__empty", children: "No items yet." })), visible.map((item) => (_jsx(TreeRow, { item: item, depth: activeId === item.id && projection
|
|
207
|
+
? projection.depth
|
|
208
|
+
: item.depth, label: labelOf(item.data), detail: detailOf(item.data), collapsed: collapsed.has(item.id), editing: editing === item.id, canAddChild: item.depth + 1 < maxDepth, onToggle: () => toggle(item.id), onEdit: () => setEditing(editing === item.id ? null : item.id), onAddChild: () => add(item.id), onRemove: () => remove(item.id), children: editing === item.id && (_jsx("div", { className: "v-tree__form", children: _jsx(FieldSet, { schema: field.fields, values: item.data, onChange: (v) => update(item.id, v), errors: errors, ctx: ctx, pathPrefix: `${path}.${item.id}` }) })) }, item.id)))] }), createPortal(_jsx(DragOverlay, { dropAnimation: null, children: activeNode && (_jsxs("div", { className: "v-tree__item v-tree__ghost", children: [_jsx("span", { className: "v-list__handle", children: _jsx(GripVertical, {}) }), _jsx("span", { className: "v-tree__label", children: labelOf(activeNode.data) }), activeNode.children.length > 0 && (_jsxs("span", { className: "v-tree__count", children: ["+", descendantIds(activeNode).length] }))] })) }), document.body)] }), _jsxs(Button, { size: "sm", className: "v-list__add", onClick: () => add(null), children: [_jsx(Plus, {}), " ", field.addLabel ?? `Add ${fieldLabel("item", field).toLowerCase()}`] })] }));
|
|
209
|
+
}
|
|
210
|
+
function TreeRow({ item, depth, label, detail, collapsed, editing, canAddChild, onToggle, onEdit, onAddChild, onRemove, children, }) {
|
|
211
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id: item.id });
|
|
212
|
+
return (_jsxs("div", { ref: setNodeRef, className: cx("v-tree__row", isDragging && "is-placeholder", editing && "is-editing"), style: {
|
|
213
|
+
transform: CSS.Translate.toString(transform),
|
|
214
|
+
transition,
|
|
215
|
+
paddingLeft: depth * INDENT,
|
|
216
|
+
"--v-tree-indent": `${depth * INDENT}px`,
|
|
217
|
+
}, children: [_jsxs("div", { className: "v-tree__item", children: [_jsx("span", { className: "v-list__handle", title: "Drag to move; drag sideways to nest", ref: setActivatorNodeRef, ...attributes, ...listeners, onPointerDown: (e) => {
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
listeners?.onPointerDown?.(e);
|
|
220
|
+
}, children: _jsx(GripVertical, {}) }), item.childCount > 0 ? (_jsx("button", { type: "button", className: "v-tree__toggle", onClick: onToggle, title: collapsed ? "Expand" : "Collapse", children: collapsed ? _jsx(ChevronRight, {}) : _jsx(ChevronDown, {}) })) : (_jsx("span", { className: "v-tree__toggle v-tree__toggle--leaf" })), _jsxs("button", { type: "button", className: "v-tree__text", onClick: onEdit, children: [_jsx("span", { className: "v-tree__label", children: label }), detail && _jsx("span", { className: "v-tree__detail", children: detail }), collapsed && item.childCount > 0 && (_jsx("span", { className: "v-tree__count", children: item.childCount }))] }), _jsxs("div", { className: "v-list__actions", children: [canAddChild && (_jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Add child", onClick: onAddChild, children: _jsx(CornerDownRight, {}) })), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: editing ? "Done" : "Edit", onClick: onEdit, children: _jsx(Pencil, {}) }), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Remove", onClick: onRemove, children: _jsx(Trash2, {}) })] })] }), children] }));
|
|
221
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export declare function Button({ variant, size, icon, className, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
3
|
+
variant?: "default" | "primary" | "danger" | "ghost";
|
|
4
|
+
size?: "sm" | "xs";
|
|
5
|
+
icon?: boolean;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
export declare function Modal({ title, onClose, children, footer, wide, }: {
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
footer?: ReactNode;
|
|
12
|
+
wide?: boolean;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
export declare function ConfirmDialog({ title, message, confirmLabel, danger, onConfirm, onCancel, busy, }: {
|
|
15
|
+
title: string;
|
|
16
|
+
message: ReactNode;
|
|
17
|
+
confirmLabel?: string;
|
|
18
|
+
danger?: boolean;
|
|
19
|
+
onConfirm: () => void;
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
busy?: boolean;
|
|
22
|
+
}): import("react").JSX.Element;
|
|
23
|
+
interface Toast {
|
|
24
|
+
id: number;
|
|
25
|
+
kind: "success" | "error" | "info";
|
|
26
|
+
message: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function ToastProvider({ children }: {
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}): import("react").JSX.Element;
|
|
31
|
+
export declare function useToast(): (kind: Toast["kind"], message: ReactNode) => void;
|
|
32
|
+
export declare function Spinner(): import("react").JSX.Element;
|
|
33
|
+
export declare function Loading({ label }: {
|
|
34
|
+
label?: string;
|
|
35
|
+
}): import("react").JSX.Element;
|
|
36
|
+
export declare function Alert({ kind, children, }: {
|
|
37
|
+
kind?: "error" | "warning" | "info";
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
export declare function Badge({ status, children, }: {
|
|
41
|
+
status?: "published" | "draft" | null;
|
|
42
|
+
children?: ReactNode;
|
|
43
|
+
}): import("react").JSX.Element;
|
|
44
|
+
export declare function EmptyState({ icon, title, children, }: {
|
|
45
|
+
icon?: ReactNode;
|
|
46
|
+
title: string;
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
}): import("react").JSX.Element;
|
|
49
|
+
export declare function Menu({ trigger, children, align, }: {
|
|
50
|
+
trigger: (props: {
|
|
51
|
+
open: boolean;
|
|
52
|
+
toggle: () => void;
|
|
53
|
+
}) => ReactNode;
|
|
54
|
+
children: (close: () => void) => ReactNode;
|
|
55
|
+
align?: "left" | "right";
|
|
56
|
+
}): import("react").JSX.Element;
|
|
57
|
+
export declare function Toggle({ checked, onChange, label, disabled, }: {
|
|
58
|
+
checked: boolean;
|
|
59
|
+
onChange: (v: boolean) => void;
|
|
60
|
+
label?: ReactNode;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
}): import("react").JSX.Element;
|
|
63
|
+
export {};
|