vestrycms 0.0.0-stage → 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -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 +403 -0
  8. package/dist/config/types.js +10 -0
  9. package/dist/core/api.d.ts +67 -0
  10. package/dist/core/api.js +538 -0
  11. package/dist/core/auth.d.ts +1561 -0
  12. package/dist/core/auth.js +234 -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 +378 -0
  56. package/dist/mdx/serialize.d.ts +9 -0
  57. package/dist/mdx/serialize.js +396 -0
  58. package/dist/mdx/types.d.ts +40 -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 +253 -0
  81. package/dist/ui/components/editor/extensions.d.ts +20 -0
  82. package/dist/ui/components/editor/extensions.js +156 -0
  83. package/dist/ui/components/fields/CodeInput.d.ts +10 -0
  84. package/dist/ui/components/fields/CodeInput.js +8 -0
  85. package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
  86. package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
  87. package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
  88. package/dist/ui/components/fields/FieldRenderer.js +299 -0
  89. package/dist/ui/components/fields/ImageTools.d.ts +31 -0
  90. package/dist/ui/components/fields/ImageTools.js +296 -0
  91. package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
  92. package/dist/ui/components/fields/MarkdownField.js +16 -0
  93. package/dist/ui/components/fields/TreeInput.d.ts +13 -0
  94. package/dist/ui/components/fields/TreeInput.js +221 -0
  95. package/dist/ui/components/ui.d.ts +63 -0
  96. package/dist/ui/components/ui.js +113 -0
  97. package/dist/ui/context.d.ts +17 -0
  98. package/dist/ui/context.js +13 -0
  99. package/dist/ui/lib/format.d.ts +5 -0
  100. package/dist/ui/lib/format.js +11 -0
  101. package/dist/ui/lib/utils.d.ts +24 -0
  102. package/dist/ui/lib/utils.js +255 -0
  103. package/dist/ui/main.d.ts +1 -0
  104. package/dist/ui/main.js +11 -0
  105. package/dist/ui/pages/Assets.d.ts +1 -0
  106. package/dist/ui/pages/Assets.js +69 -0
  107. package/dist/ui/pages/CollectionList.d.ts +6 -0
  108. package/dist/ui/pages/CollectionList.js +115 -0
  109. package/dist/ui/pages/Dashboard.d.ts +1 -0
  110. package/dist/ui/pages/Dashboard.js +57 -0
  111. package/dist/ui/pages/EntryEditor.d.ts +3 -0
  112. package/dist/ui/pages/EntryEditor.js +313 -0
  113. package/dist/ui/pages/Login.d.ts +6 -0
  114. package/dist/ui/pages/Login.js +88 -0
  115. package/dist/ui/pages/Users.d.ts +1 -0
  116. package/dist/ui/pages/Users.js +102 -0
  117. package/dist/ui/styles.css +2536 -0
  118. package/package.json +121 -3
