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,115 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { ArrowDown, ArrowUp, ExternalLink, Plus, Search } from "lucide-react";
4
+ import { useMemo, useState } from "react";
5
+ import { Link, useParams } from "react-router";
6
+ import { Icon } from "../components/Icon.js";
7
+ import { TopBar } from "../components/Layout.js";
8
+ import { Alert, Badge, EmptyState, Loading } from "../components/ui.js";
9
+ import { useVestry } from "../context.js";
10
+ import { cx, fieldLabel, fillTemplate, formatDate, titleCase, } from "../lib/utils.js";
11
+ export function CollectionListPage() {
12
+ const { collection: key = "" } = useParams();
13
+ const { config, api } = useVestry();
14
+ const col = config.collections?.[key];
15
+ const [search, setSearch] = useState("");
16
+ const [status, setStatus] = useState("all");
17
+ const [sort, setSort] = useState(null);
18
+ const q = useQuery({
19
+ queryKey: ["entries", key],
20
+ queryFn: () => api.listEntries(key),
21
+ enabled: Boolean(col),
22
+ });
23
+ const columns = useMemo(() => {
24
+ if (!col)
25
+ return [];
26
+ const list = col.columns ?? (col.sort?.field ? [col.sort.field] : []);
27
+ return list.map((c) => (typeof c === "string" ? { field: c } : c));
28
+ }, [col]);
29
+ const entries = useMemo(() => {
30
+ let list = q.data?.entries ?? [];
31
+ if (status !== "all")
32
+ list = list.filter((e) => e.status === status);
33
+ if (search.trim()) {
34
+ const s = search.toLowerCase();
35
+ const fields = col?.searchFields ?? [col?.titleField ?? "title"];
36
+ list = list.filter((e) => e.title.toLowerCase().includes(s) ||
37
+ e.slug.toLowerCase().includes(s) ||
38
+ fields.some((f) => String(e.data[f] ?? "")
39
+ .toLowerCase()
40
+ .includes(s)));
41
+ }
42
+ if (sort) {
43
+ const dir = sort.dir === "asc" ? 1 : -1;
44
+ list = [...list].sort((a, b) => {
45
+ const av = sort.field === "title"
46
+ ? a.title
47
+ : sort.field === "slug"
48
+ ? a.slug
49
+ : String(a.data[sort.field] ?? "");
50
+ const bv = sort.field === "title"
51
+ ? b.title
52
+ : sort.field === "slug"
53
+ ? b.slug
54
+ : String(b.data[sort.field] ?? "");
55
+ return av < bv ? -dir : av > bv ? dir : 0;
56
+ });
57
+ }
58
+ return list;
59
+ }, [q.data, status, search, sort, col]);
60
+ if (!col)
61
+ return _jsxs(Alert, { kind: "error", children: ["Unknown collection \u201C", key, "\u201D."] });
62
+ const toggleSort = (field) => setSort((s) => s?.field === field
63
+ ? { field, dir: s.dir === "asc" ? "desc" : "asc" }
64
+ : { field, dir: "asc" });
65
+ const header = (field, label) => (_jsx("th", { children: _jsxs("button", { type: "button", onClick: () => toggleSort(field), children: [label, sort?.field === field &&
66
+ (sort.dir === "asc" ? _jsx(ArrowUp, {}) : _jsx(ArrowDown, {}))] }) }, field));
67
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { title: col.label, actions: col.create !== false && (_jsxs(Link, { to: `/collections/${key}/new`, className: "v-btn v-btn--primary", children: [_jsx(Plus, {}), " New ", col.singular ?? "entry"] })) }), _jsxs("div", { className: "v-page", children: [_jsxs("div", { className: "v-toolbar", children: [_jsxs("div", { className: "v-search", children: [_jsx(Search, {}), _jsx("input", { className: "v-input", placeholder: `Search ${col.label.toLowerCase()}…`, value: search, onChange: (e) => setSearch(e.target.value) })] }), col.status && (_jsx("div", { className: "v-segment", children: ["all", "published", "draft"].map((s) => (_jsx("button", { type: "button", className: cx(status === s && "is-active"), onClick: () => setStatus(s), children: titleCase(s) }, s))) }))] }), _jsxs("div", { className: "v-card", children: [q.isLoading && _jsx(Loading, {}), q.error && (_jsx("div", { style: { padding: 18 }, children: _jsx(Alert, { kind: "error", children: q.error.message }) })), q.data && entries.length === 0 && (_jsx(EmptyState, { icon: _jsx(Icon, { name: col.icon ?? "files" }), title: search ? "No matches" : `No ${col.label.toLowerCase()} yet`, children: !search && col.create !== false && (_jsx("p", { children: _jsxs(Link, { to: `/collections/${key}/new`, className: "v-btn", children: [_jsx(Plus, {}), " Create the first", " ", col.singular?.toLowerCase() ?? "entry"] }) })) })), q.data && entries.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "v-table-wrap", children: _jsxs("table", { className: "v-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [header("title", "Title"), columns.map((c) => {
68
+ if (c.field === "status" || c.field === "slug")
69
+ return header(c.field, c.label ?? titleCase(c.field));
70
+ const f = col.schema[c.field];
71
+ return header(c.field, c.label ??
72
+ (f ? fieldLabel(c.field, f) : titleCase(c.field)));
73
+ }), col.status &&
74
+ !columns.some((c) => c.field === "status") && (_jsx("th", { children: "Status" })), _jsx("th", {})] }) }), _jsx("tbody", { children: entries.map((e) => (_jsxs("tr", { children: [_jsxs("td", { className: "v-table__main", children: [_jsx(Link, { className: "v-table__title", to: `/collections/${key}/${e.slug}`, children: e.title }), e.data[col.titleField ?? "title"] !== undefined &&
75
+ e.data.subtitle ? (_jsx("span", { className: "v-table__sub", children: String(e.data.subtitle) })) : (e.excerpt && (_jsx("span", { className: "v-table__sub", children: e.excerpt })))] }), columns.map((c) => (_jsx("td", { style: { whiteSpace: "nowrap" }, children: c.field === "status" ? (_jsx(Badge, { status: e.status })) : c.field === "slug" ? (_jsx("span", { className: "v-mono", children: e.slug })) : (_jsx(CellValue, { field: col.schema[c.field], value: e.data[c.field] })) }, c.field))), col.status &&
76
+ !columns.some((c) => c.field === "status") && (_jsx("td", { children: _jsx(Badge, { status: e.status }) })), _jsx("td", { className: "v-table__actions", children: col.previewUrl && (_jsx("a", { className: "v-btn v-btn--ghost v-btn--xs v-btn--icon", title: "View on site", target: "_blank", rel: "noreferrer", href: `${config.siteUrl ?? ""}${fillTemplate(col.previewUrl, { ...e.data, slug: e.slug })}`, children: _jsx(ExternalLink, {}) })) })] }, e.slug))) })] }) }), _jsxs("div", { className: "v-table__footer", children: [entries.length, " of ", q.data.entries.length, " ", col.label.toLowerCase()] })] }))] })] })] }));
77
+ }
78
+ export function CellValue({ field, value }) {
79
+ const { api } = useVestry();
80
+ if (value === undefined || value === null || value === "")
81
+ return _jsx("span", { className: "v-faint", children: "\u2014" });
82
+ if (!field)
83
+ return _jsx(_Fragment, { children: String(value) });
84
+ switch (field.type) {
85
+ case "date":
86
+ case "datetime":
87
+ return _jsx(_Fragment, { children: formatDate(value) });
88
+ case "boolean":
89
+ return _jsx(_Fragment, { children: value ? "Yes" : "No" });
90
+ case "select": {
91
+ const opt = field.options.find((o) => o.value === value);
92
+ return (_jsxs("span", { className: "v-row", style: { gap: 6 }, children: [opt?.color && (_jsx("span", { className: "v-swatch", style: { background: opt.color } })), opt?.label ?? String(value)] }));
93
+ }
94
+ case "image":
95
+ return (_jsx("img", { className: "v-thumb", src: api.fileUrl(String(value)), alt: "", loading: "lazy" }));
96
+ case "reference":
97
+ return (_jsx(ReferenceLabel, { collection: field.collection, id: String(value) }));
98
+ case "array":
99
+ return _jsx(_Fragment, { children: Array.isArray(value) ? `${value.length} items` : "" });
100
+ case "object":
101
+ return _jsx("span", { className: "v-muted", children: "\u2026" });
102
+ default:
103
+ return _jsx(_Fragment, { children: String(value) });
104
+ }
105
+ }
106
+ function ReferenceLabel({ collection, id, }) {
107
+ const { api } = useVestry();
108
+ const q = useQuery({
109
+ queryKey: ["entries", collection],
110
+ queryFn: () => api.listEntries(collection),
111
+ staleTime: 60_000,
112
+ });
113
+ const e = q.data?.entries.find((x) => x.slug === id);
114
+ return _jsx(_Fragment, { children: e?.title ?? id });
115
+ }
@@ -0,0 +1 @@
1
+ export declare function DashboardPage(): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useQueries } from "@tanstack/react-query";
3
+ import { ArrowRight, Clock } from "lucide-react";
4
+ import { Link } from "react-router";
5
+ import { TopBar } from "../components/Layout.js";
6
+ import { Icon } from "../components/Icon.js";
7
+ import { Badge } from "../components/ui.js";
8
+ import { useVestry } from "../context.js";
9
+ import { formatDate } from "../lib/utils.js";
10
+ const DEFAULT_RECENT = 5;
11
+ /** Which date field (if any) a collection's recent list should show. */
12
+ function recentDateField(col) {
13
+ const field = col.sort?.field;
14
+ if (!field)
15
+ return null;
16
+ const type = col.schema[field]?.type;
17
+ return type === "date" || type === "datetime" ? field : null;
18
+ }
19
+ function recentCount(col) {
20
+ if (col.recent === false)
21
+ return 0;
22
+ if (typeof col.recent === "number")
23
+ return col.recent;
24
+ if (col.recent === true)
25
+ return DEFAULT_RECENT;
26
+ return recentDateField(col) ? DEFAULT_RECENT : 0;
27
+ }
28
+ export function DashboardPage() {
29
+ const { config, api, client } = useVestry();
30
+ const collections = Object.entries(config.collections ?? {});
31
+ const queries = useQueries({
32
+ queries: collections.map(([key]) => ({
33
+ queryKey: ["entries", key],
34
+ queryFn: () => api.listEntries(key),
35
+ staleTime: 30_000,
36
+ })),
37
+ });
38
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { title: "Dashboard" }), _jsxs("div", { className: "v-page", children: [collections.length > 0 && (_jsxs("section", { className: "v-section", children: [_jsx("h2", { className: "v-section-title", children: "Collections" }), _jsx("div", { className: "v-stats", children: collections.map(([key, col], i) => {
39
+ const q = queries[i];
40
+ const count = q.data?.entries.length;
41
+ const drafts = q.data?.entries.filter((e) => e.status === "draft").length ??
42
+ 0;
43
+ return (_jsxs(Link, { to: `/collections/${key}`, className: "v-card v-stat", children: [_jsx("span", { className: "v-stat__icon", children: _jsx(Icon, { name: col.icon ?? "files" }) }), _jsxs("span", { className: "v-stat__body", children: [_jsx("span", { className: "v-stat__label", children: col.label }), _jsxs("span", { className: "v-stat__meta", children: [count === undefined
44
+ ? "…"
45
+ : `${count} ${count === 1 ? "entry" : "entries"}`, drafts > 0 &&
46
+ ` · ${drafts} draft${drafts === 1 ? "" : "s"}`] })] })] }, key));
47
+ }) })] })), collections.map(([key, col], i) => {
48
+ const limit = recentCount(col);
49
+ const recent = limit
50
+ ? (queries[i].data?.entries.slice(0, limit) ?? [])
51
+ : [];
52
+ if (!recent.length)
53
+ return null;
54
+ const dateField = recentDateField(col);
55
+ return (_jsxs("section", { className: "v-section", children: [_jsxs("h2", { className: "v-section-title", children: ["Recent ", col.label.toLowerCase()] }), _jsx("div", { className: "v-card", children: _jsxs("table", { className: "v-table", children: [_jsx("tbody", { children: recent.map((e) => (_jsxs("tr", { children: [_jsxs("td", { className: "v-table__main", children: [_jsx(Link, { className: "v-table__title", to: `/collections/${key}/${e.slug}`, children: e.title }), e.excerpt && (_jsx("span", { className: "v-table__sub", children: e.excerpt }))] }), dateField && (_jsxs("td", { className: "v-muted", style: { whiteSpace: "nowrap" }, children: [_jsx(Clock, { size: 13, style: { verticalAlign: -2, marginRight: 6 } }), formatDate(e.data[dateField])] })), _jsx("td", { style: { width: 1 }, children: e.status && _jsx(Badge, { status: e.status }) })] }, e.slug))) }), _jsx("tfoot", { children: _jsx("tr", { children: _jsx("td", { colSpan: 3, className: "v-table__more", children: _jsxs(Link, { to: `/collections/${key}`, children: ["All ", col.label.toLowerCase(), " ", _jsx(ArrowRight, { size: 12, style: { verticalAlign: -1 } })] }) }) }) })] }) })] }, key));
56
+ }), client.mode === "local" && (_jsx("p", { className: "v-muted", style: { marginTop: 28, fontSize: 12.5 }, children: "Local mode: changes are written straight to your working copy. Commit them with git when you're happy." }))] })] }));
57
+ }
@@ -0,0 +1,3 @@
1
+ export declare function CollectionEntryPage(): import("react").JSX.Element;
2
+ export declare function NewEntryPage(): import("react").JSX.Element;
3
+ export declare function SingletonPage(): import("react").JSX.Element;
@@ -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;