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.
- package/LICENSE +21 -0
- package/README.md +348 -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 +403 -0
- package/dist/config/types.js +10 -0
- package/dist/core/api.d.ts +67 -0
- package/dist/core/api.js +538 -0
- package/dist/core/auth.d.ts +1561 -0
- package/dist/core/auth.js +234 -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 +378 -0
- package/dist/mdx/serialize.d.ts +9 -0
- package/dist/mdx/serialize.js +396 -0
- package/dist/mdx/types.d.ts +40 -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 +253 -0
- package/dist/ui/components/editor/extensions.d.ts +20 -0
- package/dist/ui/components/editor/extensions.js +156 -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 +2536 -0
- 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";
|
package/dist/ui/main.js
ADDED
|
@@ -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,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
|
+
}
|