@@ -0,0 +1,299 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ChevronDown, ChevronRight, ChevronUp, Copy, GripVertical, Image as ImageIcon, Plus, Trash2, X, } from "lucide-react";
4
+ import { useId, useRef, useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
7
+ import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
8
+ import { CSS } from "@dnd-kit/utilities";
9
+ import { restrictToParentElement, restrictToVerticalAxis, } from "@dnd-kit/modifiers";
10
+ import { useVestry } from "../../context.js";
11
+ import { basename, cx, defaultValue, fieldLabel, isImagePath, slugify, summarize, } from "../../lib/utils.js";
12
+ import { AssetPicker } from "../AssetPicker.js";
13
+ import { Button, Toggle } from "../ui.js";
14
+ import { MarkdownField } from "./MarkdownField.js";
15
+ import { CodeInput } from "./CodeInput.js";
16
+ import { CropInput, FocalPointInput } from "./ImageTools.js";
17
+ import { TreeInput } from "./TreeInput.js";
18
+ /** Renders a schema as a responsive grid of fields. */
19
+ export function FieldSet({ schema, values, onChange, errors, filter, ctx, pathPrefix = "", hideLabels, }) {
20
+ const entries = Object.entries(schema).filter(([k, f]) => !f.hidden && (!filter || filter(k, f)));
21
+ if (!entries.length)
22
+ return null;
23
+ return (_jsx("div", { className: "v-fields", children: entries.map(([key, field]) => (_jsx("div", { className: cx("v-field", field.width === "half" && "v-field--half", field.width === "third" && "v-field--third"), children: _jsx(FieldRenderer, { fieldKey: key, path: pathPrefix ? `${pathPrefix}.${key}` : key, field: field, value: values[key], onChange: (v) => onChange({ ...values, [key]: v }), errors: errors, ctx: { ...ctx, siblings: values }, hideLabel: hideLabels?.includes(key) }) }, key))) }));
24
+ }
25
+ export function FieldRenderer(props) {
26
+ const { field, fieldKey, path, errors } = props;
27
+ const id = useId();
28
+ const error = errors?.find((e) => e.path === path)?.message;
29
+ const label = fieldLabel(fieldKey, field);
30
+ const inline = field.type === "boolean";
31
+ return (_jsxs("div", { className: cx(error && "has-error"), children: [!inline && !props.hideLabel && (_jsxs("label", { className: "v-field__label", htmlFor: id, children: [label, field.required && _jsx("span", { className: "v-field__required", children: "*" })] })), _jsx(FieldInput, { ...props, id: id, label: label }), field.type === "date" &&
32
+ field.timeHint &&
33
+ typeof props.value === "string" &&
34
+ props.value.length <= 10 && (_jsxs("div", { className: "v-field__hint", children: ["Time: ", field.timeHint] })), field.description && (_jsx("div", { className: "v-field__hint", children: field.description })), error && _jsx("div", { className: "v-field__error", children: error })] }));
35
+ }
36
+ function FieldInput(props) {
37
+ const { field, value, onChange, id, label, ctx } = props;
38
+ const readOnly = field.readOnly;
39
+ switch (field.type) {
40
+ case "text":
41
+ return (_jsx("input", { id: id, className: "v-input", value: value ?? "", placeholder: field.placeholder, maxLength: field.maxLength, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
42
+ case "slug":
43
+ return (_jsx(SlugInput, { id: id, value: value ?? "", onChange: onChange, from: field.from, ctx: ctx, readOnly: readOnly }));
44
+ case "textarea":
45
+ return (_jsx("textarea", { id: id, className: "v-textarea", rows: field.rows ?? 4, value: value ?? "", placeholder: field.placeholder, maxLength: field.maxLength, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
46
+ case "number":
47
+ return (_jsx("input", { id: id, type: "number", className: "v-input", value: value === null || value === undefined ? "" : String(value), min: field.min, max: field.max, step: field.step ?? "any", readOnly: readOnly, onChange: (e) => onChange(e.target.value === "" ? null : Number(e.target.value)) }));
48
+ case "boolean":
49
+ return (_jsx(Toggle, { checked: Boolean(value), onChange: onChange, label: label, disabled: readOnly }));
50
+ case "date": {
51
+ const str = typeof value === "string"
52
+ ? value
53
+ : value instanceof Date
54
+ ? value.toISOString()
55
+ : "";
56
+ const datePart = str.slice(0, 10);
57
+ const timePart = str.length > 10 ? str.slice(11, 16) : "";
58
+ if (!field.time) {
59
+ return (_jsx("input", { id: id, type: "date", className: "v-input", value: datePart, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
60
+ }
61
+ return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { id: id, type: "date", className: "v-input", value: datePart, readOnly: readOnly, onChange: (e) => onChange(timePart && e.target.value
62
+ ? `${e.target.value}T${timePart}`
63
+ : e.target.value) }), _jsx("input", { type: "time", className: "v-input", style: { flex: "0 0 130px" }, value: timePart, readOnly: readOnly, title: field.timeHint, onChange: (e) => onChange(e.target.value && datePart
64
+ ? `${datePart}T${e.target.value}`
65
+ : datePart) })] }));
66
+ }
67
+ case "datetime": {
68
+ if (field.autoUpdate) {
69
+ return (_jsx("input", { id: id, className: "v-input", readOnly: true, value: value ? new Date(String(value)).toLocaleString() : "Set on save" }));
70
+ }
71
+ return (_jsx(DatetimeInput, { id: id, value: value, storage: field.storage ?? "local", onChange: onChange, readOnly: readOnly }));
72
+ }
73
+ case "select":
74
+ return (_jsxs("div", { className: "v-row", children: [field.options.find((o) => o.value === value)?.color && (_jsx("span", { className: "v-swatch", style: {
75
+ background: field.options.find((o) => o.value === value).color,
76
+ } })), _jsxs("select", { id: id, className: "v-select", value: value ?? "", disabled: readOnly, onChange: (e) => onChange(e.target.value || undefined), children: [(field.clearable || value === undefined || value === "") && (_jsx("option", { value: "", children: "\u2014" })), field.options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] }));
77
+ case "multiselect": {
78
+ const selected = Array.isArray(value) ? value : [];
79
+ return (_jsx("div", { className: "v-chips", children: field.options.map((o) => {
80
+ const on = selected.includes(o.value);
81
+ return (_jsxs("button", { type: "button", className: cx("v-chip", on && "is-on"), onClick: () => onChange(on
82
+ ? selected.filter((v) => v !== o.value)
83
+ : [...selected, o.value]), disabled: readOnly, children: [o.color && (_jsx("span", { className: "v-swatch", style: { background: o.color } })), o.label] }, o.value));
84
+ }) }));
85
+ }
86
+ case "color":
87
+ return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { type: "color", value: value || "#000000", onChange: (e) => onChange(e.target.value), disabled: readOnly, style: { width: 44, height: 36, padding: 2 } }), _jsx("input", { id: id, className: "v-input v-input--mono", value: value ?? "", onChange: (e) => onChange(e.target.value), readOnly: readOnly })] }));
88
+ case "image":
89
+ case "file":
90
+ return _jsx(AssetInput, { ...props });
91
+ case "reference":
92
+ return (_jsx(ReferenceInput, { id: id, collection: field.collection, value: value ?? "", onChange: onChange, readOnly: readOnly }));
93
+ case "object": {
94
+ const obj = (value && typeof value === "object" ? value : {});
95
+ const body = (_jsx(FieldSet, { schema: field.fields, values: obj, onChange: onChange, errors: props.errors, ctx: ctx, pathPrefix: props.path }));
96
+ return field.inline ? (body) : (_jsx("div", { className: "v-group", children: _jsx("div", { className: "v-group__body", children: body }) }));
97
+ }
98
+ case "array":
99
+ return _jsx(ArrayInput, { ...props, field: field });
100
+ case "markdown":
101
+ return (_jsx(MarkdownField, { value: value ?? "", onChange: onChange, components: field.components, placeholder: field.description }));
102
+ case "code":
103
+ return (_jsx(CodeInput, { value: value ?? "", onChange: onChange, language: field.language ?? "text", rows: field.rows, readOnly: readOnly }));
104
+ case "json":
105
+ return (_jsx(JsonInput, { value: value, onChange: onChange, readOnly: readOnly }));
106
+ case "crop":
107
+ return (_jsx(CropInput, { field: field, value: value, onChange: onChange, siblings: ctx?.siblings, pendingUploads: ctx?.pendingUploads }));
108
+ case "focalPoint":
109
+ return (_jsx(FocalPointInput, { field: field, value: value, onChange: onChange, siblings: ctx?.siblings, pendingUploads: ctx?.pendingUploads }));
110
+ case "tree":
111
+ return (_jsx(TreeInput, { field: field, value: value, onChange: onChange, errors: props.errors, ctx: ctx, path: props.path }));
112
+ default:
113
+ return _jsx("input", { className: "v-input", readOnly: true, value: String(value ?? "") });
114
+ }
115
+ }
116
+ function SlugInput({ id, value, onChange, from, ctx, readOnly, }) {
117
+ const source = from ? ctx?.siblings?.[from] : undefined;
118
+ const suggestion = typeof source === "string" ? slugify(source) : "";
119
+ return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { id: id, className: "v-input v-input--mono", value: value, readOnly: readOnly, onChange: (e) => onChange(e.target.value), placeholder: suggestion || undefined }), suggestion && suggestion !== value && !readOnly && (_jsx(Button, { size: "sm", onClick: () => onChange(suggestion), children: "Generate" }))] }));
120
+ }
121
+ function DatetimeInput({ id, value, storage, onChange, readOnly, }) {
122
+ // Display in local wall-clock; store either as `YYYY-MM-DDTHH:mm` or ISO.
123
+ let display = "";
124
+ if (typeof value === "string" && value) {
125
+ if (storage === "local")
126
+ display = value.slice(0, 16);
127
+ else {
128
+ const d = new Date(value);
129
+ if (!Number.isNaN(d.getTime())) {
130
+ const pad = (n) => String(n).padStart(2, "0");
131
+ display = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
132
+ }
133
+ }
134
+ }
135
+ return (_jsx("input", { id: id, type: "datetime-local", className: "v-input", value: display, readOnly: readOnly, onChange: (e) => {
136
+ const v = e.target.value;
137
+ if (!v)
138
+ return onChange("");
139
+ onChange(storage === "local" ? v : new Date(v).toISOString());
140
+ } }));
141
+ }
142
+ function AssetInput(props) {
143
+ const { api } = useVestry();
144
+ const field = props.field;
145
+ const value = props.value ?? "";
146
+ const [open, setOpen] = useState(false);
147
+ const pending = props.ctx?.pendingUploads?.[value];
148
+ const isImage = field.type === "image" || isImagePath(value);
149
+ const previewUrl = value
150
+ ? pending
151
+ ? pending.previewUrl
152
+ : /^https?:/.test(value)
153
+ ? value
154
+ : api.fileUrl(value)
155
+ : null;
156
+ const accept = field.accept ??
157
+ (field.type === "image"
158
+ ? ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"]
159
+ : undefined);
160
+ return (_jsxs("div", { className: "v-image-field", children: [_jsx("div", { className: "v-image-field__preview", children: previewUrl && isImage ? (_jsx("img", { src: previewUrl, alt: "" })) : (_jsx(ImageIcon, {})) }), _jsxs("div", { className: "v-image-field__info", children: [value ? (_jsxs("div", { className: "v-image-field__path", children: [_jsx("strong", { style: { color: "var(--v-text)" }, children: basename(value) }), _jsx("br", {}), value.slice(0, value.length - basename(value).length)] })) : (_jsx("div", { className: "v-muted", style: { marginBottom: 8 }, children: "No file selected" })), _jsxs("div", { className: "v-image-field__actions", children: [_jsx(Button, { size: "sm", onClick: () => setOpen(true), disabled: field.readOnly, children: value ? "Replace" : "Choose" }), value && (_jsxs(Button, { size: "sm", variant: "ghost", onClick: () => props.onChange(""), disabled: field.readOnly, children: [_jsx(X, {}), " Clear"] }))] })] }), open && (_jsx(AssetPicker, { directory: field.directory, accept: accept, allowUpload: field.upload !== false, title: field.type === "image" ? "Choose an image" : "Choose a file", deferUploads: Boolean(props.ctx?.addUpload), onClose: () => setOpen(false), onPick: (path, upload) => {
161
+ if (upload)
162
+ props.ctx?.addUpload?.(upload);
163
+ props.onChange(path);
164
+ setOpen(false);
165
+ } }))] }));
166
+ }
167
+ function ReferenceInput({ id, collection, value, onChange, readOnly, }) {
168
+ const { api } = useVestry();
169
+ const q = useQuery({
170
+ queryKey: ["entries", collection],
171
+ queryFn: () => api.listEntries(collection),
172
+ staleTime: 60_000,
173
+ });
174
+ const entries = q.data?.entries ?? [];
175
+ return (_jsxs("select", { id: id, className: "v-select", value: value, disabled: readOnly, onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: q.isLoading ? "Loading…" : "—" }), value && !entries.some((e) => e.slug === value) && (_jsx("option", { value: value, children: value })), entries.map((e) => (_jsx("option", { value: e.slug, children: e.title }, e.slug)))] }));
176
+ }
177
+ let nextRowId = 0;
178
+ const newRowId = () => `row-${++nextRowId}`;
179
+ /**
180
+ * A sortable list item. dnd-kit moves the neighbours out of the way with
181
+ * transforms so a gap opens where the row will land; the row itself stays in
182
+ * flow as a dimmed placeholder while a clone floats in the DragOverlay.
183
+ */
184
+ function SortableRow({ id, className, children, }) {
185
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id });
186
+ return (_jsx("div", { ref: setNodeRef, className: cx(className, isDragging && "is-placeholder"), style: { transform: CSS.Translate.toString(transform), transition }, children: children({
187
+ ref: setActivatorNodeRef,
188
+ listeners: {
189
+ ...listeners,
190
+ // Keep the pointer-down from reaching an enclosing rich text
191
+ // editor, which would otherwise treat it as a selection drag.
192
+ onPointerDown: (e) => {
193
+ e.stopPropagation();
194
+ listeners?.onPointerDown?.(e);
195
+ },
196
+ },
197
+ attributes: attributes,
198
+ }, isDragging) }));
199
+ }
200
+ function ArrayInput(props) {
201
+ const { field, onChange, ctx } = props;
202
+ const items = Array.isArray(props.value) ? props.value : [];
203
+ const [openIdx, setOpenIdx] = useState(null);
204
+ const [activeId, setActiveId] = useState(null);
205
+ const isObject = field.of.type === "object";
206
+ // Stable ids for dnd-kit, kept parallel to the items array.
207
+ const idsRef = useRef([]);
208
+ if (idsRef.current.length < items.length) {
209
+ idsRef.current = [
210
+ ...idsRef.current,
211
+ ...Array.from({ length: items.length - idsRef.current.length }, newRowId),
212
+ ];
213
+ }
214
+ else if (idsRef.current.length > items.length) {
215
+ idsRef.current = idsRef.current.slice(0, items.length);
216
+ }
217
+ const ids = idsRef.current;
218
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, {
219
+ coordinateGetter: sortableKeyboardCoordinates,
220
+ }));
221
+ const update = (next, nextIds) => {
222
+ if (nextIds)
223
+ idsRef.current = nextIds;
224
+ onChange(next);
225
+ };
226
+ const move = (from, to) => {
227
+ if (to < 0 || to >= items.length || from === to)
228
+ return;
229
+ update(arrayMove(items, from, to), arrayMove(ids, from, to));
230
+ setOpenIdx((o) => (o === from ? to : o));
231
+ };
232
+ const remove = (i) => update(items.filter((_, j) => j !== i), ids.filter((_, j) => j !== i));
233
+ const duplicate = (i) => update([
234
+ ...items.slice(0, i + 1),
235
+ JSON.parse(JSON.stringify(items[i])),
236
+ ...items.slice(i + 1),
237
+ ], [...ids.slice(0, i + 1), newRowId(), ...ids.slice(i + 1)]);
238
+ const setItem = (i, v) => update(items.map((x, j) => (j === i ? v : x)));
239
+ const moveButtons = (i) => (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Move up", onClick: () => move(i, i - 1), disabled: i === 0, children: _jsx(ChevronUp, {}) }), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Move down", onClick: () => move(i, i + 1), disabled: i === items.length - 1, children: _jsx(ChevronDown, {}) })] }));
240
+ /** Header of an object item; `overlay` renders a static clone for the drag preview. */
241
+ const objectHeader = (i, open, handle) => {
242
+ const item = items[i];
243
+ const title = summarize(item, field) || `${fieldLabel("item", field.of)} ${i + 1}`;
244
+ return (_jsxs("div", { className: "v-list__header", onClick: handle ? () => setOpenIdx(open ? null : i) : undefined, children: [_jsx("span", { className: "v-list__handle", title: "Drag to reorder", ref: handle?.ref, ...(handle?.listeners ?? {}), ...(handle?.attributes ?? {}), children: _jsx(GripVertical, {}) }), open ? _jsx(ChevronDown, { size: 14 }) : _jsx(ChevronRight, { size: 14 }), _jsx("span", { className: "v-list__title", children: title }), handle && (_jsxs("div", { className: "v-list__actions", onClick: (e) => e.stopPropagation(), children: [moveButtons(i), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Duplicate", onClick: () => duplicate(i), children: _jsx(Copy, {}) }), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Remove", onClick: () => remove(i), children: _jsx(Trash2, {}) })] }))] }));
245
+ };
246
+ const scalarRow = (i, handle) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "v-list__handle", title: "Drag to reorder", ref: handle?.ref, ...(handle?.listeners ?? {}), ...(handle?.attributes ?? {}), children: _jsx(GripVertical, {}) }), _jsx("div", { className: "v-grow", children: _jsx(FieldInput, { fieldKey: `${props.fieldKey}.${i}`, path: `${props.path}.${i}`, field: field.of, value: items[i], onChange: (v) => setItem(i, v), errors: props.errors, ctx: ctx, id: handle ? `${props.path}.${i}` : `${props.path}.${i}.overlay`, label: "" }) }), _jsxs("div", { className: "v-list__actions", children: [moveButtons(i), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Remove", onClick: () => remove(i), children: _jsx(Trash2, {}) })] })] }));
247
+ const rowClass = (i) => cx(isObject ? "v-list__item" : "v-list__row", isDisabled(items[i]) && "is-disabled");
248
+ const activeIdx = activeId ? ids.indexOf(activeId) : -1;
249
+ return (_jsxs("div", { children: [_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis, restrictToParentElement], onDragStart: (e) => {
250
+ setActiveId(String(e.active.id));
251
+ if (isObject)
252
+ setOpenIdx(null);
253
+ }, onDragEnd: (e) => {
254
+ setActiveId(null);
255
+ const from = ids.indexOf(String(e.active.id));
256
+ const to = e.over ? ids.indexOf(String(e.over.id)) : -1;
257
+ if (from !== -1 && to !== -1)
258
+ move(from, to);
259
+ }, onDragCancel: () => setActiveId(null), children: [_jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "v-list", children: items.map((item, i) => {
260
+ const open = openIdx === i;
261
+ return (_jsx(SortableRow, { id: ids[i], className: rowClass(i), children: (handle) => isObject ? (_jsxs(_Fragment, { children: [objectHeader(i, open, handle), open && (_jsx("div", { className: "v-list__body", children: _jsx(FieldSet, { schema: field.of.fields, values: (item ?? {}), onChange: (v) => setItem(i, v), errors: props.errors, ctx: ctx, pathPrefix: `${props.path}.${i}` }) }))] })) : (scalarRow(i, handle)) }, ids[i]));
262
+ }) }) }), createPortal(_jsx(DragOverlay, { dropAnimation: {
263
+ duration: 180,
264
+ easing: "cubic-bezier(0.2, 0, 0, 1)",
265
+ }, children: activeIdx !== -1 && (_jsx("div", { className: cx(rowClass(activeIdx), "v-list__ghost"), children: isObject
266
+ ? objectHeader(activeIdx, false, null)
267
+ : scalarRow(activeIdx, null) })) }), document.body)] }), _jsxs(Button, { size: "sm", className: "v-list__add", disabled: field.max !== undefined && items.length >= field.max, onClick: () => {
268
+ update([...items, defaultValue(field.of)], [...ids, newRowId()]);
269
+ setOpenIdx(items.length);
270
+ }, children: [_jsx(Plus, {}), " ", field.addLabel ?? "Add item"] })] }));
271
+ }
272
+ function JsonInput({ value, onChange, readOnly, }) {
273
+ const [text, setText] = useState(() => value === undefined ? "" : JSON.stringify(value, null, 2));
274
+ const [error, setError] = useState(null);
275
+ return (_jsxs("div", { children: [_jsx(CodeInput, { value: text, language: "json", readOnly: readOnly, onChange: (t) => {
276
+ setText(t);
277
+ if (!t.trim()) {
278
+ setError(null);
279
+ onChange(undefined);
280
+ return;
281
+ }
282
+ try {
283
+ onChange(JSON.parse(t));
284
+ setError(null);
285
+ }
286
+ catch (e) {
287
+ setError(e.message);
288
+ }
289
+ } }), error && _jsx("div", { className: "v-field__error", children: error })] }));
290
+ }
291
+ /** Items with `enabled: false` (a common toggle in page-builder data) are shown dimmed. */
292
+ function isDisabled(item) {
293
+ return (typeof item === "object" &&
294
+ item !== null &&
295
+ item.enabled === false);
296
+ }
297
+ export function FieldGroup({ title, children, }) {
298
+ return (_jsxs("div", { className: "v-card", children: [_jsx("div", { className: "v-card__header", children: _jsx("h3", { className: "v-card__title", children: title }) }), _jsx("div", { className: "v-card__body", children: children })] }));
299
+ }
@@ -0,0 +1,31 @@
1
+ import type { CropField, FocalPointField } from "../../../config/types.js";
2
+ import type { PendingUpload } from "../AssetPicker.js";
3
+ export interface CropRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface FocalPoint {
10
+ x: number;
11
+ y: number;
12
+ }
13
+ /** Largest rectangle of the given aspect that fits the image, centred. */
14
+ export declare function defaultCrop(natural: {
15
+ width: number;
16
+ height: number;
17
+ }, aspect?: number): CropRect;
18
+ export declare function CropInput({ field, value, onChange, siblings, pendingUploads, }: {
19
+ field: CropField;
20
+ value: unknown;
21
+ onChange: (v: CropRect | undefined) => void;
22
+ siblings?: Record<string, unknown>;
23
+ pendingUploads?: Record<string, PendingUpload>;
24
+ }): import("react").JSX.Element;
25
+ export declare function FocalPointInput({ field, value, onChange, siblings, pendingUploads, }: {
26
+ field: FocalPointField;
27
+ value: unknown;
28
+ onChange: (v: FocalPoint | undefined) => void;
29
+ siblings?: Record<string, unknown>;
30
+ pendingUploads?: Record<string, PendingUpload>;
31
+ }): import("react").JSX.Element;
@@ -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;