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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/admin/auth.d.ts +10 -0
  4. package/dist/admin/auth.js +28 -0
  5. package/dist/admin/client-ops.d.ts +2 -0
  6. package/dist/admin/client-ops.js +18 -0
  7. package/dist/admin/editor/EditorView.d.ts +7 -0
  8. package/dist/admin/editor/EditorView.js +391 -0
  9. package/dist/admin/editor/autosave.d.ts +31 -0
  10. package/dist/admin/editor/autosave.js +108 -0
  11. package/dist/admin/editor/protocol.d.ts +37 -0
  12. package/dist/admin/editor/protocol.js +1 -0
  13. package/dist/admin/forms/FieldWidget.d.ts +14 -0
  14. package/dist/admin/forms/FieldWidget.js +54 -0
  15. package/dist/admin/forms/tabs.d.ts +11 -0
  16. package/dist/admin/forms/tabs.js +20 -0
  17. package/dist/admin/index.d.ts +10 -0
  18. package/dist/admin/index.js +8 -0
  19. package/dist/admin/next/bridge.d.ts +1 -0
  20. package/dist/admin/next/bridge.js +66 -0
  21. package/dist/admin/next/create-admin.d.ts +18 -0
  22. package/dist/admin/next/create-admin.js +14 -0
  23. package/dist/admin/next/index.d.ts +6 -0
  24. package/dist/admin/next/index.js +3 -0
  25. package/dist/admin/next/op-handler.d.ts +11 -0
  26. package/dist/admin/next/op-handler.js +23 -0
  27. package/dist/admin/ops-impl.d.ts +20 -0
  28. package/dist/admin/ops-impl.js +189 -0
  29. package/dist/admin/ops.d.ts +52 -0
  30. package/dist/admin/ops.js +1 -0
  31. package/dist/admin/page-path.d.ts +1 -0
  32. package/dist/admin/page-path.js +6 -0
  33. package/dist/admin/richtext.d.ts +14 -0
  34. package/dist/admin/richtext.js +17 -0
  35. package/dist/admin/serialize.d.ts +40 -0
  36. package/dist/admin/serialize.js +49 -0
  37. package/dist/admin/shell/AdminApp.d.ts +9 -0
  38. package/dist/admin/shell/AdminApp.js +122 -0
  39. package/dist/admin/shell/EntryForm.d.ts +8 -0
  40. package/dist/admin/shell/EntryForm.js +96 -0
  41. package/dist/admin/shell/ListView.d.ts +15 -0
  42. package/dist/admin/shell/ListView.js +14 -0
  43. package/dist/admin/shell/LoginView.d.ts +5 -0
  44. package/dist/admin/shell/LoginView.js +47 -0
  45. package/dist/admin/shell/pages-list.d.ts +22 -0
  46. package/dist/admin/shell/pages-list.js +31 -0
  47. package/dist/admin/shell/session.d.ts +19 -0
  48. package/dist/admin/shell/session.js +37 -0
  49. package/dist/admin/supabase-auth.d.ts +7 -0
  50. package/dist/admin/supabase-auth.js +23 -0
  51. package/dist/admin/tree-ops.d.ts +19 -0
  52. package/dist/admin/tree-ops.js +112 -0
  53. package/dist/admin/validate.d.ts +10 -0
  54. package/dist/admin/validate.js +19 -0
  55. package/dist/client.d.ts +11 -0
  56. package/dist/client.js +19 -0
  57. package/dist/collections.d.ts +39 -0
  58. package/dist/collections.js +22 -0
  59. package/dist/config.d.ts +49 -0
  60. package/dist/config.js +54 -0
  61. package/dist/entry-store.d.ts +74 -0
  62. package/dist/entry-store.js +132 -0
  63. package/dist/env.d.ts +16 -0
  64. package/dist/env.js +22 -0
  65. package/dist/fields.d.ts +67 -0
  66. package/dist/fields.js +41 -0
  67. package/dist/index.d.ts +103 -0
  68. package/dist/index.js +29 -0
  69. package/dist/next/index.d.ts +3 -0
  70. package/dist/next/index.js +2 -0
  71. package/dist/next/meta.d.ts +10 -0
  72. package/dist/next/meta.js +37 -0
  73. package/dist/next/page-renderer.d.ts +36 -0
  74. package/dist/next/page-renderer.js +69 -0
  75. package/dist/page.d.ts +22 -0
  76. package/dist/page.js +35 -0
  77. package/dist/pairing.d.ts +33 -0
  78. package/dist/pairing.js +26 -0
  79. package/dist/ref-index.d.ts +19 -0
  80. package/dist/ref-index.js +0 -0
  81. package/dist/refs.d.ts +20 -0
  82. package/dist/refs.js +49 -0
  83. package/dist/render.d.ts +48 -0
  84. package/dist/render.js +41 -0
  85. package/dist/resolve.d.ts +22 -0
  86. package/dist/resolve.js +128 -0
  87. package/dist/revalidate.d.ts +8 -0
  88. package/dist/revalidate.js +34 -0
  89. package/dist/schema.d.ts +57 -0
  90. package/dist/schema.js +40 -0
  91. package/dist/section-wrapper.d.ts +11 -0
  92. package/dist/section-wrapper.js +19 -0
  93. package/dist/sql-space.d.ts +6 -0
  94. package/dist/sql-space.js +172 -0
  95. package/dist/sql.d.ts +3 -0
  96. package/dist/sql.js +162 -0
  97. package/dist/store.d.ts +73 -0
  98. package/dist/store.js +145 -0
  99. package/dist/supabase-entry-store.d.ts +26 -0
  100. package/dist/supabase-entry-store.js +191 -0
  101. package/dist/supabase-store.d.ts +26 -0
  102. package/dist/supabase-store.js +180 -0
  103. package/dist/zones.d.ts +31 -0
  104. package/dist/zones.js +23 -0
  105. package/package.json +50 -0
