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,113 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, } from "lucide-react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { cx } from "../lib/utils.js";
|
|
6
|
+
// ---------------------------------------------------------------- Button
|
|
7
|
+
export function Button({ variant = "default", size, icon, className, children, ...props }) {
|
|
8
|
+
return (_jsx("button", { type: "button", className: cx("v-btn", variant !== "default" && `v-btn--${variant}`, size && `v-btn--${size}`, icon && "v-btn--icon", className), ...props, children: children }));
|
|
9
|
+
}
|
|
10
|
+
// ---------------------------------------------------------------- Modal
|
|
11
|
+
export function Modal({ title, onClose, children, footer, wide, }) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const onKey = (e) => {
|
|
14
|
+
if (e.key === "Escape")
|
|
15
|
+
onClose();
|
|
16
|
+
};
|
|
17
|
+
window.addEventListener("keydown", onKey);
|
|
18
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
19
|
+
}, [onClose]);
|
|
20
|
+
return (_jsx("div", { className: "v-modal-backdrop", onMouseDown: (e) => {
|
|
21
|
+
if (e.target === e.currentTarget)
|
|
22
|
+
onClose();
|
|
23
|
+
}, children: _jsxs("div", { className: cx("v-modal", wide && "v-modal--wide"), role: "dialog", "aria-modal": "true", children: [_jsxs("div", { className: "v-modal__header", children: [_jsx("h2", { className: "v-modal__title", children: title }), _jsx(Button, { variant: "ghost", icon: true, onClick: onClose, "aria-label": "Close", children: _jsx(X, {}) })] }), _jsx("div", { className: "v-modal__body", children: children }), footer && _jsx("div", { className: "v-modal__footer", children: footer })] }) }));
|
|
24
|
+
}
|
|
25
|
+
export function ConfirmDialog({ title, message, confirmLabel = "Confirm", danger, onConfirm, onCancel, busy, }) {
|
|
26
|
+
return (_jsx(Modal, { title: title, onClose: onCancel, footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: onCancel, disabled: busy, children: "Cancel" }), _jsxs(Button, { variant: danger ? "danger" : "primary", onClick: onConfirm, disabled: busy, children: [busy ? _jsx(Spinner, {}) : null, confirmLabel] })] }), children: _jsx("p", { style: { margin: 0 }, children: message }) }));
|
|
27
|
+
}
|
|
28
|
+
const ToastContext = createContext(() => { });
|
|
29
|
+
export function ToastProvider({ children }) {
|
|
30
|
+
const [toasts, setToasts] = useState([]);
|
|
31
|
+
const push = useCallback((kind, message) => {
|
|
32
|
+
const id = Date.now() + Math.random();
|
|
33
|
+
setToasts((t) => [...t, { id, kind, message }]);
|
|
34
|
+
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), kind === "error" ? 8000 : 4000);
|
|
35
|
+
}, []);
|
|
36
|
+
return (_jsxs(ToastContext.Provider, { value: push, children: [children, _jsx("div", { className: "v-toasts", children: toasts.map((t) => (_jsxs("div", { className: cx("v-toast", `v-toast--${t.kind}`), children: [t.kind === "success" ? (_jsx(CheckCircle2, {})) : t.kind === "error" ? (_jsx(AlertCircle, {})) : (_jsx(Info, {})), _jsx("span", { children: t.message })] }, t.id))) })] }));
|
|
37
|
+
}
|
|
38
|
+
export function useToast() {
|
|
39
|
+
return useContext(ToastContext);
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------- Misc
|
|
42
|
+
export function Spinner() {
|
|
43
|
+
return _jsx("span", { className: "v-spinner", "aria-hidden": "true" });
|
|
44
|
+
}
|
|
45
|
+
export function Loading({ label = "Loading…" }) {
|
|
46
|
+
return (_jsxs("div", { className: "v-loading", children: [_jsx(Spinner, {}), " ", label] }));
|
|
47
|
+
}
|
|
48
|
+
export function Alert({ kind = "info", children, }) {
|
|
49
|
+
return (_jsxs("div", { className: cx("v-alert", `v-alert--${kind}`), role: kind === "error" ? "alert" : undefined, children: [kind === "error" ? (_jsx(AlertCircle, {})) : kind === "warning" ? (_jsx(AlertTriangle, {})) : (_jsx(Info, {})), _jsx("div", { children: children })] }));
|
|
50
|
+
}
|
|
51
|
+
export function Badge({ status, children, }) {
|
|
52
|
+
if (!status)
|
|
53
|
+
return _jsx("span", { className: "v-badge v-badge--plain", children: children });
|
|
54
|
+
return (_jsx("span", { className: cx("v-badge", `v-badge--${status}`), children: children ?? (status === "published" ? "Published" : "Draft") }));
|
|
55
|
+
}
|
|
56
|
+
export function EmptyState({ icon, title, children, }) {
|
|
57
|
+
return (_jsxs("div", { className: "v-empty", children: [icon, _jsx("h3", { children: title }), children] }));
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------- Menu
|
|
60
|
+
export function Menu({ trigger, children, align = "left", }) {
|
|
61
|
+
const [open, setOpen] = useState(false);
|
|
62
|
+
const ref = useRef(null);
|
|
63
|
+
const panelRef = useRef(null);
|
|
64
|
+
const [pos, setPos] = useState(null);
|
|
65
|
+
// The panel is portaled to <body> so table wrappers and scroll panes
|
|
66
|
+
// (overflow: auto/clip) can't crop it; it's positioned from the trigger.
|
|
67
|
+
const place = useCallback(() => {
|
|
68
|
+
const anchor = ref.current;
|
|
69
|
+
if (!anchor)
|
|
70
|
+
return;
|
|
71
|
+
const r = anchor.getBoundingClientRect();
|
|
72
|
+
setPos(align === "right"
|
|
73
|
+
? { top: r.bottom + 6, right: window.innerWidth - r.right }
|
|
74
|
+
: { top: r.bottom + 6, left: r.left });
|
|
75
|
+
}, [align]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open)
|
|
78
|
+
return;
|
|
79
|
+
place();
|
|
80
|
+
const onDown = (e) => {
|
|
81
|
+
const t = e.target;
|
|
82
|
+
if (ref.current?.contains(t) || panelRef.current?.contains(t))
|
|
83
|
+
return;
|
|
84
|
+
setOpen(false);
|
|
85
|
+
};
|
|
86
|
+
const onKey = (e) => {
|
|
87
|
+
if (e.key === "Escape")
|
|
88
|
+
setOpen(false);
|
|
89
|
+
};
|
|
90
|
+
// A scroll or resize while open closes the menu rather than leaving it
|
|
91
|
+
// floating away from its trigger.
|
|
92
|
+
const onMove = () => setOpen(false);
|
|
93
|
+
document.addEventListener("mousedown", onDown);
|
|
94
|
+
document.addEventListener("keydown", onKey);
|
|
95
|
+
window.addEventListener("scroll", onMove, true);
|
|
96
|
+
window.addEventListener("resize", onMove);
|
|
97
|
+
return () => {
|
|
98
|
+
document.removeEventListener("mousedown", onDown);
|
|
99
|
+
document.removeEventListener("keydown", onKey);
|
|
100
|
+
window.removeEventListener("scroll", onMove, true);
|
|
101
|
+
window.removeEventListener("resize", onMove);
|
|
102
|
+
};
|
|
103
|
+
}, [open, place]);
|
|
104
|
+
const close = useCallback(() => setOpen(false), []);
|
|
105
|
+
const toggle = useCallback(() => setOpen((o) => !o), []);
|
|
106
|
+
const triggerNode = useMemo(() => trigger({ open, toggle }), [trigger, open, toggle]);
|
|
107
|
+
return (_jsxs("div", { className: "v-menu", ref: ref, children: [triggerNode, open &&
|
|
108
|
+
pos &&
|
|
109
|
+
createPortal(_jsx("div", { ref: panelRef, className: "v-menu__panel", style: { top: pos.top, left: pos.left, right: pos.right }, children: children(close) }), document.body)] }));
|
|
110
|
+
}
|
|
111
|
+
export function Toggle({ checked, onChange, label, disabled, }) {
|
|
112
|
+
return (_jsxs("label", { className: cx("v-toggle", checked && "is-on"), children: [_jsx("input", { type: "checkbox", checked: checked, disabled: disabled, onChange: (e) => onChange(e.target.checked) }), _jsx("span", { className: "v-toggle__track" }), label && _jsx("span", { className: "v-toggle__label", children: label })] }));
|
|
113
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { VestryConfig } from "../config/types.js";
|
|
2
|
+
import type { ApiClient, ClientConfig } from "./api.js";
|
|
3
|
+
import type { VestryAuthClient } from "./auth-client.js";
|
|
4
|
+
export interface VestryContextValue {
|
|
5
|
+
basePath: string;
|
|
6
|
+
name: string;
|
|
7
|
+
api: ApiClient;
|
|
8
|
+
/** Better Auth's browser client (sign-in, sign-out). */
|
|
9
|
+
authClient: VestryAuthClient;
|
|
10
|
+
client: ClientConfig;
|
|
11
|
+
config: VestryConfig;
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare const VestryContext: import("react").Context<VestryContextValue | null>;
|
|
15
|
+
export declare function useVestry(): VestryContextValue;
|
|
16
|
+
/** Build a control-panel URL. */
|
|
17
|
+
export declare function useHref(): (path: string) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
export const VestryContext = createContext(null);
|
|
3
|
+
export function useVestry() {
|
|
4
|
+
const ctx = useContext(VestryContext);
|
|
5
|
+
if (!ctx)
|
|
6
|
+
throw new Error("useVestry must be used inside <VestryContext.Provider>");
|
|
7
|
+
return ctx;
|
|
8
|
+
}
|
|
9
|
+
/** Build a control-panel URL. */
|
|
10
|
+
export function useHref() {
|
|
11
|
+
const { basePath } = useVestry();
|
|
12
|
+
return (path) => `${basePath.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference path="../../env.d.ts" />
|
|
2
|
+
/**
|
|
3
|
+
* Formats a file with the project's Prettier (via `virtual:vestry/formatter`).
|
|
4
|
+
* Prettier is loaded lazily on first save so it stays out of the initial bundle.
|
|
5
|
+
*/
|
|
6
|
+
export async function formatSource(text, filepath) {
|
|
7
|
+
const mod = await import("virtual:vestry/formatter");
|
|
8
|
+
if (!mod.available)
|
|
9
|
+
return text;
|
|
10
|
+
return mod.formatFile(text, filepath);
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Field, Schema } from "../../config/types.js";
|
|
2
|
+
export declare function cx(...parts: (string | false | null | undefined)[]): string;
|
|
3
|
+
export declare function titleCase(key: string): string;
|
|
4
|
+
export declare function fieldLabel(key: string, field: Field): string;
|
|
5
|
+
export declare function formatBytes(n?: number): string;
|
|
6
|
+
export declare function formatDate(value: unknown): string;
|
|
7
|
+
export declare function relativeTime(iso: string): string;
|
|
8
|
+
/** Default value for a field (used when creating entries / array items). */
|
|
9
|
+
export declare function defaultValue(field: Field): unknown;
|
|
10
|
+
export declare function defaultValues(schema: Schema): Record<string, unknown>;
|
|
11
|
+
export interface ValidationError {
|
|
12
|
+
path: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function validate(schema: Schema, data: Record<string, unknown>, prefix?: string): ValidationError[];
|
|
16
|
+
/** Short human summary of an object value, for list item headers. */
|
|
17
|
+
export declare function summarize(value: unknown, field?: Field): string;
|
|
18
|
+
export declare function isImagePath(p: string): boolean;
|
|
19
|
+
export declare function basename(p: string): string;
|
|
20
|
+
export declare function slugify(input: string): string;
|
|
21
|
+
export declare function fillTemplate(template: string, values: Record<string, unknown>, slug?: boolean): string;
|
|
22
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
23
|
+
/** Up to two initials for an avatar, from a name or an email address. */
|
|
24
|
+
export declare function initials(nameOrEmail: string): string;
|
|
@@ -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
|
+
}
|