smoodly 0.0.1
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 +211 -0
- package/dist/admin/auth.d.ts +10 -0
- package/dist/admin/auth.js +28 -0
- package/dist/admin/client-ops.d.ts +2 -0
- package/dist/admin/client-ops.js +18 -0
- package/dist/admin/editor/EditorView.d.ts +7 -0
- package/dist/admin/editor/EditorView.js +391 -0
- package/dist/admin/editor/autosave.d.ts +31 -0
- package/dist/admin/editor/autosave.js +108 -0
- package/dist/admin/editor/protocol.d.ts +37 -0
- package/dist/admin/editor/protocol.js +1 -0
- package/dist/admin/forms/FieldWidget.d.ts +14 -0
- package/dist/admin/forms/FieldWidget.js +54 -0
- package/dist/admin/forms/tabs.d.ts +11 -0
- package/dist/admin/forms/tabs.js +20 -0
- package/dist/admin/index.d.ts +10 -0
- package/dist/admin/index.js +8 -0
- package/dist/admin/next/bridge.d.ts +1 -0
- package/dist/admin/next/bridge.js +66 -0
- package/dist/admin/next/create-admin.d.ts +18 -0
- package/dist/admin/next/create-admin.js +14 -0
- package/dist/admin/next/index.d.ts +6 -0
- package/dist/admin/next/index.js +3 -0
- package/dist/admin/next/op-handler.d.ts +11 -0
- package/dist/admin/next/op-handler.js +23 -0
- package/dist/admin/ops-impl.d.ts +20 -0
- package/dist/admin/ops-impl.js +189 -0
- package/dist/admin/ops.d.ts +52 -0
- package/dist/admin/ops.js +1 -0
- package/dist/admin/page-path.d.ts +1 -0
- package/dist/admin/page-path.js +6 -0
- package/dist/admin/richtext.d.ts +14 -0
- package/dist/admin/richtext.js +17 -0
- package/dist/admin/serialize.d.ts +40 -0
- package/dist/admin/serialize.js +49 -0
- package/dist/admin/shell/AdminApp.d.ts +9 -0
- package/dist/admin/shell/AdminApp.js +122 -0
- package/dist/admin/shell/EntryForm.d.ts +8 -0
- package/dist/admin/shell/EntryForm.js +96 -0
- package/dist/admin/shell/ListView.d.ts +15 -0
- package/dist/admin/shell/ListView.js +14 -0
- package/dist/admin/shell/LoginView.d.ts +5 -0
- package/dist/admin/shell/LoginView.js +47 -0
- package/dist/admin/shell/pages-list.d.ts +22 -0
- package/dist/admin/shell/pages-list.js +31 -0
- package/dist/admin/shell/session.d.ts +19 -0
- package/dist/admin/shell/session.js +37 -0
- package/dist/admin/supabase-auth.d.ts +7 -0
- package/dist/admin/supabase-auth.js +23 -0
- package/dist/admin/tree-ops.d.ts +19 -0
- package/dist/admin/tree-ops.js +112 -0
- package/dist/admin/validate.d.ts +10 -0
- package/dist/admin/validate.js +19 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +19 -0
- package/dist/collections.d.ts +39 -0
- package/dist/collections.js +22 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +54 -0
- package/dist/entry-store.d.ts +74 -0
- package/dist/entry-store.js +132 -0
- package/dist/env.d.ts +16 -0
- package/dist/env.js +22 -0
- package/dist/fields.d.ts +67 -0
- package/dist/fields.js +41 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +29 -0
- package/dist/next/index.d.ts +3 -0
- package/dist/next/index.js +2 -0
- package/dist/next/meta.d.ts +10 -0
- package/dist/next/meta.js +37 -0
- package/dist/next/page-renderer.d.ts +36 -0
- package/dist/next/page-renderer.js +69 -0
- package/dist/page.d.ts +22 -0
- package/dist/page.js +35 -0
- package/dist/pairing.d.ts +33 -0
- package/dist/pairing.js +26 -0
- package/dist/ref-index.d.ts +19 -0
- package/dist/ref-index.js +0 -0
- package/dist/refs.d.ts +20 -0
- package/dist/refs.js +49 -0
- package/dist/render.d.ts +48 -0
- package/dist/render.js +41 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +128 -0
- package/dist/revalidate.d.ts +8 -0
- package/dist/revalidate.js +34 -0
- package/dist/schema.d.ts +57 -0
- package/dist/schema.js +40 -0
- package/dist/section-wrapper.d.ts +11 -0
- package/dist/section-wrapper.js +19 -0
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +172 -0
- package/dist/sql.d.ts +3 -0
- package/dist/sql.js +162 -0
- package/dist/store.d.ts +73 -0
- package/dist/store.js +145 -0
- package/dist/supabase-entry-store.d.ts +26 -0
- package/dist/supabase-entry-store.js +191 -0
- package/dist/supabase-store.d.ts +26 -0
- package/dist/supabase-store.js +180 -0
- package/dist/zones.d.ts +31 -0
- package/dist/zones.js +23 -0
- package/package.json +50 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { plainToRichtext, richtextToPlain } from "../richtext.js";
|
|
4
|
+
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
5
|
+
const inputStyle = {
|
|
6
|
+
width: "100%", boxSizing: "border-box", border: "1px solid #d6d3e0", borderRadius: 8,
|
|
7
|
+
padding: "9px 10px", fontFamily: "inherit", fontSize: 13.5, background: "#fff", color: "#17141f",
|
|
8
|
+
};
|
|
9
|
+
export function FieldWidget({ descriptor: d, value, onChange, error, refOptions = [], fieldKey, }) {
|
|
10
|
+
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
11
|
+
let control;
|
|
12
|
+
switch (d.type) {
|
|
13
|
+
case "text":
|
|
14
|
+
case "link":
|
|
15
|
+
case "slug":
|
|
16
|
+
case "date":
|
|
17
|
+
control = _jsx("input", { style: inputStyle, value: String(value ?? ""), onChange: (e) => onChange(e.target.value) });
|
|
18
|
+
break;
|
|
19
|
+
case "number":
|
|
20
|
+
control = (_jsx("input", { style: inputStyle, type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
|
|
21
|
+
break;
|
|
22
|
+
case "select": {
|
|
23
|
+
const options = Array.isArray(d.options) ? d.options : [];
|
|
24
|
+
control = (_jsxs("select", { style: inputStyle, value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o, children: o }, o))] }));
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "richtext":
|
|
28
|
+
control = (_jsx("textarea", { style: { ...inputStyle, resize: "vertical" }, rows: 4, value: richtextToPlain(value), onChange: (e) => onChange(plainToRichtext(e.target.value)) }));
|
|
29
|
+
break;
|
|
30
|
+
case "image":
|
|
31
|
+
case "video":
|
|
32
|
+
case "file": {
|
|
33
|
+
const url = value?.url ?? "";
|
|
34
|
+
control = (_jsx("input", { style: inputStyle, placeholder: "https://\u2026", value: url, onChange: (e) => onChange(e.target.value ? { url: e.target.value } : undefined) }));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "ref":
|
|
38
|
+
control = (_jsxs("select", { style: inputStyle, value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
|
|
39
|
+
break;
|
|
40
|
+
case "refList": {
|
|
41
|
+
const ids = Array.isArray(value) ? value : [];
|
|
42
|
+
const byId = new Map(refOptions.map((o) => [o.id, o]));
|
|
43
|
+
control = (_jsxs("div", { children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, marginBottom: 6 }, children: [_jsx("span", { style: { ...inputStyle, flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx("button", { type: "button", onClick: () => onChange(ids.filter((_, j) => j !== i)), style: { border: 0, background: "#efecf6", borderRadius: 8, cursor: "pointer", padding: "0 10px" }, children: "\u2715" })] }, `${id}-${i}`))), _jsxs("select", { style: inputStyle, value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
control = _jsx("textarea", { style: inputStyle, disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
|
|
48
|
+
}
|
|
49
|
+
// A <label> activates its first labelable descendant, so it may only wrap a
|
|
50
|
+
// single control — around refList's rows it would turn every click into a
|
|
51
|
+
// press of the first row's remove button.
|
|
52
|
+
const Wrapper = d.type === "refList" ? "div" : "label";
|
|
53
|
+
return (_jsxs(Wrapper, { style: { display: "block", marginBottom: 14 }, children: [_jsx("span", { style: { display: "block", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", opacity: 0.55, marginBottom: 6 }, children: label(fieldKey) }), control, error && _jsx("span", { style: { display: "block", color: "#b91c1c", fontSize: 12, marginTop: 4 }, children: error })] }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminField, AdminSection } from "../serialize.ts";
|
|
2
|
+
export type TabName = "Content" | "Styles" | "Media" | "Meta";
|
|
3
|
+
export type TabEntry = {
|
|
4
|
+
key: string;
|
|
5
|
+
ns: "fields" | "styles" | "meta";
|
|
6
|
+
descriptor: AdminField;
|
|
7
|
+
};
|
|
8
|
+
export declare function tabsFor(section: AdminSection): {
|
|
9
|
+
tab: TabName;
|
|
10
|
+
entries: TabEntry[];
|
|
11
|
+
}[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const MEDIA_TYPES = new Set(["image", "video", "file"]);
|
|
2
|
+
const TAB_BY_NS = {
|
|
3
|
+
fields: "Content", styles: "Styles", meta: "Meta",
|
|
4
|
+
};
|
|
5
|
+
const TAB_BY_OVERRIDE = {
|
|
6
|
+
content: "Content", styles: "Styles", media: "Media", meta: "Meta",
|
|
7
|
+
};
|
|
8
|
+
export function tabsFor(section) {
|
|
9
|
+
const buckets = { Content: [], Styles: [], Media: [], Meta: [] };
|
|
10
|
+
for (const ns of ["fields", "styles", "meta"]) {
|
|
11
|
+
for (const [key, descriptor] of Object.entries(section[ns] ?? {})) {
|
|
12
|
+
const override = typeof descriptor.tab === "string" ? TAB_BY_OVERRIDE[descriptor.tab] : undefined;
|
|
13
|
+
const tab = override ?? (MEDIA_TYPES.has(descriptor.type) ? "Media" : TAB_BY_NS[ns]);
|
|
14
|
+
buckets[tab].push({ key, ns, descriptor });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return ["Content", "Styles", "Media", "Meta"]
|
|
18
|
+
.filter((tab) => buckets[tab].length > 0)
|
|
19
|
+
.map((tab) => ({ tab, entries: buckets[tab] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError } from "./ops.ts";
|
|
2
|
+
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
|
+
export { makeClientOps } from "./client-ops.ts";
|
|
4
|
+
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
5
|
+
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
6
|
+
export { serializeAdminConfig } from "./serialize.ts";
|
|
7
|
+
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminField } from "./serialize.ts";
|
|
8
|
+
export * from "./tree-ops.ts";
|
|
9
|
+
export { validateFields, type FieldError } from "./validate.ts";
|
|
10
|
+
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
|
+
export { makeClientOps } from "./client-ops.js";
|
|
3
|
+
export { gateAdminOp } from "./auth.js";
|
|
4
|
+
export { supabaseAdminAuth } from "./supabase-auth.js";
|
|
5
|
+
export { serializeAdminConfig } from "./serialize.js";
|
|
6
|
+
export * from "./tree-ops.js";
|
|
7
|
+
export { validateFields } from "./validate.js";
|
|
8
|
+
export { plainToRichtext, richtextToPlain } from "./richtext.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function EditorBridge(): null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// Runs INSIDE the draft-route iframe (glue adds it only when draft mode +
|
|
3
|
+
// the editor param are on). Reports geometry, forwards select/hover,
|
|
4
|
+
// executes refresh/scroll — the admin overlay draws all chrome. Rects are
|
|
5
|
+
// in document coordinates: the iframe is sized to the full document height
|
|
6
|
+
// by the editor, so client rects + scroll offsets map 1:1 onto the overlay.
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
import { useRouter } from "next/navigation";
|
|
9
|
+
export function EditorBridge() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const post = (m) => window.parent.postMessage(m, "*");
|
|
13
|
+
const report = () => {
|
|
14
|
+
// Array.from, not spread: the package's lib set is ES2020 + DOM
|
|
15
|
+
// (no DOM.Iterable), so a NodeList isn't statically iterable here.
|
|
16
|
+
const nodes = Array.from(document.querySelectorAll("[data-smoodly-node]")).map((el) => {
|
|
17
|
+
const r = el.getBoundingClientRect();
|
|
18
|
+
return { id: el.dataset.smoodlyNode, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
|
|
19
|
+
});
|
|
20
|
+
const emptyZones = Array.from(document.querySelectorAll("[data-smoodly-zone]")).map((el) => {
|
|
21
|
+
const r = el.getBoundingClientRect();
|
|
22
|
+
return { zone: el.dataset.smoodlyZone, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
|
|
23
|
+
});
|
|
24
|
+
post({ source: "smoodly", kind: "rects", nodes, emptyZones, docH: document.documentElement.scrollHeight });
|
|
25
|
+
};
|
|
26
|
+
const raf = () => requestAnimationFrame(report);
|
|
27
|
+
const ro = new ResizeObserver(raf);
|
|
28
|
+
ro.observe(document.documentElement);
|
|
29
|
+
const mo = new MutationObserver(raf);
|
|
30
|
+
mo.observe(document.body, { subtree: true, childList: true, attributes: true });
|
|
31
|
+
const nodeIdAt = (target) => target?.closest?.("[data-smoodly-node]")?.dataset.smoodlyNode ?? null;
|
|
32
|
+
const onClick = (e) => {
|
|
33
|
+
// The canvas must never navigate away from under the editor.
|
|
34
|
+
if (e.target?.closest?.("a"))
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
post({ source: "smoodly", kind: "select", id: nodeIdAt(e.target) });
|
|
37
|
+
};
|
|
38
|
+
const onMove = (e) => post({ source: "smoodly", kind: "hover", id: nodeIdAt(e.target) });
|
|
39
|
+
const onMessage = (e) => {
|
|
40
|
+
const m = e.data;
|
|
41
|
+
if (m?.source !== "smoodly")
|
|
42
|
+
return;
|
|
43
|
+
if (m.kind === "refresh")
|
|
44
|
+
router.refresh();
|
|
45
|
+
if (m.kind === "scroll") {
|
|
46
|
+
document
|
|
47
|
+
.querySelector(`[data-smoodly-node="${CSS.escape(m.id)}"]`)
|
|
48
|
+
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener("click", onClick, true);
|
|
52
|
+
document.addEventListener("mousemove", onMove, true);
|
|
53
|
+
window.addEventListener("message", onMessage);
|
|
54
|
+
report();
|
|
55
|
+
const late = setTimeout(report, 500); // fonts/images settle
|
|
56
|
+
return () => {
|
|
57
|
+
ro.disconnect();
|
|
58
|
+
mo.disconnect();
|
|
59
|
+
clearTimeout(late);
|
|
60
|
+
document.removeEventListener("click", onClick, true);
|
|
61
|
+
document.removeEventListener("mousemove", onMove, true);
|
|
62
|
+
window.removeEventListener("message", onMessage);
|
|
63
|
+
};
|
|
64
|
+
}, [router]);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { ResolvedConfig } from "../../config.ts";
|
|
3
|
+
import type { AdminOpAction } from "../ops.ts";
|
|
4
|
+
import type { AuthConfig } from "../shell/session.ts";
|
|
5
|
+
export declare function createSmoodlyAdmin(input: {
|
|
6
|
+
config: ResolvedConfig;
|
|
7
|
+
op: AdminOpAction;
|
|
8
|
+
auth: () => AuthConfig;
|
|
9
|
+
}): {
|
|
10
|
+
AdminPage: ({ params }: {
|
|
11
|
+
params: Promise<{
|
|
12
|
+
segments?: string[];
|
|
13
|
+
}>;
|
|
14
|
+
}) => Promise<ReactElement>;
|
|
15
|
+
AdminLayout: ({ children }: {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}) => ReactElement;
|
|
18
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { serializeAdminConfig } from "../serialize.js";
|
|
3
|
+
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
|
+
export function createSmoodlyAdmin(input) {
|
|
5
|
+
const registry = serializeAdminConfig(input.config);
|
|
6
|
+
async function AdminPage({ params }) {
|
|
7
|
+
const { segments = [] } = await params;
|
|
8
|
+
return _jsx(AdminApp, { registry: registry, segments: segments, op: input.op, auth: input.auth() });
|
|
9
|
+
}
|
|
10
|
+
function AdminLayout({ children }) {
|
|
11
|
+
return (_jsx("div", { style: { fontFamily: "system-ui, sans-serif", minHeight: "100vh", background: "#f4f3f8", color: "#17141f" }, children: children }));
|
|
12
|
+
}
|
|
13
|
+
return { AdminPage, AdminLayout };
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createAdminOpHandler } from "./op-handler.ts";
|
|
2
|
+
export { createSmoodlyAdmin } from "./create-admin.tsx";
|
|
3
|
+
export type { AdminOpCall, AdminOpAction, AdminOps, OpResult } from "../ops.ts";
|
|
4
|
+
export type { AuthConfig } from "../shell/session.ts";
|
|
5
|
+
export type { AdminAuth } from "../auth.ts";
|
|
6
|
+
export { supabaseAdminAuth } from "../supabase-auth.ts";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../../config.ts";
|
|
2
|
+
import type { EntryStore } from "../../entry-store.ts";
|
|
3
|
+
import type { PageStore } from "../../store.ts";
|
|
4
|
+
import { type AdminAuth } from "../auth.ts";
|
|
5
|
+
import type { AdminOpAction } from "../ops.ts";
|
|
6
|
+
export declare function createAdminOpHandler(deps: {
|
|
7
|
+
config: ResolvedConfig;
|
|
8
|
+
pages: PageStore;
|
|
9
|
+
entries: EntryStore;
|
|
10
|
+
auth: AdminAuth;
|
|
11
|
+
}): AdminOpAction;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// packages/smoodly/src/admin/next/op-handler.ts
|
|
2
|
+
// The Next face of the ops seam: ONE server-action body, gated by
|
|
3
|
+
// AdminAuth. The app's 'use server' file wraps this in its single
|
|
4
|
+
// exported async function.
|
|
5
|
+
import { revalidateTag } from "next/cache";
|
|
6
|
+
import { draftMode } from "next/headers";
|
|
7
|
+
import { gateAdminOp } from "../auth.js";
|
|
8
|
+
import { createAdminOps, runAdminOp } from "../ops-impl.js";
|
|
9
|
+
export function createAdminOpHandler(deps) {
|
|
10
|
+
const ops = createAdminOps({
|
|
11
|
+
config: deps.config,
|
|
12
|
+
pages: deps.pages,
|
|
13
|
+
entries: deps.entries,
|
|
14
|
+
effects: {
|
|
15
|
+
revalidatePage: (slug) => revalidateTag(`page:${slug}`, "max"),
|
|
16
|
+
draft: {
|
|
17
|
+
enable: async () => (await draftMode()).enable(),
|
|
18
|
+
disable: async () => (await draftMode()).disable(),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return (call) => gateAdminOp(deps.auth, call, (c) => runAdminOp(ops, c));
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { EntryStore } from "../entry-store.ts";
|
|
3
|
+
import type { PageStore } from "../store.ts";
|
|
4
|
+
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
5
|
+
export type AdminEffects = {
|
|
6
|
+
revalidatePage?(slug: string): void;
|
|
7
|
+
draft?: {
|
|
8
|
+
enable(): void | Promise<void>;
|
|
9
|
+
disable(): void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare function createAdminOps(deps: {
|
|
13
|
+
config: ResolvedConfig;
|
|
14
|
+
pages: PageStore;
|
|
15
|
+
entries: EntryStore;
|
|
16
|
+
effects?: AdminEffects;
|
|
17
|
+
}): AdminOps;
|
|
18
|
+
/** Path-dispatch for the one-server-action seam. Own-property checks only —
|
|
19
|
+
* "constructor.constructor" and prototype paths must not resolve. */
|
|
20
|
+
export declare function runAdminOp(ops: AdminOps, call: AdminOpCall): Promise<OpResult<unknown>>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { affectedPageSlugs } from "../revalidate.js";
|
|
2
|
+
import { validateFields } from "./validate.js";
|
|
3
|
+
const ok = (data) => ({ ok: true, data });
|
|
4
|
+
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
5
|
+
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
6
|
+
* below and `runAdminOp`'s envelope check both lean on. */
|
|
7
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
/** A client-submitted tree is untyped data until proven otherwise — a
|
|
9
|
+
* malformed shape here would corrupt the stored draft and crash every
|
|
10
|
+
* later reader (canvas, publish-integrity walk). Structural only: does
|
|
11
|
+
* not validate per-section field schemas (that's publish's job). */
|
|
12
|
+
function isPageTree(tree) {
|
|
13
|
+
if (!isPlainObject(tree) || !isPlainObject(tree.zones))
|
|
14
|
+
return false;
|
|
15
|
+
if (tree.meta !== undefined && !isPlainObject(tree.meta))
|
|
16
|
+
return false;
|
|
17
|
+
for (const nodes of Object.values(tree.zones)) {
|
|
18
|
+
if (!Array.isArray(nodes))
|
|
19
|
+
return false;
|
|
20
|
+
for (const node of nodes) {
|
|
21
|
+
if (!isPlainObject(node))
|
|
22
|
+
return false;
|
|
23
|
+
if (typeof node.id !== "string" || typeof node.type !== "string")
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/** Store throws are stringly-typed; classify the known guards, default to internal. */
|
|
30
|
+
const classify = (e) => {
|
|
31
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
32
|
+
if (message.includes("already exists"))
|
|
33
|
+
return err("conflict", message);
|
|
34
|
+
if (message.includes("is referenced by"))
|
|
35
|
+
return err("blocked", message);
|
|
36
|
+
if (message.includes("cannot publish"))
|
|
37
|
+
return err("blocked", message);
|
|
38
|
+
if (message.includes("no page") || message.includes("no entry") || message.includes("no collection"))
|
|
39
|
+
return err("not_found", message);
|
|
40
|
+
return err("internal", message);
|
|
41
|
+
};
|
|
42
|
+
export function createAdminOps(deps) {
|
|
43
|
+
const { config, pages, entries, effects } = deps;
|
|
44
|
+
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
45
|
+
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
46
|
+
const fanOut = async (entryId) => {
|
|
47
|
+
if (!effects?.revalidatePage)
|
|
48
|
+
return;
|
|
49
|
+
for (const slug of await affectedPageSlugs({ pages, entries }, entryId)) {
|
|
50
|
+
effects.revalidatePage(slug);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const guard = async (work) => {
|
|
54
|
+
try {
|
|
55
|
+
return await work();
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return classify(e);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
pages: {
|
|
63
|
+
list: () => guard(async () => ok(await pages.listPages())),
|
|
64
|
+
get: (slug) => guard(async () => {
|
|
65
|
+
const record = await pages.getPage(slug);
|
|
66
|
+
const draft = record ? await pages.getDraftTree(slug) : null;
|
|
67
|
+
if (!record || !draft)
|
|
68
|
+
return err("not_found", `smoodly: no page with slug "${slug}".`);
|
|
69
|
+
return ok({ record, draft });
|
|
70
|
+
}),
|
|
71
|
+
create: (input) => guard(async () => {
|
|
72
|
+
const registration = config.registry.pages?.find((p) => p.schema.name === input.template);
|
|
73
|
+
if (!registration) {
|
|
74
|
+
return err("validation", `smoodly: no page template named "${input.template}".`);
|
|
75
|
+
}
|
|
76
|
+
const fixed = registration.schema.slug;
|
|
77
|
+
if (fixed !== undefined && fixed !== input.slug) {
|
|
78
|
+
return err("validation", `smoodly: page template "${input.template}" is fixed to the slug "${fixed}" and cannot be created at "${input.slug}".`);
|
|
79
|
+
}
|
|
80
|
+
if (fixed === undefined) {
|
|
81
|
+
// An open page here would be shadowed forever by the fixed
|
|
82
|
+
// registration's own route file.
|
|
83
|
+
const owner = config.registry.pages?.find((p) => p.schema.slug === input.slug);
|
|
84
|
+
if (owner) {
|
|
85
|
+
return err("validation", `smoodly: the slug "${input.slug}" belongs to the page template "${owner.schema.name}" and its own route file.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return ok(await pages.createPage(input));
|
|
89
|
+
}),
|
|
90
|
+
saveDraft: (slug, tree) => guard(async () => {
|
|
91
|
+
if (!isPageTree(tree))
|
|
92
|
+
return err("validation", "smoodly: malformed page tree.");
|
|
93
|
+
return ok({ versionId: (await pages.saveDraft(slug, tree)).id });
|
|
94
|
+
}),
|
|
95
|
+
publish: (slug) => guard(async () => {
|
|
96
|
+
const draft = await pages.getDraftTree(slug);
|
|
97
|
+
if (!draft)
|
|
98
|
+
return err("not_found", `smoodly: no page with slug "${slug}".`);
|
|
99
|
+
const fieldErrors = [];
|
|
100
|
+
for (const nodes of Object.values(draft.zones)) {
|
|
101
|
+
for (const node of nodes) {
|
|
102
|
+
const schema = sectionFields(node.type);
|
|
103
|
+
if (!schema?.fields)
|
|
104
|
+
continue;
|
|
105
|
+
for (const fe of validateFields(node.fields ?? {}, schema.fields)) {
|
|
106
|
+
fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (fieldErrors.length > 0) {
|
|
111
|
+
return err("validation", `smoodly: cannot publish "${slug}" — fix the listed fields first.`, fieldErrors);
|
|
112
|
+
}
|
|
113
|
+
const record = await pages.publish(slug);
|
|
114
|
+
effects?.revalidatePage?.(slug);
|
|
115
|
+
return ok(record);
|
|
116
|
+
}),
|
|
117
|
+
delete: (slug) => guard(async () => {
|
|
118
|
+
const record = await pages.getPage(slug);
|
|
119
|
+
if (record) {
|
|
120
|
+
// Blocked only when this record IS the registration's singleton.
|
|
121
|
+
// A record whose template is fixed but which sits at another slug
|
|
122
|
+
// (the route file's slug was renamed under it) is reachable at no
|
|
123
|
+
// URL — blocking it would strand it forever.
|
|
124
|
+
const registration = config.registry.pages?.find((p) => p.schema.name === record.template);
|
|
125
|
+
if (registration && registration.schema.slug === record.slug) {
|
|
126
|
+
return err("blocked", `smoodly: "${slug}" is a fixed page owned by the "${record.template}" route file — remove the route file to remove it.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await pages.deletePage(slug);
|
|
130
|
+
return ok(null);
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
entries: {
|
|
134
|
+
list: (collection) => guard(async () => ok(await entries.list(collection))),
|
|
135
|
+
get: (collection, id) => guard(async () => {
|
|
136
|
+
const entry = await entries.get(collection, id);
|
|
137
|
+
return entry ? ok(entry) : err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
138
|
+
}),
|
|
139
|
+
create: (collection, fields) => guard(async () => {
|
|
140
|
+
const descriptors = collectionFields(collection);
|
|
141
|
+
if (!descriptors)
|
|
142
|
+
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
143
|
+
const fieldErrors = validateFields(fields, descriptors);
|
|
144
|
+
if (fieldErrors.length > 0)
|
|
145
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
146
|
+
return ok(await entries.create(collection, fields));
|
|
147
|
+
}),
|
|
148
|
+
save: (collection, id, fields) => guard(async () => {
|
|
149
|
+
const descriptors = collectionFields(collection);
|
|
150
|
+
if (!descriptors)
|
|
151
|
+
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
152
|
+
const fieldErrors = validateFields(fields, descriptors);
|
|
153
|
+
if (fieldErrors.length > 0)
|
|
154
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
155
|
+
const entry = await entries.update(collection, id, fields);
|
|
156
|
+
await fanOut(id);
|
|
157
|
+
return ok(entry);
|
|
158
|
+
}),
|
|
159
|
+
setStatus: (collection, id, status) => guard(async () => {
|
|
160
|
+
const entry = await entries.setStatus(collection, id, status);
|
|
161
|
+
await fanOut(id);
|
|
162
|
+
return ok(entry);
|
|
163
|
+
}),
|
|
164
|
+
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), ok(null))),
|
|
165
|
+
},
|
|
166
|
+
preview: {
|
|
167
|
+
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
168
|
+
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Path-dispatch for the one-server-action seam. Own-property checks only —
|
|
173
|
+
* "constructor.constructor" and prototype paths must not resolve. */
|
|
174
|
+
export async function runAdminOp(ops, call) {
|
|
175
|
+
if (!isPlainObject(call) || typeof call.path !== "string" || !Array.isArray(call.args)) {
|
|
176
|
+
return { ok: false, code: "not_found", message: "smoodly: malformed op call." };
|
|
177
|
+
}
|
|
178
|
+
const [group, method, ...rest] = call.path.split(".");
|
|
179
|
+
const groupObj = rest.length === 0 && group && Object.prototype.hasOwnProperty.call(ops, group)
|
|
180
|
+
? ops[group]
|
|
181
|
+
: undefined;
|
|
182
|
+
const fn = groupObj && method && Object.prototype.hasOwnProperty.call(groupObj, method)
|
|
183
|
+
? groupObj[method]
|
|
184
|
+
: undefined;
|
|
185
|
+
if (typeof fn !== "function") {
|
|
186
|
+
return { ok: false, code: "not_found", message: `smoodly: unknown op "${call.path}".` };
|
|
187
|
+
}
|
|
188
|
+
return fn(...call.args);
|
|
189
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PageRecord } from "../store.ts";
|
|
2
|
+
import type { PageTree } from "../render.tsx";
|
|
3
|
+
import type { EntryRecord } from "../entry-store.ts";
|
|
4
|
+
import type { FieldError } from "./validate.ts";
|
|
5
|
+
export type OpError = {
|
|
6
|
+
ok: false;
|
|
7
|
+
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
8
|
+
message: string;
|
|
9
|
+
fields?: FieldError[];
|
|
10
|
+
};
|
|
11
|
+
export type OpResult<T> = {
|
|
12
|
+
ok: true;
|
|
13
|
+
data: T;
|
|
14
|
+
} | OpError;
|
|
15
|
+
export type AdminOps = {
|
|
16
|
+
pages: {
|
|
17
|
+
list(): Promise<OpResult<PageRecord[]>>;
|
|
18
|
+
get(slug: string): Promise<OpResult<{
|
|
19
|
+
record: PageRecord;
|
|
20
|
+
draft: PageTree;
|
|
21
|
+
}>>;
|
|
22
|
+
create(input: {
|
|
23
|
+
slug: string;
|
|
24
|
+
template: string;
|
|
25
|
+
}): Promise<OpResult<PageRecord>>;
|
|
26
|
+
saveDraft(slug: string, tree: PageTree): Promise<OpResult<{
|
|
27
|
+
versionId: string;
|
|
28
|
+
}>>;
|
|
29
|
+
publish(slug: string): Promise<OpResult<PageRecord>>;
|
|
30
|
+
delete(slug: string): Promise<OpResult<null>>;
|
|
31
|
+
};
|
|
32
|
+
entries: {
|
|
33
|
+
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
34
|
+
get(collection: string, id: string): Promise<OpResult<EntryRecord>>;
|
|
35
|
+
create(collection: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
36
|
+
save(collection: string, id: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
37
|
+
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
38
|
+
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
39
|
+
};
|
|
40
|
+
preview: {
|
|
41
|
+
enable(): Promise<OpResult<null>>;
|
|
42
|
+
disable(): Promise<OpResult<null>>;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/** `token`: the editor's Supabase Auth session token, attached by the
|
|
46
|
+
* client facade and verified by the server gate (admin/auth.ts). */
|
|
47
|
+
export type AdminOpCall = {
|
|
48
|
+
path: string;
|
|
49
|
+
args: unknown[];
|
|
50
|
+
token?: string;
|
|
51
|
+
};
|
|
52
|
+
export type AdminOpAction = (call: AdminOpCall) => Promise<OpResult<unknown>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function pagePath(slug: string, homePageSlug: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The one place the admin turns a page slug into the site URL that serves
|
|
2
|
+
// it. The root's slug is config (`homePageSlug`), not a convention the
|
|
3
|
+
// package guesses at; a registration-declared path is the logged follow-up.
|
|
4
|
+
export function pagePath(slug, homePageSlug) {
|
|
5
|
+
return slug === homePageSlug ? "/" : `/${slug}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type TextNode = {
|
|
2
|
+
type: "text";
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
type Paragraph = {
|
|
6
|
+
type: "paragraph";
|
|
7
|
+
content?: TextNode[];
|
|
8
|
+
};
|
|
9
|
+
export declare function richtextToPlain(doc: unknown): string;
|
|
10
|
+
export declare function plainToRichtext(text: string): {
|
|
11
|
+
type: "doc";
|
|
12
|
+
content: Paragraph[];
|
|
13
|
+
};
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function richtextToPlain(doc) {
|
|
2
|
+
const d = doc;
|
|
3
|
+
if (!d || d.type !== "doc" || !Array.isArray(d.content))
|
|
4
|
+
return "";
|
|
5
|
+
return d.content
|
|
6
|
+
.map((p) => (p.content ?? []).map((t) => t.text ?? "").join(""))
|
|
7
|
+
.join("\n");
|
|
8
|
+
}
|
|
9
|
+
export function plainToRichtext(text) {
|
|
10
|
+
return {
|
|
11
|
+
type: "doc",
|
|
12
|
+
content: text.split("\n").map((line) => ({
|
|
13
|
+
type: "paragraph",
|
|
14
|
+
...(line ? { content: [{ type: "text", text: line }] } : {}),
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { ZonePolicy } from "../zones.ts";
|
|
3
|
+
export type AdminField = Record<string, unknown> & {
|
|
4
|
+
type: string;
|
|
5
|
+
target?: string;
|
|
6
|
+
};
|
|
7
|
+
export type AdminSection = {
|
|
8
|
+
name: string;
|
|
9
|
+
title: string;
|
|
10
|
+
icon?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
fields: Record<string, AdminField>;
|
|
13
|
+
styles: Record<string, AdminField>;
|
|
14
|
+
meta: Record<string, AdminField>;
|
|
15
|
+
sample?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type AdminCollection = {
|
|
18
|
+
name: string;
|
|
19
|
+
title: string;
|
|
20
|
+
titleField?: string;
|
|
21
|
+
fields: Record<string, AdminField>;
|
|
22
|
+
};
|
|
23
|
+
export type AdminPageTemplate = {
|
|
24
|
+
name: string;
|
|
25
|
+
title: string;
|
|
26
|
+
slug?: string;
|
|
27
|
+
zones: Record<string, ZonePolicy>;
|
|
28
|
+
};
|
|
29
|
+
export type AdminRegistry = {
|
|
30
|
+
sections: AdminSection[];
|
|
31
|
+
collections: AdminCollection[];
|
|
32
|
+
pages: AdminPageTemplate[];
|
|
33
|
+
locales: {
|
|
34
|
+
default: string;
|
|
35
|
+
supported: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Which slug the site root serves — see pagePath(). */
|
|
38
|
+
homePageSlug: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
|