@@ -0,0 +1,49 @@
1
+ function serializeDescriptor(d) {
2
+ const out = { type: d.type };
3
+ for (const [key, value] of Object.entries(d)) {
4
+ if (key === "target" && typeof value === "function") {
5
+ out.target = value().name;
6
+ }
7
+ else if (key === "fields" && d.type === "object") {
8
+ out.fields = serializeMap(value);
9
+ }
10
+ else if (typeof value !== "function") {
11
+ out[key] = value;
12
+ }
13
+ }
14
+ return out;
15
+ }
16
+ function serializeMap(map) {
17
+ return Object.fromEntries(Object.entries(map ?? {}).map(([k, d]) => [k, serializeDescriptor(d)]));
18
+ }
19
+ export function serializeAdminConfig(config) {
20
+ return {
21
+ sections: (config.registry.sections ?? []).map(({ schema }) => {
22
+ const s = schema;
23
+ return {
24
+ name: s.name,
25
+ title: s.title ?? s.name,
26
+ icon: s.icon,
27
+ description: s.description,
28
+ fields: serializeMap(s.fields),
29
+ styles: serializeMap(config.resolvedStyles(s.name)),
30
+ meta: serializeMap(s.meta),
31
+ sample: s.sample,
32
+ };
33
+ }),
34
+ collections: (config.registry.collections ?? []).map((c) => ({
35
+ name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(c.fields),
36
+ })),
37
+ pages: (config.registry.pages ?? []).map(({ schema }) => {
38
+ const p = schema;
39
+ return {
40
+ name: p.name,
41
+ title: p.title ?? p.name,
42
+ ...(p.slug !== undefined ? { slug: p.slug } : {}),
43
+ zones: p.zones ?? {},
44
+ };
45
+ }),
46
+ locales: config.locales,
47
+ homePageSlug: config.homePageSlug,
48
+ };
49
+ }
@@ -0,0 +1,9 @@
1
+ import type { AdminOpAction } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ import { type AuthConfig } from "./session.ts";
4
+ export declare function AdminApp({ registry, segments, op, auth }: {
5
+ registry: AdminRegistry;
6
+ segments: string[];
7
+ op: AdminOpAction;
8
+ auth: AuthConfig;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,122 @@
1
+ // packages/smoodly/src/admin/shell/AdminApp.tsx
2
+ "use client";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ // The admin shell: registry-derived nav (never separately configured),
5
+ // list views in the main area, editor/entry views mounted by segment.
6
+ // Internal routing = the catch-all's segments; plain <a> navigation.
7
+ import { useEffect, useState } from "react";
8
+ import { makeClientOps } from "../client-ops.js";
9
+ import { ListView, StatusChip } from "./ListView.js";
10
+ import { openTemplates, pagesRows } from "./pages-list.js";
11
+ import { pagePath } from "../page-path.js";
12
+ import { EntryForm } from "./EntryForm.js";
13
+ import { EditorView } from "../editor/EditorView.js";
14
+ import { LoginView } from "./LoginView.js";
15
+ import { useAdminSession } from "./session.js";
16
+ const BASE = "/admin";
17
+ function Nav({ registry, active, onSignOut }) {
18
+ const item = (href, label, isActive) => (_jsx("a", { href: href ?? undefined, "aria-disabled": href ? undefined : true, style: {
19
+ display: "block", padding: "9px 14px", borderRadius: 9, marginBottom: 2, textDecoration: "none",
20
+ color: href ? "#17141f" : "#a7a1b8", fontWeight: isActive ? 700 : 500, fontSize: 14,
21
+ background: isActive ? "#e7e2f6" : "transparent", cursor: href ? "pointer" : "default",
22
+ }, children: label }, label));
23
+ return (_jsxs("nav", { style: { width: 210, flexShrink: 0, padding: 18, borderRight: "1px solid #e7e4f0", background: "#faf9fd", minHeight: "100vh", boxSizing: "border-box" }, children: [_jsxs("div", { style: { fontWeight: 800, fontSize: 17, margin: "4px 0 22px" }, children: ["smoodly", _jsx("span", { style: { color: "#6d4aff" }, children: "." })] }), item(`${BASE}/pages`, "Pages", active === "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, active === c.name)), _jsx("div", { style: { borderTop: "1px solid #e7e4f0", margin: "14px 0" } }), item(null, "Global Content", false), item(null, "Media", false), item(null, "Settings", false), _jsx("div", { style: { borderTop: "1px solid #e7e4f0", margin: "14px 0" } }), _jsx("button", { onClick: onSignOut, style: { display: "block", width: "100%", textAlign: "left", padding: "9px 14px", borderRadius: 9, border: 0, background: "transparent", color: "#6b7280", fontSize: 14, cursor: "pointer" }, children: "Sign out" })] }));
24
+ }
25
+ function PagesList({ ops, registry }) {
26
+ const [pages, setPages] = useState(null);
27
+ const [error, setError] = useState(null);
28
+ useEffect(() => {
29
+ ops.pages.list()
30
+ .then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
31
+ .catch(() => setError("Couldn't reach the server."));
32
+ }, [ops]);
33
+ const rows = pages ? pagesRows(pages, registry.pages) : [];
34
+ const creatable = openTemplates(registry.pages);
35
+ const noOpen = creatable.length === 0;
36
+ /** Create a page, then open it in the editor — or surface the op's message. */
37
+ const createAndOpen = async (slug, template) => {
38
+ const result = await ops.pages.create({ slug, template });
39
+ if (result.ok)
40
+ window.location.href = `${BASE}/pages/${encodeURIComponent(slug)}`;
41
+ else
42
+ window.alert(result.message);
43
+ };
44
+ const newPage = async () => {
45
+ if (noOpen)
46
+ return;
47
+ const slug = window.prompt("Slug for the new page (e.g. about):");
48
+ if (!slug)
49
+ return;
50
+ const template = creatable.length === 1
51
+ ? creatable[0].name
52
+ : window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) ?? "";
53
+ await createAndOpen(slug, template);
54
+ };
55
+ if (error)
56
+ return _jsx("p", { style: { padding: 32, color: "#b91c1c" }, children: error });
57
+ return (_jsx(ListView, { title: "Pages", actions: _jsx("button", { onClick: newPage, disabled: noOpen, title: noOpen ? "Every page registration is fixed to its own route file — add an open one (a page schema with no `slug`) to create pages here." : undefined, style: { background: "#6d4aff", color: "#fff", border: 0, borderRadius: 9, padding: "9px 16px", fontWeight: 600, cursor: noOpen ? "not-allowed" : "pointer", opacity: noOpen ? 0.5 : 1 }, children: "New page" }), columns: ["Slug", "Template", "Status"], rows: rows.map((row) => row.kind === "missing"
58
+ ? {
59
+ key: `missing:${row.slug}`,
60
+ onClick: () => createAndOpen(row.slug, row.template),
61
+ cells: [
62
+ _jsx("strong", { children: pagePath(row.slug, registry.homePageSlug) }, "s"),
63
+ row.templateTitle,
64
+ _jsx("em", { style: { color: "#6b7280" }, children: "not created" }, "c"),
65
+ ],
66
+ }
67
+ : {
68
+ key: row.record.id,
69
+ href: `${BASE}/pages/${encodeURIComponent(row.record.slug)}`,
70
+ cells: [
71
+ _jsx("strong", { children: pagePath(row.record.slug, registry.homePageSlug) }, "s"),
72
+ row.templateTitle,
73
+ _jsx(StatusChip, { status: row.record.publishedVersionId ? (row.record.publishedVersionId === row.record.draftVersionId ? "published" : "draft edits") : "draft" }, "c"),
74
+ ],
75
+ }) }));
76
+ }
77
+ function EntriesList({ ops, registry, collection }) {
78
+ const meta = registry.collections.find((c) => c.name === collection);
79
+ const [rows, setRows] = useState(null);
80
+ const [error, setError] = useState(null);
81
+ useEffect(() => {
82
+ ops.entries.list(collection)
83
+ .then((r) => (r.ok ? setRows(r.data) : setError(r.message)))
84
+ .catch(() => setError("Couldn't reach the server."));
85
+ }, [ops, collection]);
86
+ if (!meta)
87
+ return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
88
+ if (error)
89
+ return _jsx("p", { style: { padding: 32, color: "#b91c1c" }, children: error });
90
+ const title = (e) => String((meta.titleField && e.fields[meta.titleField]) ?? Object.values(e.fields).find((v) => typeof v === "string") ?? e.id);
91
+ return (_jsx(ListView, { title: meta.title, actions: _jsx("a", { href: `${BASE}/${collection}/new`, style: { background: "#6d4aff", color: "#fff", borderRadius: 9, padding: "9px 16px", fontWeight: 600, textDecoration: "none" }, children: "New entry" }), columns: ["Title", "Status", "Updated"], rows: (rows ?? []).map((e) => ({
92
+ key: e.id,
93
+ href: `${BASE}/${collection}/${encodeURIComponent(e.id)}`,
94
+ cells: [
95
+ _jsx("strong", { children: title(e) }, "t"),
96
+ _jsx(StatusChip, { status: e.status }, "c"),
97
+ new Date(e.updatedAt).toLocaleString(),
98
+ ],
99
+ })) }));
100
+ }
101
+ export function AdminApp({ registry, segments, op, auth }) {
102
+ const session = useAdminSession(auth);
103
+ const [ops] = useState(() => makeClientOps(op, session.token));
104
+ const [head = "pages", tail] = segments.length ? segments : ["pages"];
105
+ const isCollection = registry.collections.some((c) => c.name === head);
106
+ if (session.state.status === "loading")
107
+ return _jsx("p", { style: { padding: 32, color: "#6b7280" }, children: "Loading\u2026" });
108
+ if (session.state.status === "signed_out")
109
+ return _jsx(LoginView, { client: session.client, providers: auth.providers });
110
+ let main;
111
+ if (head === "pages" && !tail)
112
+ main = _jsx(PagesList, { ops: ops, registry: registry });
113
+ else if (head === "pages" && tail)
114
+ main = _jsx(EditorView, { ops: ops, registry: registry, slug: decodeURIComponent(tail) });
115
+ else if (isCollection && !tail)
116
+ main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
117
+ else if (isCollection && tail)
118
+ main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
119
+ else
120
+ main = _jsxs("p", { style: { padding: 32 }, children: ["Not found. ", _jsx("a", { href: `${BASE}/pages`, children: "Back to pages" }), "."] });
121
+ return (_jsxs("div", { style: { display: "flex", alignItems: "stretch" }, children: [_jsx(Nav, { registry: registry, active: head, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { style: { flex: 1, minWidth: 0 }, children: main })] }));
122
+ }
@@ -0,0 +1,8 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function EntryForm({ ops, registry, collection, id }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ collection: string;
7
+ id: string;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ // packages/smoodly/src/admin/shell/EntryForm.tsx
2
+ "use client";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ // The classic field view: same FieldWidget renderer as the editor sidebar,
5
+ // no tabs (collections are fields-only by design). The safe-delete guard's
6
+ // message renders verbatim — it is product surface, not an error detail.
7
+ import { useEffect, useMemo, useState } from "react";
8
+ import { FieldWidget } from "../forms/FieldWidget.js";
9
+ import { StatusChip } from "./ListView.js";
10
+ export function EntryForm({ ops, registry, collection, id }) {
11
+ const meta = registry.collections.find((c) => c.name === collection);
12
+ const isNew = id === "new";
13
+ const [fields, setFields] = useState({});
14
+ const [status, setStatus] = useState("draft");
15
+ const [refOptions, setRefOptions] = useState({});
16
+ const [banner, setBanner] = useState(null);
17
+ const [fieldErrors, setFieldErrors] = useState({});
18
+ const [loaded, setLoaded] = useState(isNew);
19
+ const refTargets = useMemo(() => {
20
+ const out = {};
21
+ for (const [key, d] of Object.entries(meta?.fields ?? {})) {
22
+ if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
23
+ out[key] = d.target;
24
+ }
25
+ return out;
26
+ }, [meta]);
27
+ useEffect(() => {
28
+ if (!isNew) {
29
+ ops.entries.get(collection, id).then((r) => {
30
+ if (r.ok) {
31
+ setFields(r.data.fields);
32
+ setStatus(r.data.status);
33
+ setLoaded(true);
34
+ }
35
+ else
36
+ setBanner({ tone: "error", text: r.message });
37
+ }).catch(() => setBanner({ tone: "error", text: "Couldn't reach the server." }));
38
+ }
39
+ for (const target of new Set(Object.values(refTargets))) {
40
+ const targetMeta = registry.collections.find((c) => c.name === target);
41
+ ops.entries.list(target).then((r) => {
42
+ if (!r.ok)
43
+ return;
44
+ const options = r.data.map((e) => ({
45
+ id: e.id,
46
+ title: String((targetMeta?.titleField && e.fields[targetMeta.titleField]) ?? e.id),
47
+ status: e.status,
48
+ }));
49
+ setRefOptions((prev) => ({ ...prev, [target]: options }));
50
+ }).catch(console.error);
51
+ }
52
+ }, [ops, collection, id, isNew, refTargets, registry]);
53
+ if (!meta)
54
+ return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
55
+ const applyResult = (r, okText) => {
56
+ if (r.ok) {
57
+ setBanner({ tone: "ok", text: okText });
58
+ setFieldErrors({});
59
+ return true;
60
+ }
61
+ setBanner({ tone: "error", text: r.message });
62
+ setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
63
+ return false;
64
+ };
65
+ const save = async () => {
66
+ if (isNew) {
67
+ const r = await ops.entries.create(collection, fields);
68
+ if (applyResult(r, "Created.") && r.ok) {
69
+ window.location.href = `/admin/${collection}/${encodeURIComponent(r.data.id)}`;
70
+ }
71
+ }
72
+ else {
73
+ applyResult(await ops.entries.save(collection, id, fields), "Saved.");
74
+ }
75
+ };
76
+ const flipStatus = async () => {
77
+ const next = status === "published" ? "draft" : "published";
78
+ const r = await ops.entries.setStatus(collection, id, next);
79
+ if (applyResult(r, next === "published" ? "Published." : "Unpublished."))
80
+ setStatus(next);
81
+ };
82
+ const destroy = async () => {
83
+ if (!window.confirm("Delete this entry?"))
84
+ return;
85
+ const r = await ops.entries.delete(collection, id);
86
+ if (r.ok)
87
+ window.location.href = `/admin/${collection}`;
88
+ else
89
+ setBanner({ tone: "error", text: r.message });
90
+ };
91
+ const btn = (bg, fg = "#fff", disabled = false) => ({
92
+ background: bg, color: fg, border: 0, borderRadius: 9, padding: "9px 16px", fontWeight: 600,
93
+ cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.5 : 1,
94
+ });
95
+ return (_jsxs("section", { style: { maxWidth: 640, margin: "0 auto", padding: "32px 24px" }, children: [_jsxs("header", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }, children: [_jsx("h1", { style: { fontSize: 22, margin: 0 }, children: isNew ? `New ${meta.title}` : `Edit ${meta.title}` }), !isNew && _jsx(StatusChip, { status: status })] }), _jsx("p", { style: { margin: "0 0 20px" }, children: _jsxs("a", { href: `/admin/${collection}`, style: { fontSize: 13, opacity: 0.6 }, children: ["\u2190 ", meta.title] }) }), banner && (_jsx("p", { style: { padding: "10px 14px", borderRadius: 9, fontSize: 14, background: banner.tone === "ok" ? "#e5f6ec" : "#fdecec", color: banner.tone === "ok" ? "#116b38" : "#b91c1c" }, children: banner.text })), loaded && (_jsx("div", { style: { background: "#fff", borderRadius: 12, border: "1px solid #e7e4f0", padding: 20 }, children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => setFields((f) => ({ ...f, [key]: value })) }, key))) })), _jsxs("footer", { style: { display: "flex", gap: 10, marginTop: 18 }, children: [_jsx("button", { onClick: save, disabled: !loaded, style: btn("#6d4aff", "#fff", !loaded), children: isNew ? "Create" : "Save" }), !isNew && _jsx("button", { onClick: flipStatus, disabled: !loaded, style: btn("#17141f", "#fff", !loaded), children: status === "published" ? "Unpublish" : "Publish" }), !isNew && _jsx("button", { onClick: destroy, disabled: !loaded, style: btn("#fdecec", "#b91c1c", !loaded), children: "Delete" })] })] }));
96
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ export declare function ListView({ title, actions, columns, rows }: {
3
+ title: string;
4
+ actions?: ReactNode;
5
+ columns: string[];
6
+ rows: {
7
+ key: string;
8
+ href?: string;
9
+ onClick?: () => void;
10
+ cells: ReactNode[];
11
+ }[];
12
+ }): import("react").JSX.Element;
13
+ export declare function StatusChip({ status }: {
14
+ status: string;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ // packages/smoodly/src/admin/shell/ListView.tsx
2
+ "use client";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ export function ListView({ title, actions, columns, rows }) {
5
+ return (_jsxs("section", { style: { maxWidth: 780, margin: "0 auto", padding: "32px 24px" }, children: [_jsxs("header", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }, children: [_jsx("h1", { style: { fontSize: 22, margin: 0 }, children: title }), _jsx("div", { children: actions })] }), _jsx("div", { style: { background: "#fff", borderRadius: 12, border: "1px solid #e7e4f0", overflow: "hidden" }, children: _jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 14 }, children: [_jsx("thead", { children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { style: { textAlign: "left", padding: "10px 14px", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", opacity: 0.5, borderBottom: "1px solid #e7e4f0" }, children: c }, c))) }) }), _jsxs("tbody", { children: [rows.map((r) => (_jsx("tr", { style: { borderBottom: "1px solid #f0eef6", cursor: r.href || r.onClick ? "pointer" : "default" }, onClick: r.onClick ?? (r.href ? () => (window.location.href = r.href) : undefined), children: r.cells.map((cell, i) => _jsx("td", { style: { padding: "12px 14px" }, children: cell }, i)) }, r.key))), rows.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: columns.length, style: { padding: 24, opacity: 0.5 }, children: "Nothing here yet." }) }))] })] }) })] }));
6
+ }
7
+ export function StatusChip({ status }) {
8
+ const palette = {
9
+ published: { bg: "#e5f6ec", fg: "#116b38" },
10
+ draft: { bg: "#f1eefb", fg: "#5b3df0" },
11
+ };
12
+ const c = palette[status] ?? { bg: "#eee", fg: "#444" };
13
+ return (_jsx("span", { style: { background: c.bg, color: c.fg, borderRadius: 99, padding: "3px 10px", fontSize: 12, fontWeight: 600 }, children: status }));
14
+ }
@@ -0,0 +1,5 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ export declare function LoginView({ client, providers }: {
3
+ client: SupabaseClient;
4
+ providers?: ("google" | "github")[];
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The admin's front door (spec 2026-09-04 §5): email+password always;
4
+ // Google/GitHub when the auth project has them. Errors come from
5
+ // Supabase Auth verbatim. Being an editor is checked by the server on
6
+ // the first op — a signed-in non-editor sees that message in the shell.
7
+ import { useState } from "react";
8
+ const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
9
+ export function LoginView({ client, providers = [] }) {
10
+ const [email, setEmail] = useState("");
11
+ const [password, setPassword] = useState("");
12
+ const [error, setError] = useState(null);
13
+ const [busy, setBusy] = useState(false);
14
+ const submit = async (e) => {
15
+ e.preventDefault();
16
+ setBusy(true);
17
+ setError(null);
18
+ try {
19
+ const res = await client.auth.signInWithPassword({ email, password });
20
+ if (res.error)
21
+ setError(res.error.message);
22
+ }
23
+ catch (e) {
24
+ setError(e instanceof Error ? e.message : String(e));
25
+ }
26
+ finally {
27
+ setBusy(false);
28
+ }
29
+ };
30
+ const oauth = async (provider) => {
31
+ setBusy(true);
32
+ setError(null);
33
+ try {
34
+ const res = await client.auth.signInWithOAuth({ provider, options: { redirectTo: window.location.href } });
35
+ if (res.error)
36
+ setError(res.error.message);
37
+ }
38
+ catch (e) {
39
+ setError(e instanceof Error ? e.message : String(e));
40
+ }
41
+ finally {
42
+ setBusy(false);
43
+ }
44
+ };
45
+ const field = { display: "block", width: "100%", boxSizing: "border-box", padding: "10px 12px", border: "1px solid #d9d5e6", borderRadius: 9, marginBottom: 12, fontSize: 14 };
46
+ return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center" }, children: _jsxs("form", { onSubmit: submit, style: { width: 340, background: "#fff", padding: 28, borderRadius: 14, border: "1px solid #e7e4f0" }, children: [_jsxs("div", { style: { fontWeight: 800, fontSize: 20, marginBottom: 18 }, children: ["smoodly", _jsx("span", { style: { color: "#6d4aff" }, children: "." })] }), _jsxs("label", { style: { fontSize: 13, fontWeight: 600 }, children: ["Email", _jsx("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "email", style: field })] }), _jsxs("label", { style: { fontSize: 13, fontWeight: 600 }, children: ["Password", _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), required: true, autoComplete: "current-password", style: field })] }), error && _jsx("p", { style: { color: "#b91c1c", fontSize: 13, margin: "0 0 12px" }, children: error }), _jsx("button", { type: "submit", disabled: busy, style: { width: "100%", background: "#6d4aff", color: "#fff", border: 0, borderRadius: 9, padding: "10px 16px", fontWeight: 600, cursor: "pointer" }, children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx("button", { type: "button", disabled: busy, onClick: () => oauth(p), style: { width: "100%", marginTop: 10, background: "#fff", border: "1px solid #d9d5e6", borderRadius: 9, padding: "10px 16px", fontWeight: 600, cursor: "pointer" }, children: LABELS[p] }, p)))] }) }));
47
+ }
@@ -0,0 +1,22 @@
1
+ import type { PageRecord } from "../../store.ts";
2
+ import type { AdminPageTemplate } from "../serialize.ts";
3
+ /** Registrations editors may create pages against. A fixed-slug
4
+ * registration is a singleton owned by one route file, so it is never
5
+ * offered — and with exactly one open template left, the New Page flow
6
+ * needs no template prompt at all. */
7
+ export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
8
+ export type PagesRow = {
9
+ kind: "record";
10
+ record: PageRecord;
11
+ templateTitle: string;
12
+ fixed: boolean;
13
+ } | {
14
+ kind: "missing";
15
+ slug: string;
16
+ template: string;
17
+ templateTitle: string;
18
+ };
19
+ /** Fixed registrations always get a row, even before their record
20
+ * exists: otherwise a fresh install's "/" is a 404 the admin offers no
21
+ * way to fix, since New Page filters singletons out. */
22
+ export declare function pagesRows(records: PageRecord[], templates: AdminPageTemplate[]): PagesRow[];
@@ -0,0 +1,31 @@
1
+ /** Registrations editors may create pages against. A fixed-slug
2
+ * registration is a singleton owned by one route file, so it is never
3
+ * offered — and with exactly one open template left, the New Page flow
4
+ * needs no template prompt at all. */
5
+ export function openTemplates(templates) {
6
+ return templates.filter((t) => t.slug === undefined);
7
+ }
8
+ /** Fixed registrations always get a row, even before their record
9
+ * exists: otherwise a fresh install's "/" is a 404 the admin offers no
10
+ * way to fix, since New Page filters singletons out. */
11
+ export function pagesRows(records, templates) {
12
+ const titleOf = (name) => templates.find((t) => t.name === name)?.title ?? name;
13
+ const bySlug = new Map(records.map((r) => [r.slug, r]));
14
+ const fixedSlugs = new Set();
15
+ const rows = [];
16
+ for (const t of templates) {
17
+ if (t.slug === undefined)
18
+ continue;
19
+ fixedSlugs.add(t.slug);
20
+ const record = bySlug.get(t.slug);
21
+ rows.push(record
22
+ ? { kind: "record", record, templateTitle: titleOf(record.template), fixed: true }
23
+ : { kind: "missing", slug: t.slug, template: t.name, templateTitle: t.title });
24
+ }
25
+ for (const record of records) {
26
+ if (fixedSlugs.has(record.slug))
27
+ continue;
28
+ rows.push({ kind: "record", record, templateTitle: titleOf(record.template), fixed: false });
29
+ }
30
+ return rows;
31
+ }
@@ -0,0 +1,19 @@
1
+ import { type Session, type SupabaseClient } from "@supabase/supabase-js";
2
+ export type AuthConfig = {
3
+ url: string;
4
+ key: string;
5
+ providers?: ("google" | "github")[];
6
+ };
7
+ export type SessionState = {
8
+ status: "loading";
9
+ } | {
10
+ status: "signed_out";
11
+ } | {
12
+ status: "signed_in";
13
+ session: Session;
14
+ };
15
+ export declare function useAdminSession(auth: AuthConfig): {
16
+ client: SupabaseClient;
17
+ state: SessionState;
18
+ token: () => string | null;
19
+ };
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ // The editor's session, browser side: one Supabase client for the AUTH
3
+ // project (smoodly.io's in the cloud, the customer's own in self-host —
4
+ // the app passes the URL and publishable key). The token getter reads a
5
+ // ref so the ops facade, created once, always sends the current token.
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { createClient } from "@supabase/supabase-js";
8
+ export function useAdminSession(auth) {
9
+ const browser = typeof window !== "undefined";
10
+ const [client] = useState(() => createClient(auth.url, auth.key, {
11
+ auth: { autoRefreshToken: browser, persistSession: browser, detectSessionInUrl: browser },
12
+ }));
13
+ const [state, setState] = useState({ status: "loading" });
14
+ const current = useRef(null);
15
+ useEffect(() => {
16
+ let active = true;
17
+ const apply = (session) => {
18
+ current.current = session;
19
+ setState(session ? { status: "signed_in", session } : { status: "signed_out" });
20
+ };
21
+ client.auth.getSession().then(({ data }) => {
22
+ if (!active)
23
+ return;
24
+ apply(data.session);
25
+ }).catch(() => {
26
+ if (!active)
27
+ return;
28
+ apply(null);
29
+ });
30
+ const { data } = client.auth.onAuthStateChange((_event, session) => apply(session));
31
+ return () => {
32
+ active = false;
33
+ data.subscription.unsubscribe();
34
+ };
35
+ }, [client]);
36
+ return { client, state, token: () => current.current?.access_token ?? null };
37
+ }
@@ -0,0 +1,7 @@
1
+ import { type SupabaseClient } from "@supabase/supabase-js";
2
+ import type { AdminAuth } from "./auth.ts";
3
+ export declare function supabaseAdminAuth(input: {
4
+ authUrl: string;
5
+ authKey: string;
6
+ db: SupabaseClient;
7
+ }): AdminAuth;
@@ -0,0 +1,23 @@
1
+ // The Supabase-backed AdminAuth: the session is verified against the
2
+ // AUTH project (smoodly.io's in the cloud, the customer's own in self-
3
+ // host — same code, different SMOODLY_AUTH_URL), the editors row is read
4
+ // from the CONTENT project with the write key. No space filter: the
5
+ // policy set scopes the write key, and self-host has one space.
6
+ import { createClient } from "@supabase/supabase-js";
7
+ export function supabaseAdminAuth(input) {
8
+ const auth = createClient(input.authUrl, input.authKey, {
9
+ auth: { persistSession: false, autoRefreshToken: false },
10
+ });
11
+ return {
12
+ async verify(token) {
13
+ const res = await auth.auth.getUser(token);
14
+ return res.data.user ? { userId: res.data.user.id } : null;
15
+ },
16
+ async isEditor(userId) {
17
+ const res = await input.db.from("editors").select("user_id").eq("user_id", userId).limit(1);
18
+ if (res.error)
19
+ throw new Error(`smoodly: ${res.error.message}`);
20
+ return res.data.length > 0;
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,19 @@
1
+ import type { PageTree, TreeNode } from "../render.tsx";
2
+ import type { ZonePolicy } from "../zones.ts";
3
+ import type { AdminSection } from "./serialize.ts";
4
+ export declare function zoneOf(tree: PageTree, id: string): string | null;
5
+ export declare function findNode(tree: PageTree, id: string): TreeNode | null;
6
+ export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
7
+ export declare function removeNode(tree: PageTree, id: string): PageTree;
8
+ export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageTree;
9
+ export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
10
+ export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
11
+ export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
12
+ export declare function nodeFromSample(section: AdminSection, id: string): TreeNode;
13
+ /** A locked zone holds exactly one node the editor can never insert or
14
+ * remove — so someone has to put it there. This does, from the component's
15
+ * sample, for every locked zone that is empty or missing. Runs when the
16
+ * editor loads a draft, which also covers pages that predate a locked zone
17
+ * being added to their schema. Returns the same tree when nothing changed. */
18
+ export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[]): PageTree;
19
+ export declare function newNodeId(type: string): string;