vestrycms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +383 -2
- package/dist/config/fields.d.ts +30 -0
- package/dist/config/fields.js +75 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +18 -0
- package/dist/config/types.d.ts +412 -0
- package/dist/config/types.js +10 -0
- package/dist/core/api.d.ts +67 -0
- package/dist/core/api.js +543 -0
- package/dist/core/auth.d.ts +1597 -0
- package/dist/core/auth.js +264 -0
- package/dist/core/cache.d.ts +27 -0
- package/dist/core/cache.js +52 -0
- package/dist/core/entries.d.ts +19 -0
- package/dist/core/entries.js +127 -0
- package/dist/core/index-types.d.ts +20 -0
- package/dist/core/index-types.js +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/mail.d.ts +28 -0
- package/dist/core/mail.js +62 -0
- package/dist/core/paths.d.ts +35 -0
- package/dist/core/paths.js +148 -0
- package/dist/core/roles.d.ts +24 -0
- package/dist/core/roles.js +15 -0
- package/dist/core/service.d.ts +102 -0
- package/dist/core/service.js +478 -0
- package/dist/core/source.d.ts +82 -0
- package/dist/core/source.js +75 -0
- package/dist/core/sources/github-app.d.ts +26 -0
- package/dist/core/sources/github-app.js +161 -0
- package/dist/core/sources/github.d.ts +40 -0
- package/dist/core/sources/github.js +278 -0
- package/dist/core/sources/local.d.ts +16 -0
- package/dist/core/sources/local.js +98 -0
- package/dist/core/values.d.ts +22 -0
- package/dist/core/values.js +123 -0
- package/dist/env.d.ts +19 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +167 -0
- package/dist/integration/build-index.d.ts +8 -0
- package/dist/integration/build-index.js +67 -0
- package/dist/integration/client-deps.d.ts +10 -0
- package/dist/integration/client-deps.js +43 -0
- package/dist/integration/dev-middleware.d.ts +6 -0
- package/dist/integration/dev-middleware.js +48 -0
- package/dist/integration/formatter.d.ts +7 -0
- package/dist/integration/formatter.js +56 -0
- package/dist/mdx/expressions.d.ts +14 -0
- package/dist/mdx/expressions.js +141 -0
- package/dist/mdx/index.d.ts +4 -0
- package/dist/mdx/index.js +4 -0
- package/dist/mdx/parse.d.ts +6 -0
- package/dist/mdx/parse.js +401 -0
- package/dist/mdx/serialize.d.ts +9 -0
- package/dist/mdx/serialize.js +485 -0
- package/dist/mdx/types.d.ts +42 -0
- package/dist/mdx/types.js +6 -0
- package/dist/pages/admin.astro +27 -0
- package/dist/pages/api.d.ts +3 -0
- package/dist/pages/api.js +31 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +70 -0
- package/dist/ui/api.d.ts +83 -0
- package/dist/ui/api.js +97 -0
- package/dist/ui/auth-client.d.ts +27 -0
- package/dist/ui/auth-client.js +29 -0
- package/dist/ui/components/AssetPicker.d.ts +27 -0
- package/dist/ui/components/AssetPicker.js +97 -0
- package/dist/ui/components/Icon.d.ts +11 -0
- package/dist/ui/components/Icon.js +46 -0
- package/dist/ui/components/Layout.d.ts +15 -0
- package/dist/ui/components/Layout.js +87 -0
- package/dist/ui/components/editor/EditorContext.d.ts +15 -0
- package/dist/ui/components/editor/EditorContext.js +9 -0
- package/dist/ui/components/editor/NodeViews.d.ts +4 -0
- package/dist/ui/components/editor/NodeViews.js +127 -0
- package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
- package/dist/ui/components/editor/RichTextEditor.js +230 -0
- package/dist/ui/components/editor/extensions.d.ts +30 -0
- package/dist/ui/components/editor/extensions.js +248 -0
- package/dist/ui/components/editor/schema.d.ts +12 -0
- package/dist/ui/components/editor/schema.js +37 -0
- package/dist/ui/components/fields/CodeInput.d.ts +10 -0
- package/dist/ui/components/fields/CodeInput.js +8 -0
- package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
- package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
- package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
- package/dist/ui/components/fields/FieldRenderer.js +299 -0
- package/dist/ui/components/fields/ImageTools.d.ts +31 -0
- package/dist/ui/components/fields/ImageTools.js +296 -0
- package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
- package/dist/ui/components/fields/MarkdownField.js +16 -0
- package/dist/ui/components/fields/TreeInput.d.ts +13 -0
- package/dist/ui/components/fields/TreeInput.js +221 -0
- package/dist/ui/components/ui.d.ts +63 -0
- package/dist/ui/components/ui.js +113 -0
- package/dist/ui/context.d.ts +17 -0
- package/dist/ui/context.js +13 -0
- package/dist/ui/lib/format.d.ts +5 -0
- package/dist/ui/lib/format.js +11 -0
- package/dist/ui/lib/utils.d.ts +24 -0
- package/dist/ui/lib/utils.js +255 -0
- package/dist/ui/main.d.ts +1 -0
- package/dist/ui/main.js +11 -0
- package/dist/ui/pages/Assets.d.ts +1 -0
- package/dist/ui/pages/Assets.js +69 -0
- package/dist/ui/pages/CollectionList.d.ts +6 -0
- package/dist/ui/pages/CollectionList.js +115 -0
- package/dist/ui/pages/Dashboard.d.ts +1 -0
- package/dist/ui/pages/Dashboard.js +57 -0
- package/dist/ui/pages/EntryEditor.d.ts +3 -0
- package/dist/ui/pages/EntryEditor.js +313 -0
- package/dist/ui/pages/Login.d.ts +6 -0
- package/dist/ui/pages/Login.js +88 -0
- package/dist/ui/pages/Users.d.ts +1 -0
- package/dist/ui/pages/Users.js +102 -0
- package/dist/ui/styles.css +2554 -0
- package/package.json +123 -3
|
@@ -0,0 +1,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,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,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;
|