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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -2
  3. package/dist/config/fields.d.ts +30 -0
  4. package/dist/config/fields.js +75 -0
  5. package/dist/config/index.d.ts +9 -0
  6. package/dist/config/index.js +18 -0
  7. package/dist/config/types.d.ts +412 -0
  8. package/dist/config/types.js +10 -0
  9. package/dist/core/api.d.ts +67 -0
  10. package/dist/core/api.js +543 -0
  11. package/dist/core/auth.d.ts +1597 -0
  12. package/dist/core/auth.js +264 -0
  13. package/dist/core/cache.d.ts +27 -0
  14. package/dist/core/cache.js +52 -0
  15. package/dist/core/entries.d.ts +19 -0
  16. package/dist/core/entries.js +127 -0
  17. package/dist/core/index-types.d.ts +20 -0
  18. package/dist/core/index-types.js +1 -0
  19. package/dist/core/index.d.ts +13 -0
  20. package/dist/core/index.js +13 -0
  21. package/dist/core/mail.d.ts +28 -0
  22. package/dist/core/mail.js +62 -0
  23. package/dist/core/paths.d.ts +35 -0
  24. package/dist/core/paths.js +148 -0
  25. package/dist/core/roles.d.ts +24 -0
  26. package/dist/core/roles.js +15 -0
  27. package/dist/core/service.d.ts +102 -0
  28. package/dist/core/service.js +478 -0
  29. package/dist/core/source.d.ts +82 -0
  30. package/dist/core/source.js +75 -0
  31. package/dist/core/sources/github-app.d.ts +26 -0
  32. package/dist/core/sources/github-app.js +161 -0
  33. package/dist/core/sources/github.d.ts +40 -0
  34. package/dist/core/sources/github.js +278 -0
  35. package/dist/core/sources/local.d.ts +16 -0
  36. package/dist/core/sources/local.js +98 -0
  37. package/dist/core/values.d.ts +22 -0
  38. package/dist/core/values.js +123 -0
  39. package/dist/env.d.ts +19 -0
  40. package/dist/index.d.ts +18 -0
  41. package/dist/index.js +167 -0
  42. package/dist/integration/build-index.d.ts +8 -0
  43. package/dist/integration/build-index.js +67 -0
  44. package/dist/integration/client-deps.d.ts +10 -0
  45. package/dist/integration/client-deps.js +43 -0
  46. package/dist/integration/dev-middleware.d.ts +6 -0
  47. package/dist/integration/dev-middleware.js +48 -0
  48. package/dist/integration/formatter.d.ts +7 -0
  49. package/dist/integration/formatter.js +56 -0
  50. package/dist/mdx/expressions.d.ts +14 -0
  51. package/dist/mdx/expressions.js +141 -0
  52. package/dist/mdx/index.d.ts +4 -0
  53. package/dist/mdx/index.js +4 -0
  54. package/dist/mdx/parse.d.ts +6 -0
  55. package/dist/mdx/parse.js +401 -0
  56. package/dist/mdx/serialize.d.ts +9 -0
  57. package/dist/mdx/serialize.js +485 -0
  58. package/dist/mdx/types.d.ts +42 -0
  59. package/dist/mdx/types.js +6 -0
  60. package/dist/pages/admin.astro +27 -0
  61. package/dist/pages/api.d.ts +3 -0
  62. package/dist/pages/api.js +31 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +70 -0
  65. package/dist/ui/api.d.ts +83 -0
  66. package/dist/ui/api.js +97 -0
  67. package/dist/ui/auth-client.d.ts +27 -0
  68. package/dist/ui/auth-client.js +29 -0
  69. package/dist/ui/components/AssetPicker.d.ts +27 -0
  70. package/dist/ui/components/AssetPicker.js +97 -0
  71. package/dist/ui/components/Icon.d.ts +11 -0
  72. package/dist/ui/components/Icon.js +46 -0
  73. package/dist/ui/components/Layout.d.ts +15 -0
  74. package/dist/ui/components/Layout.js +87 -0
  75. package/dist/ui/components/editor/EditorContext.d.ts +15 -0
  76. package/dist/ui/components/editor/EditorContext.js +9 -0
  77. package/dist/ui/components/editor/NodeViews.d.ts +4 -0
  78. package/dist/ui/components/editor/NodeViews.js +127 -0
  79. package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
  80. package/dist/ui/components/editor/RichTextEditor.js +230 -0
  81. package/dist/ui/components/editor/extensions.d.ts +30 -0
  82. package/dist/ui/components/editor/extensions.js +248 -0
  83. package/dist/ui/components/editor/schema.d.ts +12 -0
  84. package/dist/ui/components/editor/schema.js +37 -0
  85. package/dist/ui/components/fields/CodeInput.d.ts +10 -0
  86. package/dist/ui/components/fields/CodeInput.js +8 -0
  87. package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
  88. package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
  89. package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
  90. package/dist/ui/components/fields/FieldRenderer.js +299 -0
  91. package/dist/ui/components/fields/ImageTools.d.ts +31 -0
  92. package/dist/ui/components/fields/ImageTools.js +296 -0
  93. package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
  94. package/dist/ui/components/fields/MarkdownField.js +16 -0
  95. package/dist/ui/components/fields/TreeInput.d.ts +13 -0
  96. package/dist/ui/components/fields/TreeInput.js +221 -0
  97. package/dist/ui/components/ui.d.ts +63 -0
  98. package/dist/ui/components/ui.js +113 -0
  99. package/dist/ui/context.d.ts +17 -0
  100. package/dist/ui/context.js +13 -0
  101. package/dist/ui/lib/format.d.ts +5 -0
  102. package/dist/ui/lib/format.js +11 -0
  103. package/dist/ui/lib/utils.d.ts +24 -0
  104. package/dist/ui/lib/utils.js +255 -0
  105. package/dist/ui/main.d.ts +1 -0
  106. package/dist/ui/main.js +11 -0
  107. package/dist/ui/pages/Assets.d.ts +1 -0
  108. package/dist/ui/pages/Assets.js +69 -0
  109. package/dist/ui/pages/CollectionList.d.ts +6 -0
  110. package/dist/ui/pages/CollectionList.js +115 -0
  111. package/dist/ui/pages/Dashboard.d.ts +1 -0
  112. package/dist/ui/pages/Dashboard.js +57 -0
  113. package/dist/ui/pages/EntryEditor.d.ts +3 -0
  114. package/dist/ui/pages/EntryEditor.js +313 -0
  115. package/dist/ui/pages/Login.d.ts +6 -0
  116. package/dist/ui/pages/Login.js +88 -0
  117. package/dist/ui/pages/Users.d.ts +1 -0
  118. package/dist/ui/pages/Users.js +102 -0
  119. package/dist/ui/styles.css +2554 -0
  120. 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 {};