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,313 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { AlertTriangle, ExternalLink, Save, Trash2 } from "lucide-react";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { useNavigate, useParams } from "react-router";
6
+ import { serializeEntry } from "../../core/entries.js";
7
+ import { collectionFormat, dirname, entryPath, fillTemplate as fillPathTemplate, normalizePath, parsePathPattern, relativePath, resolveRelative, slugify, } from "../../core/paths.js";
8
+ import { applyAutoFields, assetToRepoPath, repoPathToAsset, toStoredValues, } from "../../core/values.js";
9
+ import { ApiError } from "../api.js";
10
+ import { RichTextEditor } from "../components/editor/RichTextEditor.js";
11
+ import { FieldSet } from "../components/fields/FieldRenderer.js";
12
+ import { TopBar } from "../components/Layout.js";
13
+ import { Alert, Badge, Button, ConfirmDialog, Loading, Spinner, Toggle, useToast, } from "../components/ui.js";
14
+ import { useVestry } from "../context.js";
15
+ import { formatSource } from "../lib/format.js";
16
+ import { defaultValues, deepEqual, fieldLabel, fillTemplate, formatDate, validate, } from "../lib/utils.js";
17
+ export function CollectionEntryPage() {
18
+ const { collection = "", "*": slug = "" } = useParams();
19
+ return (_jsx(EntryEditor, { kind: "collection", collectionKey: collection, slug: slug || null }, `${collection}:${slug}`));
20
+ }
21
+ export function NewEntryPage() {
22
+ const { collection = "" } = useParams();
23
+ return (_jsx(EntryEditor, { kind: "collection", collectionKey: collection, slug: null, isNew: true }, `${collection}:new`));
24
+ }
25
+ export function SingletonPage() {
26
+ const { singleton = "" } = useParams();
27
+ return (_jsx(EntryEditor, { kind: "singleton", collectionKey: singleton, slug: null }, `singleton:${singleton}`));
28
+ }
29
+ function EntryEditor({ kind, collectionKey, slug, isNew = false, }) {
30
+ const { config, api, client } = useVestry();
31
+ const navigate = useNavigate();
32
+ const toast = useToast();
33
+ const qc = useQueryClient();
34
+ const col = kind === "collection"
35
+ ? config.collections?.[collectionKey]
36
+ : config.singletons?.[collectionKey];
37
+ const collection = kind === "collection" ? col : null;
38
+ const query = useQuery({
39
+ queryKey: kind === "collection"
40
+ ? ["entry", collectionKey, slug]
41
+ : ["singleton", collectionKey],
42
+ queryFn: () => kind === "collection"
43
+ ? api.getEntry(collectionKey, slug)
44
+ : api.getSingleton(collectionKey),
45
+ enabled: Boolean(col) && !isNew,
46
+ staleTime: 0,
47
+ });
48
+ const [state, setState] = useState(null);
49
+ const [initial, setInitial] = useState(null);
50
+ const [errors, setErrors] = useState([]);
51
+ const [uploads, setUploads] = useState({});
52
+ const [confirmDelete, setConfirmDelete] = useState(false);
53
+ const [slugInput, setSlugInput] = useState("");
54
+ const [sha, setSha] = useState(null);
55
+ // Initialise state from the loaded entry (or defaults for a new one).
56
+ useEffect(() => {
57
+ if (!col || state)
58
+ return;
59
+ if (isNew) {
60
+ const init = { data: defaultValues(col.schema), body: "" };
61
+ setState(init);
62
+ setInitial(init);
63
+ return;
64
+ }
65
+ if (query.data) {
66
+ const init = { data: query.data.data, body: query.data.body };
67
+ setState(init);
68
+ setInitial(init);
69
+ setSha(query.data.sha);
70
+ }
71
+ }, [col, isNew, query.data, state]);
72
+ const dirty = state !== null && initial !== null && !deepEqual(state, initial);
73
+ useEffect(() => {
74
+ if (!dirty)
75
+ return;
76
+ const onBeforeUnload = (e) => {
77
+ e.preventDefault();
78
+ };
79
+ window.addEventListener("beforeunload", onBeforeUnload);
80
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
81
+ }, [dirty]);
82
+ const format = col ? collectionFormat(col) : "mdx";
83
+ const pattern = useMemo(() => (col ? parsePathPattern(col.path) : null), [col]);
84
+ const effectiveSlug = useMemo(() => {
85
+ if (!collection || !state)
86
+ return slug ?? "";
87
+ if (!isNew)
88
+ return slug ?? "";
89
+ if (slugInput)
90
+ return slugInput;
91
+ const template = collection.slug?.template;
92
+ if (template)
93
+ return fillPathTemplate(template, state.data, { slug: true });
94
+ const from = collection.slug?.from ?? collection.titleField ?? "title";
95
+ const v = state.data[from];
96
+ return slugify(String(v ?? ""));
97
+ }, [collection, state, isNew, slug, slugInput]);
98
+ const filePath = useMemo(() => {
99
+ if (!pattern)
100
+ return "";
101
+ if (kind === "singleton")
102
+ return normalizePath(col.path);
103
+ if (pattern.single)
104
+ return pattern.prefix;
105
+ return entryPath(pattern, effectiveSlug || "new-entry");
106
+ }, [pattern, kind, col, effectiveSlug]);
107
+ // Body editor environment: resolve image references relative to this file.
108
+ const bodyConfig = col?.body === false ? null : (col?.body ?? {});
109
+ const env = useMemo(() => {
110
+ const images = bodyConfig?.images;
111
+ const fileDir = dirname(filePath);
112
+ const toRepoPath = (src) => {
113
+ if (/^https?:\/\//.test(src))
114
+ return null;
115
+ if (images)
116
+ return assetToRepoPath(images, src, filePath);
117
+ if (src.startsWith("/"))
118
+ return `public${src}`;
119
+ if (src.startsWith("."))
120
+ return resolveRelative(fileDir, src);
121
+ return null;
122
+ };
123
+ const components = Object.fromEntries((bodyConfig?.components ?? [])
124
+ .map((n) => [n, config.components?.[n]])
125
+ .filter(([, c]) => c));
126
+ return {
127
+ components,
128
+ imageDirectory: images?.directory,
129
+ toRepoPath,
130
+ toDocPath: (repoPath) => images
131
+ ? repoPathToAsset(images, repoPath, filePath)
132
+ : relativePath(fileDir, repoPath),
133
+ resolveImage: (src) => {
134
+ const repo = toRepoPath(src);
135
+ if (!repo)
136
+ return src;
137
+ const pending = uploads[repo];
138
+ return pending ? pending.previewUrl : api.fileUrl(repo);
139
+ },
140
+ };
141
+ }, [bodyConfig, filePath, config.components, uploads, api]);
142
+ const save = useMutation({
143
+ mutationFn: async () => {
144
+ if (!col || !state)
145
+ throw new Error("Nothing to save");
146
+ const errs = validate(col.schema, state.data);
147
+ setErrors(errs);
148
+ if (errs.length)
149
+ throw new Error(errs[0].message);
150
+ const data = applyAutoFields(col.schema, state.data);
151
+ const input = {
152
+ data,
153
+ body: state.body,
154
+ sha: isNew ? undefined : sha,
155
+ uploads: Object.values(uploads).map((u) => ({
156
+ path: u.path,
157
+ base64: u.base64,
158
+ })),
159
+ };
160
+ // Compose the whole file here (and format it with the project's
161
+ // Prettier) whenever the file holds just this entry. Single-file
162
+ // collections are merged on the server instead.
163
+ if (kind === "singleton" || (pattern && !pattern.single)) {
164
+ const stored = toStoredValues(col.schema, data, filePath);
165
+ let raw = serializeEntry(format, { data: stored, body: state.body });
166
+ try {
167
+ raw = await formatSource(raw, filePath);
168
+ }
169
+ catch (e) {
170
+ console.warn("[vestry] formatting failed, saving unformatted", e);
171
+ }
172
+ input.raw = raw;
173
+ }
174
+ if (kind === "singleton")
175
+ return {
176
+ ...(await api.saveSingleton(collectionKey, input)),
177
+ slug: collectionKey,
178
+ data,
179
+ };
180
+ if (isNew) {
181
+ input.slug = effectiveSlug;
182
+ return { ...(await api.createEntry(collectionKey, input)), data };
183
+ }
184
+ return { ...(await api.updateEntry(collectionKey, slug, input)), data };
185
+ },
186
+ onSuccess: (res) => {
187
+ toast("success", client.mode === "local" ? "Saved to disk" : "Saved and committed");
188
+ setUploads({});
189
+ setSha(res.sha);
190
+ setInitial(state ? { data: res.data, body: state.body } : null);
191
+ setState((s) => (s ? { ...s, data: res.data } : s));
192
+ qc.invalidateQueries({ queryKey: ["entries", collectionKey] });
193
+ qc.invalidateQueries({ queryKey: ["assets"] });
194
+ if (kind === "collection")
195
+ qc.setQueryData(["entry", collectionKey, res.slug], undefined);
196
+ if (isNew)
197
+ navigate(`/collections/${collectionKey}/${res.slug}`, {
198
+ replace: true,
199
+ });
200
+ },
201
+ onError: (e) => {
202
+ if (e instanceof ApiError && e.status === 409) {
203
+ toast("error", `${e.message} Reload the page to pick up the latest version.`);
204
+ }
205
+ else {
206
+ toast("error", e.message);
207
+ }
208
+ },
209
+ });
210
+ const remove = useMutation({
211
+ mutationFn: () => api.deleteEntry(collectionKey, slug),
212
+ onSuccess: () => {
213
+ toast("success", "Entry deleted");
214
+ qc.invalidateQueries({ queryKey: ["entries", collectionKey] });
215
+ navigate(`/collections/${collectionKey}`);
216
+ },
217
+ onError: (e) => toast("error", e.message),
218
+ });
219
+ const saveRef = useRef(save);
220
+ saveRef.current = save;
221
+ useEffect(() => {
222
+ const onKey = (e) => {
223
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
224
+ e.preventDefault();
225
+ if (!saveRef.current.isPending)
226
+ saveRef.current.mutate();
227
+ }
228
+ };
229
+ window.addEventListener("keydown", onKey);
230
+ return () => window.removeEventListener("keydown", onKey);
231
+ }, []);
232
+ const setData = useCallback((data) => setState((s) => (s ? { ...s, data } : s)), []);
233
+ const setBody = useCallback((body) => setState((s) => (s ? { ...s, body } : s)), []);
234
+ const addUpload = useCallback((u) => setUploads((m) => ({ ...m, [u.path]: u })), []);
235
+ if (!col)
236
+ return (_jsxs(Alert, { kind: "error", children: ["Unknown ", kind, " \u201C", collectionKey, "\u201D."] }));
237
+ if (!isNew && query.isLoading)
238
+ return _jsx(Loading, {});
239
+ if (!isNew && query.error) {
240
+ return (_jsx("div", { className: "v-page", children: _jsx(Alert, { kind: "error", children: query.error.message }) }));
241
+ }
242
+ if (!state)
243
+ return _jsx(Loading, {});
244
+ const titleField = kind === "collection" ? (collection.titleField ?? "title") : null;
245
+ const titleIsText = titleField
246
+ ? col.schema[titleField]?.type === "text"
247
+ : false;
248
+ const statusField = collection?.status?.field ?? null;
249
+ const publishedWhen = collection?.status?.publishedWhen ?? true;
250
+ const isPublished = statusField
251
+ ? Boolean(state.data[statusField]) === publishedWhen
252
+ : null;
253
+ const titleValue = titleField
254
+ ? String(state.data[titleField] ?? "")
255
+ : col.label;
256
+ const previewHref = col.previewUrl && !isNew
257
+ ? `${config.siteUrl ?? ""}${fillTemplate(col.previewUrl, { ...state.data, slug: effectiveSlug })}`
258
+ : null;
259
+ const entry = query.data;
260
+ const sections = col.sections ?? [];
261
+ const sectioned = new Set(sections.flatMap((s) => (typeof s === "string" ? [] : s.fields)));
262
+ const isEditable = (key) => key !== statusField && !(titleIsText && key === titleField);
263
+ const isMain = (key, f) => !f.sidebar && !sectioned.has(key) && isEditable(key);
264
+ const isSidebar = (key, f) => Boolean(f.sidebar) && !sectioned.has(key) && isEditable(key);
265
+ const hasVisible = (filter) => Object.entries(col.schema).some(([k, f]) => filter(k, f) && !f.hidden);
266
+ const bodyPlaced = sections.includes("body");
267
+ const sectionCard = (section, i) => {
268
+ const inSection = (key) => section.fields.includes(key) && isEditable(key);
269
+ if (!hasVisible(inSection))
270
+ return null;
271
+ // A lone field named like its section doesn't need a second heading.
272
+ const lone = section.fields.length === 1 ? section.fields[0] : null;
273
+ const hideLabels = lone &&
274
+ col.schema[lone] &&
275
+ fieldLabel(lone, col.schema[lone]) === section.label
276
+ ? [lone]
277
+ : undefined;
278
+ return (_jsxs("div", { className: "v-card", children: [_jsxs("div", { className: "v-card__header", children: [_jsx("h3", { className: "v-card__title", children: section.label }), section.description && (_jsx("span", { className: "v-muted", style: { fontSize: 12.5 }, children: section.description }))] }), _jsx("div", { className: "v-card__body", children: _jsx(FieldSet, { schema: Object.fromEntries(section.fields
279
+ .filter((k) => col.schema[k])
280
+ .map((k) => [k, col.schema[k]])), values: state.data, onChange: setData, errors: errors, filter: inSection, ctx: ctx, hideLabels: hideLabels }) })] }, `section-${i}`));
281
+ };
282
+ const bodyCard = bodyConfig && (format === "md" || format === "mdx") ? (_jsxs("div", { className: "v-card v-card--editor", children: [_jsx("div", { className: "v-card__header", children: _jsx("h3", { className: "v-card__title", children: bodyConfig.label ?? "Content" }) }), _jsx(RichTextEditor, { value: state.body, onChange: setBody, components: bodyConfig.components ?? [], env: env, format: format, placeholder: bodyConfig.placeholder, onUpload: addUpload, flush: true })] }, "body")) : null;
283
+ const ctx = {
284
+ addUpload,
285
+ pendingUploads: uploads,
286
+ isNew,
287
+ siblings: state.data,
288
+ };
289
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { status: isPublished === null ? null : isPublished ? "published" : "draft", crumbs: kind === "collection"
290
+ ? [
291
+ {
292
+ label: collection.label,
293
+ to: `/collections/${collectionKey}`,
294
+ },
295
+ ]
296
+ : undefined, title: isNew
297
+ ? `New ${collection?.singular ?? "entry"}`
298
+ : titleValue || effectiveSlug, actions: _jsxs(_Fragment, { children: [dirty && (_jsxs("span", { className: "v-unsaved", children: [_jsx(AlertTriangle, { size: 13 }), " Unsaved changes"] })), previewHref && (_jsxs("a", { className: "v-btn", href: previewHref, target: "_blank", rel: "noreferrer", children: [_jsx(ExternalLink, {}), " View"] })), kind === "collection" &&
299
+ !isNew &&
300
+ collection.delete !== false && (_jsx(Button, { variant: "danger", icon: true, title: "Delete", onClick: () => setConfirmDelete(true), children: _jsx(Trash2, {}) })), _jsxs(Button, { variant: "primary", onClick: () => save.mutate(), disabled: save.isPending ||
301
+ (!dirty && !isNew && Object.keys(uploads).length === 0), children: [save.isPending ? _jsx(Spinner, {}) : _jsx(Save, {}), " ", isNew ? "Create" : "Save", _jsx("span", { className: "v-kbd", children: "\u2318S" })] })] }) }), _jsxs("div", { className: "v-page", children: [errors.length > 0 && (_jsx("div", { style: { marginBottom: 18 }, children: _jsxs(Alert, { kind: "error", children: [_jsx("strong", { children: "Please fix the following:" }), _jsx("ul", { style: { margin: "4px 0 0 16px", padding: 0 }, children: errors.map((e) => (_jsx("li", { children: e.message }, e.path))) })] }) })), _jsxs("div", { className: "v-editor-layout", children: [_jsxs("div", { className: "v-stack", style: { gap: 20 }, children: [titleIsText && (_jsx("div", { className: "v-card", children: _jsxs("div", { className: "v-card__body", style: { padding: "14px 18px" }, children: [_jsx("input", { className: "v-title-input", value: titleValue, placeholder: `${fieldLabel(titleField, col.schema[titleField])}…`, onChange: (e) => setData({ ...state.data, [titleField]: e.target.value }), autoFocus: isNew }), errors.some((e) => e.path === titleField) && (_jsx("div", { className: "v-field__error", children: errors.find((e) => e.path === titleField).message }))] }) })), sections.map((section, i) => section === "body"
302
+ ? bodyCard
303
+ : section.sidebar
304
+ ? null
305
+ : sectionCard(section, i)), hasVisible(isMain) && (_jsxs("div", { className: "v-card", children: [_jsx("div", { className: "v-card__header", children: _jsx("h3", { className: "v-card__title", children: "Details" }) }), _jsx("div", { className: "v-card__body", children: _jsx(FieldSet, { schema: col.schema, values: state.data, onChange: setData, errors: errors, filter: isMain, ctx: ctx }) })] })), !bodyPlaced && bodyCard] }), _jsxs("aside", { className: "v-editor-sidebar v-stack", children: [(statusField || kind === "collection") && (_jsxs("div", { className: "v-card", children: [_jsxs("div", { className: "v-card__header", children: [_jsx("h3", { className: "v-card__title", children: "Publish" }), isPublished !== null && (_jsx("span", { style: { marginLeft: "auto" }, children: _jsx(Badge, { status: isPublished ? "published" : "draft" }) }))] }), _jsxs("div", { className: "v-card__body v-stack", children: [statusField && (_jsx(Toggle, { checked: isPublished ?? false, label: isPublished ? "Published" : "Draft", onChange: (on) => setData({
306
+ ...state.data,
307
+ [statusField]: on ? publishedWhen : !publishedWhen,
308
+ }) })), kind === "collection" && (_jsxs("div", { children: [_jsx("label", { className: "v-field__label", children: "Slug" }), isNew && !pattern?.single ? (_jsx("input", { className: "v-input v-input--mono", value: slugInput || effectiveSlug, onChange: (e) => setSlugInput(e.target.value), placeholder: "auto" })) : (_jsx("input", { className: "v-input v-input--mono", readOnly: true, value: effectiveSlug })), isNew && pattern && !pattern.single && (_jsx("div", { className: "v-field__hint v-mono", style: { fontSize: 11.5 }, children: entryPath(pattern, effectiveSlug || "…") }))] })), entry && (_jsxs("dl", { className: "v-meta", children: [_jsx("dt", { children: "File" }), _jsx("dd", { title: entry.path, children: entry.path }), Object.entries(col.schema)
309
+ .filter(([, f]) => f.type === "datetime" && f.autoUpdate)
310
+ .map(([k]) => (_jsxs("span", { style: { display: "contents" }, children: [_jsx("dt", { children: fieldLabel(k, col.schema[k]) }), _jsx("dd", { children: state.data[k] ? formatDate(state.data[k]) : "—" })] }, k)))] }))] })] })), sections.map((section, i) => section !== "body" && section.sidebar
311
+ ? sectionCard(section, i)
312
+ : null), hasVisible(isSidebar) && (_jsx("div", { className: "v-card", children: _jsx("div", { className: "v-card__body", children: _jsx(FieldSet, { schema: col.schema, values: state.data, onChange: setData, errors: errors, filter: isSidebar, ctx: ctx }) }) })), Object.keys(uploads).length > 0 && (_jsxs(Alert, { kind: "info", children: [Object.keys(uploads).length, " new file", Object.keys(uploads).length === 1 ? "" : "s", " will be committed with this entry."] }))] })] })] }), confirmDelete && (_jsx(ConfirmDialog, { title: `Delete “${titleValue}”?`, message: "The file will be removed from the repository in a new commit.", confirmLabel: "Delete", danger: true, busy: remove.isPending, onCancel: () => setConfirmDelete(false), onConfirm: () => remove.mutate() }))] }));
313
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Two-step sign-in: an email address, then the six-digit code we emailed.
3
+ * The first step always "succeeds" so the page never reveals who has an
4
+ * account.
5
+ */
6
+ export declare function LoginPage(): import("react").JSX.Element;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ArrowLeft, KeyRound, Mail } from "lucide-react";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { authErrorMessage } from "../auth-client.js";
5
+ import { Alert, Spinner } from "../components/ui.js";
6
+ import { useVestry } from "../context.js";
7
+ /**
8
+ * Two-step sign-in: an email address, then the six-digit code we emailed.
9
+ * The first step always "succeeds" so the page never reveals who has an
10
+ * account.
11
+ */
12
+ export function LoginPage() {
13
+ const { api, authClient, client, name, refresh } = useVestry();
14
+ const [step, setStep] = useState("email");
15
+ const [email, setEmail] = useState("");
16
+ const [code, setCode] = useState("");
17
+ const [busy, setBusy] = useState(false);
18
+ const [error, setError] = useState(null);
19
+ const [notice, setNotice] = useState(null);
20
+ const codeRef = useRef(null);
21
+ useEffect(() => {
22
+ if (step === "code")
23
+ codeRef.current?.focus();
24
+ }, [step]);
25
+ const logo = client.config.logo
26
+ ? /^https?:\/\//.test(client.config.logo)
27
+ ? client.config.logo
28
+ : api.fileUrl(client.config.logo)
29
+ : null;
30
+ /** Asks for a code; resolves true when the request was accepted. */
31
+ async function requestCode(e) {
32
+ e?.preventDefault();
33
+ setError(null);
34
+ setNotice(null);
35
+ setBusy(true);
36
+ try {
37
+ const result = await authClient.emailOtp.sendVerificationOtp({
38
+ email: email.trim(),
39
+ type: "sign-in",
40
+ });
41
+ if (result.error)
42
+ throw result.error;
43
+ setCode("");
44
+ setStep("code");
45
+ return true;
46
+ }
47
+ catch (err) {
48
+ setError(authErrorMessage(err));
49
+ return false;
50
+ }
51
+ finally {
52
+ setBusy(false);
53
+ }
54
+ }
55
+ async function verify(e) {
56
+ e.preventDefault();
57
+ setError(null);
58
+ setBusy(true);
59
+ try {
60
+ const result = await authClient.signIn.emailOtp({
61
+ email: email.trim(),
62
+ otp: code,
63
+ });
64
+ if (result.error)
65
+ throw result.error;
66
+ // Once the config reloads with a user, the /login route redirects to `next`.
67
+ await refresh();
68
+ }
69
+ catch (err) {
70
+ const e = err;
71
+ setError(authErrorMessage(e));
72
+ if (e.code === "OTP_EXPIRED" || e.code === "TOO_MANY_ATTEMPTS") {
73
+ setStep("email");
74
+ }
75
+ }
76
+ finally {
77
+ setBusy(false);
78
+ }
79
+ }
80
+ return (_jsx("div", { className: "v-login", children: _jsxs("div", { className: "v-card v-login__card", children: [logo ? (_jsx("div", { className: "v-login__logo", children: _jsx("img", { src: logo, alt: name }) })) : (_jsx("div", { className: "v-login__mark", children: name.slice(0, 1) })), _jsx("h1", { children: name }), client.setupError ? (_jsxs(_Fragment, { children: [_jsx("p", { children: "Vestry isn't configured yet." }), _jsx(Alert, { kind: "error", children: client.setupError })] })) : step === "email" ? (_jsxs("form", { onSubmit: requestCode, className: "v-login__form", children: [_jsx("p", { children: "Enter your email address and we'll send you a code to sign in." }), _jsxs("label", { className: "v-login__field", children: [_jsx("span", { className: "v-field__label", children: "Email" }), _jsx("input", { className: "v-input", type: "email", name: "email", autoComplete: "email", autoFocus: true, required: true, value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.org" })] }), error && _jsx(Alert, { kind: "error", children: error }), _jsxs("button", { className: "v-btn v-btn--primary", type: "submit", disabled: busy || !email, children: [busy ? _jsx(Spinner, {}) : _jsx(Mail, {}), " Email me a code"] })] })) : (_jsxs("form", { onSubmit: verify, className: "v-login__form", children: [_jsxs("p", { children: ["If ", _jsx("strong", { children: email }), " can edit this site, we've emailed it a six-digit code. It expires in 10 minutes."] }), _jsxs("label", { className: "v-login__field", children: [_jsx("span", { className: "v-field__label", children: "Code" }), _jsx("input", { ref: codeRef, className: "v-input v-login__code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9 ]*", maxLength: 7, required: true, value: code, onChange: (e) => setCode(e.target.value.replace(/[^\d]/g, "").slice(0, 6)), placeholder: "123456" })] }), error && _jsx(Alert, { kind: "error", children: error }), notice && _jsx(Alert, { kind: "info", children: notice }), _jsxs("button", { className: "v-btn v-btn--primary", type: "submit", disabled: busy || code.length !== 6, children: [busy ? _jsx(Spinner, {}) : _jsx(KeyRound, {}), " Sign in"] }), _jsxs("div", { className: "v-login__links", children: [_jsxs("button", { type: "button", className: "v-link", disabled: busy, onClick: () => {
81
+ setStep("email");
82
+ setError(null);
83
+ setNotice(null);
84
+ }, children: [_jsx(ArrowLeft, { size: 13 }), " Use a different email"] }), _jsx("button", { type: "button", className: "v-link", disabled: busy, onClick: async () => {
85
+ if (await requestCode())
86
+ setNotice("We sent a new code.");
87
+ }, children: "Send a new code" })] })] }))] }) }));
88
+ }
@@ -0,0 +1 @@
1
+ export declare function UsersPage(): import("react").JSX.Element;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Mail, MoreHorizontal, Plus, Trash2, UserCog } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { Navigate } from "react-router";
6
+ import { ROLES } from "../../core/roles.js";
7
+ import { TopBar } from "../components/Layout.js";
8
+ import { Alert, Badge, Button, ConfirmDialog, EmptyState, Loading, Menu, Modal, Spinner, useToast, } from "../components/ui.js";
9
+ import { useVestry } from "../context.js";
10
+ import { formatDate, initials } from "../lib/utils.js";
11
+ export function UsersPage() {
12
+ const { api, client, refresh } = useVestry();
13
+ const toast = useToast();
14
+ const queryClient = useQueryClient();
15
+ const me = client.user;
16
+ const [inviting, setInviting] = useState(false);
17
+ const [editing, setEditing] = useState(null);
18
+ const [removing, setRemoving] = useState(null);
19
+ const q = useQuery({
20
+ queryKey: ["users"],
21
+ queryFn: () => api.listUsers(),
22
+ enabled: me.role === "admin" && client.auth,
23
+ });
24
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ["users"] });
25
+ const resend = useMutation({
26
+ mutationFn: (u) => api.resendInvite(u.id),
27
+ onSuccess: (_r, u) => toast("success", `Invitation re-sent to ${u.email}.`),
28
+ onError: (e) => toast("error", message(e)),
29
+ });
30
+ const remove = useMutation({
31
+ mutationFn: (u) => api.removeUser(u.id),
32
+ onSuccess: (_r, u) => {
33
+ toast("success", `${u.name} was removed.`);
34
+ setRemoving(null);
35
+ invalidate();
36
+ },
37
+ onError: (e) => toast("error", message(e)),
38
+ });
39
+ if (me.role !== "admin")
40
+ return _jsx(Navigate, { to: "/", replace: true });
41
+ const crumbs = [{ label: "Users" }];
42
+ const users = q.data?.users ?? [];
43
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { title: "Users", crumbs: crumbs, actions: client.auth && (_jsxs(Button, { variant: "primary", onClick: () => setInviting(true), children: [_jsx(Plus, {}), " Invite user"] })) }), _jsx("div", { className: "v-page", children: !client.auth ? (_jsxs(Alert, { kind: "info", children: ["Sign-in is off in local mode, so there are no users to manage. Start the dev server with ", _jsx("code", { children: "VESTRY_MODE=auth" }), " to work on sign-in and users locally."] })) : q.isLoading ? (_jsx(Loading, { label: "Loading users\u2026" })) : q.error ? (_jsx(Alert, { kind: "error", children: message(q.error) })) : users.length === 0 ? (_jsx("div", { className: "v-card", children: _jsx(EmptyState, { icon: _jsx(UserCog, {}), title: "No users yet", children: _jsx("p", { children: "Invite the people who should be able to edit the site." }) }) })) : (_jsx("div", { className: "v-card", children: _jsx("div", { className: "v-table-wrap", children: _jsxs("table", { className: "v-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Name" }), _jsx("th", { children: "Role" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Last sign-in" }), _jsx("th", {})] }) }), _jsx("tbody", { children: users.map((u) => (_jsxs("tr", { children: [_jsx("td", { className: "v-table__main", children: _jsxs("span", { className: "v-user", children: [_jsx("span", { className: "v-avatar", "aria-hidden": true, children: initials(u.name || u.email) }), _jsxs("span", { className: "v-user__text", children: [_jsxs("span", { className: "v-table__title", children: [u.name, u.id === me.id && (_jsx("span", { className: "v-muted", children: " (you)" }))] }), _jsx("span", { className: "v-table__sub", children: u.email })] })] }) }), _jsxs("td", { style: { whiteSpace: "nowrap" }, children: [roleLabel(u.role), u.origin === "config" && (_jsxs("span", { className: "v-muted", title: "Listed in auth.admins in vestry.config", children: [" ", "\u00B7 config"] }))] }), _jsx("td", { children: u.status === "active" ? (_jsx(Badge, { status: "published", children: "Active" })) : (_jsx(Badge, { status: "draft", children: "Invited" })) }), _jsx("td", { className: "v-muted", style: { whiteSpace: "nowrap" }, children: u.lastLoginAt ? formatDate(u.lastLoginAt) : "—" }), _jsx("td", { className: "v-table__actions", children: _jsx(Menu, { align: "right", trigger: ({ toggle }) => (_jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Actions", onClick: toggle, children: _jsx(MoreHorizontal, {}) })), children: (close) => (_jsxs(_Fragment, { children: [_jsxs("button", { className: "v-menu__item", onClick: () => {
44
+ close();
45
+ setEditing(u);
46
+ }, children: [_jsx(UserCog, {}), _jsx("span", { children: "Edit name and role" })] }), u.status === "invited" && (_jsxs("button", { className: "v-menu__item", onClick: () => {
47
+ close();
48
+ resend.mutate(u);
49
+ }, children: [_jsx(Mail, {}), _jsx("span", { children: "Re-send invitation" })] })), u.id !== me.id && u.origin !== "config" && (_jsxs("button", { className: "v-menu__item v-menu__item--danger", onClick: () => {
50
+ close();
51
+ setRemoving(u);
52
+ }, children: [_jsx(Trash2, {}), _jsx("span", { children: "Remove" })] }))] })) }) })] }, u.id))) })] }) }) })) }), inviting && (_jsx(UserForm, { title: "Invite user", submitLabel: "Send invitation", onClose: () => setInviting(false), onSubmit: async (values) => {
53
+ const { user, mailError } = await api.inviteUser(values);
54
+ if (mailError) {
55
+ toast("error", `${user.name} was added, but the invitation email failed: ${mailError}. Use "Re-send invitation" once email is working.`);
56
+ }
57
+ else {
58
+ toast("success", `Invitation sent to ${user.email}.`);
59
+ }
60
+ setInviting(false);
61
+ invalidate();
62
+ } })), editing && (_jsx(UserForm, { title: `Edit ${editing.name}`, submitLabel: "Save", user: editing, onClose: () => setEditing(null), onSubmit: async (values) => {
63
+ await api.updateUser(editing.id, {
64
+ name: values.name,
65
+ role: values.role,
66
+ });
67
+ toast("success", "User updated.");
68
+ setEditing(null);
69
+ invalidate();
70
+ if (editing.id === me.id)
71
+ await refresh();
72
+ } })), removing && (_jsx(ConfirmDialog, { title: `Remove ${removing.name}?`, message: `${removing.email} will no longer be able to sign in. Content they created stays as it is.`, confirmLabel: "Remove", danger: true, busy: remove.isPending, onCancel: () => setRemoving(null), onConfirm: () => remove.mutate(removing) }))] }));
73
+ }
74
+ function UserForm({ title, submitLabel, user, onClose, onSubmit, }) {
75
+ const [email, setEmail] = useState(user?.email ?? "");
76
+ const [name, setName] = useState(user?.name ?? "");
77
+ const [role, setRole] = useState(user?.role ?? "editor");
78
+ const [busy, setBusy] = useState(false);
79
+ const [error, setError] = useState(null);
80
+ const roleLocked = user?.origin === "config";
81
+ async function submit(e) {
82
+ e.preventDefault();
83
+ setBusy(true);
84
+ setError(null);
85
+ try {
86
+ await onSubmit({ email, name, role });
87
+ }
88
+ catch (err) {
89
+ setError(message(err));
90
+ }
91
+ finally {
92
+ setBusy(false);
93
+ }
94
+ }
95
+ return (_jsx(Modal, { title: title, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: onClose, disabled: busy, children: "Cancel" }), _jsxs(Button, { variant: "primary", type: "submit", form: "v-user-form", disabled: busy || (!user && !email), children: [busy ? _jsx(Spinner, {}) : null, submitLabel] })] }), children: _jsxs("form", { id: "v-user-form", onSubmit: submit, className: "v-fields", children: [!user && (_jsxs("label", { className: "v-field", children: [_jsx("span", { className: "v-field__label", children: "Email" }), _jsx("input", { className: "v-input", type: "email", autoFocus: true, required: true, value: email, onChange: (e) => setEmail(e.target.value), placeholder: "colleague@example.org" }), _jsx("span", { className: "v-field__hint", children: "They'll get an email with a link to sign in. No password needed: we email a code each time." })] })), _jsxs("label", { className: "v-field", children: [_jsx("span", { className: "v-field__label", children: "Name" }), _jsx("input", { className: "v-input", type: "text", autoFocus: !!user, value: name, onChange: (e) => setName(e.target.value), placeholder: user ? undefined : "Optional" })] }), _jsxs("div", { className: "v-field", children: [_jsx("span", { className: "v-field__label", children: "Role" }), _jsx("div", { className: "v-radio-cards", children: ROLES.map((r) => (_jsxs("label", { className: `v-radio-card${role === r.value ? " is-selected" : ""}${roleLocked ? " is-disabled" : ""}`, children: [_jsx("input", { type: "radio", name: "role", value: r.value, checked: role === r.value, disabled: roleLocked, onChange: () => setRole(r.value) }), _jsxs("span", { className: "v-radio-card__body", children: [_jsx("span", { className: "v-radio-card__label", children: r.label }), _jsx("span", { className: "v-radio-card__desc", children: r.description })] })] }, r.value))) }), roleLocked && (_jsxs("span", { className: "v-field__hint", children: ["This address is listed in ", _jsx("code", { children: "auth.admins" }), " in the Vestry config, so it is always an admin."] }))] }), error && _jsx(Alert, { kind: "error", children: error })] }) }));
96
+ }
97
+ function roleLabel(role) {
98
+ return ROLES.find((r) => r.value === role)?.label ?? role;
99
+ }
100
+ function message(e) {
101
+ return e instanceof Error ? e.message : String(e);
102
+ }