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,255 @@
1
+ export function cx(...parts) {
2
+ return parts.filter(Boolean).join(" ");
3
+ }
4
+ export function titleCase(key) {
5
+ return key
6
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
7
+ .replace(/[_-]+/g, " ")
8
+ .replace(/^\w/, (c) => c.toUpperCase());
9
+ }
10
+ export function fieldLabel(key, field) {
11
+ return field.label ?? titleCase(key);
12
+ }
13
+ export function formatBytes(n) {
14
+ if (n === undefined)
15
+ return "";
16
+ if (n < 1024)
17
+ return `${n} B`;
18
+ if (n < 1024 * 1024)
19
+ return `${(n / 1024).toFixed(0)} KB`;
20
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
21
+ }
22
+ export function formatDate(value) {
23
+ if (value === null || value === undefined || value === "")
24
+ return "";
25
+ const d = value instanceof Date ? value : new Date(String(value));
26
+ if (Number.isNaN(d.getTime()))
27
+ return String(value);
28
+ const s = String(value);
29
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s);
30
+ if (dateOnly) {
31
+ const [y, m, day] = s.split("-").map(Number);
32
+ return new Date(y, m - 1, day).toLocaleDateString(undefined, {
33
+ year: "numeric",
34
+ month: "short",
35
+ day: "numeric",
36
+ });
37
+ }
38
+ return d.toLocaleString(undefined, {
39
+ year: "numeric",
40
+ month: "short",
41
+ day: "numeric",
42
+ hour: "numeric",
43
+ minute: "2-digit",
44
+ });
45
+ }
46
+ export function relativeTime(iso) {
47
+ const diff = Date.now() - new Date(iso).getTime();
48
+ const m = Math.round(diff / 60000);
49
+ if (m < 1)
50
+ return "just now";
51
+ if (m < 60)
52
+ return `${m}m ago`;
53
+ const h = Math.round(m / 60);
54
+ if (h < 24)
55
+ return `${h}h ago`;
56
+ const d = Math.round(h / 24);
57
+ return `${d}d ago`;
58
+ }
59
+ /** Default value for a field (used when creating entries / array items). */
60
+ export function defaultValue(field) {
61
+ switch (field.type) {
62
+ case "text":
63
+ case "textarea":
64
+ case "slug":
65
+ case "color":
66
+ case "image":
67
+ case "file":
68
+ case "reference":
69
+ case "markdown":
70
+ case "code":
71
+ return field.default ?? "";
72
+ case "number":
73
+ return field.default ?? null;
74
+ case "boolean":
75
+ return field.default ?? false;
76
+ case "date":
77
+ return field.default === "now"
78
+ ? new Date().toISOString().slice(0, 10)
79
+ : (field.default ?? "");
80
+ case "datetime":
81
+ if (field.default === "now") {
82
+ return field.storage === "local"
83
+ ? new Date().toISOString().slice(0, 16)
84
+ : new Date().toISOString();
85
+ }
86
+ return field.default ?? "";
87
+ case "select":
88
+ return (field.default ??
89
+ (field.clearable ? "" : (field.options[0]?.value ?? "")));
90
+ case "multiselect":
91
+ return field.default ?? [];
92
+ case "object":
93
+ return defaultValues(field.fields);
94
+ case "array":
95
+ return [];
96
+ case "json":
97
+ return field.default ?? null;
98
+ case "crop":
99
+ case "focalPoint":
100
+ return undefined;
101
+ case "tree":
102
+ return [];
103
+ }
104
+ }
105
+ export function defaultValues(schema) {
106
+ const out = {};
107
+ for (const [key, field] of Object.entries(schema)) {
108
+ const v = defaultValue(field);
109
+ if (v !== "" && v !== null && !(Array.isArray(v) && v.length === 0))
110
+ out[key] = v;
111
+ else if (field.type === "boolean")
112
+ out[key] = v;
113
+ }
114
+ return out;
115
+ }
116
+ export function validate(schema, data, prefix = "") {
117
+ const errors = [];
118
+ for (const [key, field] of Object.entries(schema)) {
119
+ const path = prefix ? `${prefix}.${key}` : key;
120
+ const value = data?.[key];
121
+ const label = fieldLabel(key, field);
122
+ const empty = value === undefined ||
123
+ value === null ||
124
+ value === "" ||
125
+ (Array.isArray(value) && value.length === 0);
126
+ if (field.required && empty) {
127
+ errors.push({ path, message: `${label} is required` });
128
+ continue;
129
+ }
130
+ if (empty)
131
+ continue;
132
+ if (field.type === "text" && field.pattern && typeof value === "string") {
133
+ if (!new RegExp(field.pattern).test(value))
134
+ errors.push({
135
+ path,
136
+ message: `${label} is not in the expected format`,
137
+ });
138
+ }
139
+ if ((field.type === "text" || field.type === "textarea") &&
140
+ field.maxLength &&
141
+ typeof value === "string" &&
142
+ value.length > field.maxLength) {
143
+ errors.push({
144
+ path,
145
+ message: `${label} must be at most ${field.maxLength} characters`,
146
+ });
147
+ }
148
+ if (field.type === "number" && typeof value === "number") {
149
+ if (field.min !== undefined && value < field.min)
150
+ errors.push({
151
+ path,
152
+ message: `${label} must be at least ${field.min}`,
153
+ });
154
+ if (field.max !== undefined && value > field.max)
155
+ errors.push({ path, message: `${label} must be at most ${field.max}` });
156
+ }
157
+ if (field.type === "object" && value && typeof value === "object") {
158
+ errors.push(...validate(field.fields, value, path));
159
+ }
160
+ if (field.type === "tree" && Array.isArray(value)) {
161
+ const key = field.childrenKey ?? "children";
162
+ const walk = (nodes, prefix) => nodes.forEach((n, i) => {
163
+ if (!n || typeof n !== "object")
164
+ return;
165
+ errors.push(...validate(field.fields, n, `${prefix}.${i}`));
166
+ const kids = n[key];
167
+ if (Array.isArray(kids))
168
+ walk(kids, `${prefix}.${i}.${key}`);
169
+ });
170
+ walk(value, path);
171
+ }
172
+ if (field.type === "array" && Array.isArray(value)) {
173
+ if (field.min !== undefined && value.length < field.min)
174
+ errors.push({
175
+ path,
176
+ message: `${label} needs at least ${field.min} items`,
177
+ });
178
+ if (field.max !== undefined && value.length > field.max)
179
+ errors.push({
180
+ path,
181
+ message: `${label} allows at most ${field.max} items`,
182
+ });
183
+ if (field.of.type === "object") {
184
+ value.forEach((item, i) => errors.push(...validate(field.of.fields, item, `${path}.${i}`)));
185
+ }
186
+ }
187
+ }
188
+ return errors;
189
+ }
190
+ /** Short human summary of an object value, for list item headers. */
191
+ export function summarize(value, field) {
192
+ if (value === null || value === undefined)
193
+ return "";
194
+ if (typeof value === "string")
195
+ return value;
196
+ if (typeof value === "number" || typeof value === "boolean")
197
+ return String(value);
198
+ if (Array.isArray(value))
199
+ return `${value.length} item${value.length === 1 ? "" : "s"}`;
200
+ if (typeof value === "object") {
201
+ const obj = value;
202
+ if (field?.type === "array" &&
203
+ field.itemLabel &&
204
+ obj[field.itemLabel] !== undefined)
205
+ return String(obj[field.itemLabel]);
206
+ for (const k of ["title", "label", "name", "heading", "id"]) {
207
+ if (typeof obj[k] === "string" && obj[k])
208
+ return obj[k];
209
+ }
210
+ const first = Object.values(obj).find((v) => typeof v === "string" && v);
211
+ return first ? String(first) : "";
212
+ }
213
+ return "";
214
+ }
215
+ export function isImagePath(p) {
216
+ return /\.(jpe?g|png|gif|webp|avif|svg)$/i.test(p);
217
+ }
218
+ export function basename(p) {
219
+ return p.slice(p.lastIndexOf("/") + 1);
220
+ }
221
+ export function slugify(input) {
222
+ return input
223
+ .normalize("NFKD")
224
+ .replace(/[̀-ͯ]/g, "")
225
+ .toLowerCase()
226
+ .replace(/['’"]/g, "")
227
+ .replace(/[^a-z0-9]+/g, "-")
228
+ .replace(/^-+|-+$/g, "");
229
+ }
230
+ export function fillTemplate(template, values, slug = false) {
231
+ return template.replace(/\{([\w.]+)\}/g, (_, key) => {
232
+ const raw = key
233
+ .split(".")
234
+ .reduce((acc, k) => acc && typeof acc === "object"
235
+ ? acc[k]
236
+ : undefined, values);
237
+ if (raw === undefined || raw === null)
238
+ return "";
239
+ const s = String(raw);
240
+ return slug ? slugify(s) : s;
241
+ });
242
+ }
243
+ export function deepEqual(a, b) {
244
+ return JSON.stringify(a) === JSON.stringify(b);
245
+ }
246
+ /** Up to two initials for an avatar, from a name or an email address. */
247
+ export function initials(nameOrEmail) {
248
+ const parts = nameOrEmail
249
+ .replace(/@.*$/, "")
250
+ .split(/[\s._-]+/)
251
+ .filter(Boolean);
252
+ const first = parts[0]?.[0] ?? "?";
253
+ const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
254
+ return `${first}${last ?? ""}`.toUpperCase();
255
+ }
@@ -0,0 +1 @@
1
+ import "./styles.css";
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createRoot } from "react-dom/client";
3
+ import { StrictMode } from "react";
4
+ import { App } from "./App.js";
5
+ import "./styles.css";
6
+ const root = document.getElementById("vestry-root");
7
+ if (root) {
8
+ const basePath = root.dataset.base ?? "/admin";
9
+ const name = root.dataset.name ?? "Vestry";
10
+ createRoot(root).render(_jsx(StrictMode, { children: _jsx(App, { basePath: basePath, name: name }) }));
11
+ }
@@ -0,0 +1 @@
1
+ export declare function AssetsPage(): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { ChevronRight, Copy, File as FileIcon, Folder, Trash2, Upload, } from "lucide-react";
4
+ import { useRef, useState } from "react";
5
+ import { useParams, useSearchParams } from "react-router";
6
+ import { fileToBase64 } from "../api.js";
7
+ import { Icon } from "../components/Icon.js";
8
+ import { TopBar } from "../components/Layout.js";
9
+ import { Alert, Button, ConfirmDialog, EmptyState, Loading, Modal, Spinner, useToast, } from "../components/ui.js";
10
+ import { useVestry } from "../context.js";
11
+ import { cx, formatBytes } from "../lib/utils.js";
12
+ export function AssetsPage() {
13
+ const { container: key = "" } = useParams();
14
+ const { config, api } = useVestry();
15
+ const container = config.assets?.[key];
16
+ const [params, setParams] = useSearchParams();
17
+ const dir = params.get("dir") ?? "";
18
+ const toast = useToast();
19
+ const qc = useQueryClient();
20
+ const [selected, setSelected] = useState(null);
21
+ const [confirmDelete, setConfirmDelete] = useState(null);
22
+ const [over, setOver] = useState(false);
23
+ const fileInput = useRef(null);
24
+ const q = useQuery({
25
+ queryKey: ["assets", key, dir],
26
+ queryFn: () => api.listAssets(key, dir),
27
+ enabled: Boolean(container),
28
+ });
29
+ const upload = useMutation({
30
+ mutationFn: async (files) => {
31
+ const payload = await Promise.all(files.map(async (f) => ({
32
+ name: f.name,
33
+ base64: await fileToBase64(f),
34
+ })));
35
+ return api.uploadAssets(key, dir, payload);
36
+ },
37
+ onSuccess: (res) => {
38
+ toast("success", `Uploaded ${res.paths.length} file${res.paths.length === 1 ? "" : "s"}`);
39
+ qc.invalidateQueries({ queryKey: ["assets", key] });
40
+ },
41
+ onError: (e) => toast("error", e.message),
42
+ });
43
+ const remove = useMutation({
44
+ mutationFn: (path) => api.deleteAsset(key, path),
45
+ onSuccess: () => {
46
+ toast("success", "File deleted");
47
+ setConfirmDelete(null);
48
+ setSelected(null);
49
+ qc.invalidateQueries({ queryKey: ["assets", key] });
50
+ },
51
+ onError: (e) => toast("error", e.message),
52
+ });
53
+ if (!container)
54
+ return _jsxs(Alert, { kind: "error", children: ["Unknown asset container \u201C", key, "\u201D."] });
55
+ const crumbs = dir ? dir.split("/") : [];
56
+ const selectedItem = q.data?.items.find((i) => i.path === selected);
57
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { title: container.label, actions: _jsxs(_Fragment, { children: [_jsx("input", { ref: fileInput, type: "file", hidden: true, multiple: true, accept: container.accept?.map((a) => `.${a}`).join(","), onChange: (e) => e.target.files && upload.mutate(Array.from(e.target.files)) }), _jsxs(Button, { variant: "primary", onClick: () => fileInput.current?.click(), disabled: upload.isPending, children: [upload.isPending ? _jsx(Spinner, {}) : _jsx(Upload, {}), " Upload"] })] }) }), _jsxs("div", { className: "v-page", children: [_jsx("div", { className: "v-toolbar", children: _jsxs("div", { className: "v-breadcrumbs", children: [_jsx("button", { type: "button", onClick: () => setParams({}), children: container.directory }), crumbs.map((c, i) => (_jsxs("span", { className: "v-row", style: { gap: 6 }, children: [_jsx(ChevronRight, { size: 14 }), _jsx("button", { type: "button", onClick: () => setParams({ dir: crumbs.slice(0, i + 1).join("/") }), children: c })] }, i)))] }) }), _jsxs("div", { className: cx("v-dropzone", over && "is-over"), onDragOver: (e) => {
58
+ e.preventDefault();
59
+ setOver(true);
60
+ }, onDragLeave: () => setOver(false), onDrop: (e) => {
61
+ e.preventDefault();
62
+ setOver(false);
63
+ if (e.dataTransfer.files.length)
64
+ upload.mutate(Array.from(e.dataTransfer.files));
65
+ }, children: ["Drop files here to upload to", " ", _jsx("span", { className: "v-mono", children: [container.directory, dir].filter(Boolean).join("/") })] }), q.isLoading && _jsx(Loading, {}), q.error && _jsx(Alert, { kind: "error", children: q.error.message }), q.data && (_jsxs(_Fragment, { children: [q.data.folders.length > 0 && (_jsx("div", { className: "v-asset-grid", style: { marginBottom: 14 }, children: q.data.folders.map((f) => (_jsxs("button", { type: "button", className: "v-folder", onClick: () => setParams({ dir: [dir, f].filter(Boolean).join("/") }), children: [_jsx(Folder, {}), " ", f] }, f))) })), q.data.items.length === 0 && q.data.folders.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Icon, { name: container.icon ?? "image" }), title: "No files here yet", children: _jsx("p", { children: "Upload files with the button above or drop them onto this page." }) })) : (_jsx("div", { className: "v-asset-grid", children: q.data.items.map((item) => (_jsxs("button", { type: "button", className: cx("v-asset", selected === item.path && "is-selected"), onClick: () => setSelected(item.path), children: [_jsx("div", { className: "v-asset__preview", children: item.kind === "image" ? (_jsx("img", { src: api.fileUrl(item.path, item.sha), alt: "", loading: "lazy" })) : (_jsx(FileIcon, {})) }), _jsx("div", { className: "v-asset__name", title: item.name, children: item.name }), _jsx("div", { className: "v-asset__meta", children: formatBytes(item.size) })] }, item.path))) }))] }))] }), selectedItem && (_jsxs(Modal, { title: selectedItem.name, onClose: () => setSelected(null), footer: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "danger", onClick: () => setConfirmDelete(selectedItem.path), children: [_jsx(Trash2, {}), " Delete"] }), _jsxs(Button, { onClick: () => {
66
+ navigator.clipboard.writeText(selectedItem.path);
67
+ toast("success", "Path copied");
68
+ }, children: [_jsx(Copy, {}), " Copy path"] }), _jsx(Button, { variant: "primary", onClick: () => setSelected(null), children: "Done" })] }), children: [selectedItem.kind === "image" && (_jsx("div", { style: { textAlign: "center", marginBottom: 14 }, children: _jsx("img", { src: api.fileUrl(selectedItem.path, selectedItem.sha), alt: "", style: { maxWidth: "100%", maxHeight: 360, borderRadius: 6 } }) })), _jsxs("dl", { className: "v-meta", children: [_jsx("dt", { children: "Path" }), _jsx("dd", { className: "v-mono", children: selectedItem.path }), _jsx("dt", { children: "Size" }), _jsx("dd", { children: formatBytes(selectedItem.size) }), container.directory.startsWith("public/") && (_jsxs(_Fragment, { children: [_jsx("dt", { children: "URL" }), _jsxs("dd", { className: "v-mono", children: ["/", selectedItem.path.slice("public/".length)] })] }))] })] })), confirmDelete && (_jsx(ConfirmDialog, { title: "Delete file?", message: _jsxs(_Fragment, { children: [_jsx("span", { className: "v-mono", children: confirmDelete }), " will be removed from the repository. Pages that reference it will break."] }), confirmLabel: "Delete", danger: true, busy: remove.isPending, onCancel: () => setConfirmDelete(null), onConfirm: () => remove.mutate(confirmDelete) }))] }));
69
+ }
@@ -0,0 +1,6 @@
1
+ import type { Field } from "../../config/types.js";
2
+ export declare function CollectionListPage(): import("react").JSX.Element;
3
+ export declare function CellValue({ field, value }: {
4
+ field?: Field;
5
+ value: unknown;
6
+ }): import("react").JSX.Element;
@@ -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;