smoodly 0.0.4 → 0.0.6

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 (89) hide show
  1. package/README.md +408 -34
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.d.ts +3 -2
  4. package/dist/admin/editor/EditorView.js +133 -80
  5. package/dist/admin/editor/PageSettings.d.ts +13 -0
  6. package/dist/admin/editor/PageSettings.js +40 -0
  7. package/dist/admin/fixed-nodes.d.ts +23 -0
  8. package/dist/admin/fixed-nodes.js +83 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +2 -1
  10. package/dist/admin/forms/FieldWidget.js +16 -16
  11. package/dist/admin/index.d.ts +2 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/next/create-admin.js +7 -2
  14. package/dist/admin/next/op-handler.d.ts +1 -0
  15. package/dist/admin/next/op-handler.js +7 -2
  16. package/dist/admin/ops-impl.d.ts +4 -1
  17. package/dist/admin/ops-impl.js +190 -48
  18. package/dist/admin/ops.d.ts +39 -11
  19. package/dist/admin/serialize.d.ts +10 -1
  20. package/dist/admin/serialize.js +13 -2
  21. package/dist/admin/shell/AdminApp.js +36 -74
  22. package/dist/admin/shell/EntryForm.js +34 -10
  23. package/dist/admin/shell/ErrorPane.d.ts +4 -0
  24. package/dist/admin/shell/ErrorPane.js +9 -0
  25. package/dist/admin/shell/ListView.d.ts +8 -5
  26. package/dist/admin/shell/ListView.js +12 -11
  27. package/dist/admin/shell/LoginView.js +6 -2
  28. package/dist/admin/shell/PagesList.d.ts +6 -0
  29. package/dist/admin/shell/PagesList.js +148 -0
  30. package/dist/admin/shell/base.d.ts +1 -0
  31. package/dist/admin/shell/base.js +4 -0
  32. package/dist/admin/shell/pages-tree.d.ts +39 -0
  33. package/dist/admin/shell/pages-tree.js +65 -0
  34. package/dist/admin/ui/format.d.ts +1 -0
  35. package/dist/admin/ui/format.js +25 -0
  36. package/dist/admin/ui/primitives.d.ts +89 -0
  37. package/dist/admin/ui/primitives.js +115 -0
  38. package/dist/admin/ui/theme.d.ts +5 -0
  39. package/dist/admin/ui/theme.js +396 -0
  40. package/dist/collections.d.ts +23 -2
  41. package/dist/collections.js +19 -0
  42. package/dist/config.d.ts +11 -1
  43. package/dist/config.js +71 -13
  44. package/dist/entry-page.d.ts +16 -0
  45. package/dist/entry-page.js +6 -0
  46. package/dist/entry-store.d.ts +94 -7
  47. package/dist/entry-store.js +175 -26
  48. package/dist/index.d.ts +24 -8
  49. package/dist/index.js +14 -3
  50. package/dist/localize.d.ts +19 -0
  51. package/dist/localize.js +42 -0
  52. package/dist/next/index.d.ts +2 -2
  53. package/dist/next/index.js +1 -1
  54. package/dist/next/meta.d.ts +0 -6
  55. package/dist/next/meta.js +0 -11
  56. package/dist/next/page-renderer.d.ts +26 -18
  57. package/dist/next/page-renderer.js +79 -56
  58. package/dist/page.d.ts +10 -3
  59. package/dist/page.js +12 -2
  60. package/dist/pairing.d.ts +7 -6
  61. package/dist/path-index.d.ts +16 -0
  62. package/dist/path-index.js +33 -0
  63. package/dist/paths.d.ts +80 -0
  64. package/dist/paths.js +110 -0
  65. package/dist/render.d.ts +34 -5
  66. package/dist/render.js +9 -2
  67. package/dist/resolve.d.ts +8 -0
  68. package/dist/resolve.js +19 -7
  69. package/dist/revalidate.d.ts +10 -2
  70. package/dist/revalidate.js +13 -8
  71. package/dist/site.d.ts +85 -0
  72. package/dist/site.js +164 -0
  73. package/dist/sql-space.d.ts +1 -1
  74. package/dist/sql-space.js +2 -0
  75. package/dist/sql.d.ts +1 -1
  76. package/dist/sql.js +92 -15
  77. package/dist/store.d.ts +106 -30
  78. package/dist/store.js +242 -66
  79. package/dist/supabase-entry-store.d.ts +17 -3
  80. package/dist/supabase-entry-store.js +198 -22
  81. package/dist/supabase-path-index.d.ts +11 -0
  82. package/dist/supabase-path-index.js +52 -0
  83. package/dist/supabase-store.d.ts +46 -16
  84. package/dist/supabase-store.js +322 -110
  85. package/package.json +5 -4
  86. package/dist/admin/page-path.d.ts +0 -1
  87. package/dist/admin/page-path.js +0 -6
  88. package/dist/admin/shell/pages-list.d.ts +0 -22
  89. package/dist/admin/shell/pages-list.js +0 -31
@@ -4,75 +4,31 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // The admin shell: registry-derived nav (never separately configured),
5
5
  // list views in the main area, editor/entry views mounted by segment.
6
6
  // Internal routing = the catch-all's segments; plain <a> navigation.
7
+ //
8
+ // Layout is the design system's three columns: a 200px nav, then the main
9
+ // column whose views own their own 48px top bar (the editor adds the third,
10
+ // its 300px inspector). The shell is viewport-height; every column scrolls
11
+ // inside itself.
7
12
  import { useEffect, useState } from "react";
8
13
  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";
14
+ import { ListView } from "./ListView.js";
15
+ import { PagesList } from "./PagesList.js";
12
16
  import { EntryForm } from "./EntryForm.js";
13
17
  import { EditorView } from "../editor/EditorView.js";
14
18
  import { LoginView } from "./LoginView.js";
15
19
  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
- }) }));
20
+ import { Button, Logo, StatusBadge, ThemeToggle } from "../ui/primitives.js";
21
+ import { relativeTime } from "../ui/format.js";
22
+ import { BASE } from "./base.js";
23
+ import { ErrorPane } from "./ErrorPane.js";
24
+ /** Nav items the registry can't produce yet: shown, disabled, so the shape
25
+ * of the product is visible the same set the design system's SideNav has. */
26
+ const PLANNED = ["Shared Content", "Media", "Settings"];
27
+ function Nav({ registry, active, email, onSignOut }) {
28
+ const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
29
+ const name = email?.split("@")[0] ?? "Editor";
30
+ const initials = name.slice(0, 2).toUpperCase();
31
+ return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
76
32
  }
77
33
  function EntriesList({ ops, registry, collection }) {
78
34
  const meta = registry.collections.find((c) => c.name === collection);
@@ -84,17 +40,21 @@ function EntriesList({ ops, registry, collection }) {
84
40
  .catch(() => setError("Couldn't reach the server."));
85
41
  }, [ops, collection]);
86
42
  if (!meta)
87
- return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
43
+ return _jsx(ErrorPane, { message: "No such collection." });
88
44
  if (error)
89
- return _jsx("p", { style: { padding: 32, color: "#b91c1c" }, children: error });
45
+ return _jsx(ErrorPane, { message: error });
90
46
  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) => ({
47
+ return (_jsx(ListView, { title: meta.title, count: rows?.length, actions: _jsx(Button, { onClick: () => { window.location.href = `${BASE}/${collection}/new`; }, children: "+ New entry" }), columns: [
48
+ { label: "Title", width: "minmax(160px,1fr)" },
49
+ { label: "Updated", width: "minmax(110px,160px)" },
50
+ { label: "Status", width: "120px" },
51
+ ], empty: rows ? "Nothing here yet." : "Loading…", rows: (rows ?? []).map((e) => ({
92
52
  key: e.id,
93
53
  href: `${BASE}/${collection}/${encodeURIComponent(e.id)}`,
94
54
  cells: [
95
- _jsx("strong", { children: title(e) }, "t"),
96
- _jsx(StatusChip, { status: e.status }, "c"),
97
- new Date(e.updatedAt).toLocaleString(),
55
+ _jsx("span", { className: "sm-cell sm-cell--title", children: title(e) }, "t"),
56
+ _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(e.updatedAt) }, "u"),
57
+ _jsx(StatusBadge, { status: e.status }, "c"),
98
58
  ],
99
59
  })) }));
100
60
  }
@@ -103,20 +63,22 @@ export function AdminApp({ registry, segments, op, auth }) {
103
63
  const [ops] = useState(() => makeClientOps(op, session.token));
104
64
  const [head = "pages", tail] = segments.length ? segments : ["pages"];
105
65
  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" });
66
+ const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
67
+ if (session.state.status === "loading") {
68
+ return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
69
+ }
108
70
  if (session.state.status === "signed_out")
109
71
  return _jsx(LoginView, { client: session.client, providers: auth.providers });
110
72
  let main;
111
73
  if (head === "pages" && !tail)
112
74
  main = _jsx(PagesList, { ops: ops, registry: registry });
113
75
  else if (head === "pages" && tail)
114
- main = _jsx(EditorView, { ops: ops, registry: registry, slug: decodeURIComponent(tail) });
76
+ main = _jsx(EditorView, { ops: ops, registry: registry, id: decodeURIComponent(tail) });
115
77
  else if (isCollection && !tail)
116
78
  main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
117
79
  else if (isCollection && tail)
118
80
  main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
119
81
  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 })] }));
82
+ main = _jsx(ErrorPane, { message: "Not found." });
83
+ return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
122
84
  }
@@ -1,17 +1,24 @@
1
1
  // packages/smoodly/src/admin/shell/EntryForm.tsx
2
2
  "use client";
3
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
4
4
  // The classic field view: same FieldWidget renderer as the editor sidebar,
5
5
  // no tabs (collections are fields-only by design). The safe-delete guard's
6
6
  // message renders verbatim — it is product surface, not an error detail.
7
+ //
8
+ // Layout follows the design system's article-edit screen: top bar with the
9
+ // breadcrumb and the save action, a roomy field column, and a 300px sidebar
10
+ // holding status and document info.
7
11
  import { useEffect, useMemo, useState } from "react";
8
12
  import { FieldWidget } from "../forms/FieldWidget.js";
9
- import { StatusChip } from "./ListView.js";
13
+ import { relativeTime } from "../ui/format.js";
14
+ import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar, } from "../ui/primitives.js";
15
+ const STATUSES = ["Draft", "Published"];
10
16
  export function EntryForm({ ops, registry, collection, id }) {
11
17
  const meta = registry.collections.find((c) => c.name === collection);
12
18
  const isNew = id === "new";
13
19
  const [fields, setFields] = useState({});
14
20
  const [status, setStatus] = useState("draft");
21
+ const [stamps, setStamps] = useState(null);
15
22
  const [refOptions, setRefOptions] = useState({});
16
23
  const [banner, setBanner] = useState(null);
17
24
  const [fieldErrors, setFieldErrors] = useState({});
@@ -31,6 +38,7 @@ export function EntryForm({ ops, registry, collection, id }) {
31
38
  setFields(r.data.fields);
32
39
  setStatus(r.data.status);
33
40
  setLoaded(true);
41
+ setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
34
42
  }
35
43
  else
36
44
  setBanner({ tone: "error", text: r.message });
@@ -70,11 +78,14 @@ export function EntryForm({ ops, registry, collection, id }) {
70
78
  }
71
79
  }
72
80
  else {
73
- applyResult(await ops.entries.save(collection, id, fields), "Saved.");
81
+ const r = await ops.entries.save(collection, id, fields);
82
+ if (applyResult(r, "Saved.") && r.ok)
83
+ setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
74
84
  }
75
85
  };
76
- const flipStatus = async () => {
77
- const next = status === "published" ? "draft" : "published";
86
+ const setPublished = async (next) => {
87
+ if (next === status)
88
+ return;
78
89
  const r = await ops.entries.setStatus(collection, id, next);
79
90
  if (applyResult(r, next === "published" ? "Published." : "Unpublished."))
80
91
  setStatus(next);
@@ -88,9 +99,22 @@ export function EntryForm({ ops, registry, collection, id }) {
88
99
  else
89
100
  setBanner({ tone: "error", text: r.message });
90
101
  };
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" })] })] }));
102
+ const titleValue = meta.titleField ? fields[meta.titleField] : undefined;
103
+ const heading = isNew ? "New entry" : (typeof titleValue === "string" && titleValue) || id;
104
+ // The mount's path in the default locale prefixes the slug (spec §7);
105
+ // a collection without a path has no URLs and shows no prefix.
106
+ const base = meta.path?.[registry.locales.default];
107
+ const slugPrefix = base ? `/${base}/` : undefined;
108
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `/admin/${collection}`, items: [{ label: meta.title, href: `/admin/${collection}` }, { label: heading }] }), right: _jsx(Button, { onClick: save, disabled: !loaded, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), loaded
109
+ ? 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, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => setFields((f) => ({ ...f, [key]: value })) }, key)))
110
+ : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
111
+ display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
112
+ minHeight: 0, overflow: "auto", borderLeft: "var(--border)",
113
+ }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Status" }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: isNew || !loaded, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } }), isNew && _jsx("span", { className: "sm-hint", children: "Create the entry first \u2014 it starts as a draft." })] }), !isNew && stamps && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(stamps.createdAt) }), _jsx(InfoRow, { label: "Updated", value: relativeTime(stamps.updatedAt) }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })] })), !isNew && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsx(Button, { variant: "danger", disabled: !loaded, onClick: destroy, children: "Delete" })] }))] })] })] }));
114
+ }
115
+ function InfoRow({ label, value, mono }) {
116
+ return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
117
+ color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
118
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
119
+ }, children: value })] }));
96
120
  }
@@ -0,0 +1,4 @@
1
+ /** A whole-column failure: nothing to show but the message and a way back. */
2
+ export declare function ErrorPane({ message }: {
3
+ message: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ // packages/smoodly/src/admin/shell/ErrorPane.tsx
2
+ "use client";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ import { Banner } from "../ui/primitives.js";
5
+ import { BASE } from "./base.js";
6
+ /** A whole-column failure: nothing to show but the message and a way back. */
7
+ export function ErrorPane({ message }) {
8
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40 }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(Banner, { tone: "error", children: message }), _jsx("a", { href: `${BASE}/pages`, style: { fontSize: "var(--text-md)" }, children: "\u2190 Back to pages" })] }) }));
9
+ }
@@ -1,15 +1,18 @@
1
1
  import type { ReactNode } from "react";
2
- export declare function ListView({ title, actions, columns, rows }: {
2
+ export type ListColumn = {
3
+ label: string;
4
+ width: string;
5
+ };
6
+ export declare function ListView({ title, count, actions, columns, rows, empty }: {
3
7
  title: string;
8
+ count?: number;
4
9
  actions?: ReactNode;
5
- columns: string[];
10
+ columns: ListColumn[];
6
11
  rows: {
7
12
  key: string;
8
13
  href?: string;
9
14
  onClick?: () => void;
10
15
  cells: ReactNode[];
11
16
  }[];
12
- }): import("react").JSX.Element;
13
- export declare function StatusChip({ status }: {
14
- status: string;
17
+ empty?: ReactNode;
15
18
  }): import("react").JSX.Element;
@@ -1,14 +1,15 @@
1
1
  // packages/smoodly/src/admin/shell/ListView.tsx
2
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 }));
3
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
+ import { TopBar } from "../ui/primitives.js";
5
+ export function ListView({ title, count, actions, columns, rows, empty }) {
6
+ const grid = { gridTemplateColumns: columns.map((c) => c.width).join(" ") };
7
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), count !== undefined && _jsx("span", { className: "sm-count", children: count })] }), right: actions }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: "auto" }, children: _jsxs("div", { className: "sm-table", children: [_jsx("div", { className: "sm-table__head", style: grid, children: columns.map((c) => _jsx("span", { children: c.label }, c.label)) }), _jsxs("div", { className: "sm-table__body", children: [rows.map((r) => {
8
+ const clickable = Boolean(r.href || r.onClick);
9
+ const go = r.onClick ?? (r.href ? () => { window.location.href = r.href; } : undefined);
10
+ return (_jsx("div", { style: grid, role: clickable ? "button" : undefined, tabIndex: clickable ? 0 : undefined, className: clickable ? "sm-row sm-row--clickable" : "sm-row", onClick: go, onKeyDown: (e) => { if (go && (e.key === "Enter" || e.key === " ")) {
11
+ e.preventDefault();
12
+ go();
13
+ } }, children: r.cells.map((cell, i) => _jsx("span", { className: "sm-cell", children: cell }, i)) }, r.key));
14
+ }), rows.length === 0 && _jsx("div", { className: "sm-empty", children: empty ?? "Nothing here yet." })] })] }) })] }));
14
15
  }
@@ -5,6 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // Supabase Auth verbatim. Being an editor is checked by the server on
6
6
  // the first op — a signed-in non-editor sees that message in the shell.
7
7
  import { useState } from "react";
8
+ import { Banner, Button, Field, Logo } from "../ui/primitives.js";
8
9
  const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
9
10
  export function LoginView({ client, providers = [] }) {
10
11
  const [email, setEmail] = useState("");
@@ -42,6 +43,9 @@ export function LoginView({ client, providers = [] }) {
42
43
  setBusy(false);
43
44
  }
44
45
  };
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)))] }) }));
46
+ return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
47
+ width: 320, display: "flex", flexDirection: "column", gap: 14,
48
+ background: "var(--surface)", padding: 24, borderRadius: "var(--radius-md)",
49
+ border: "var(--border)", boxShadow: "var(--shadow-page)",
50
+ }, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p)))] }) }));
47
51
  }
@@ -0,0 +1,6 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function PagesList({ ops, registry }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,148 @@
1
+ // packages/smoodly/src/admin/shell/PagesList.tsx
2
+ "use client";
3
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
+ // The Pages list: the tree of the design system's TreeTable, plus the
5
+ // row menu (New subpage/subfolder, Add page here, Rename, Move, Delete)
6
+ // as a floating popover positioned from the triggering click.
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { ListView } from "./ListView.js";
9
+ import { openTemplates, pagesTreeRows } from "./pages-tree.js";
10
+ import { Button, StatusBadge } from "../ui/primitives.js";
11
+ import { BASE } from "./base.js";
12
+ import { ErrorPane } from "./ErrorPane.js";
13
+ export function PagesList({ ops, registry }) {
14
+ const [pages, setPages] = useState(null);
15
+ const [error, setError] = useState(null);
16
+ const [collapsed, setCollapsed] = useState(() => new Set());
17
+ const [menu, setMenu] = useState(null);
18
+ const locale = registry.locales.default;
19
+ const load = useCallback(() => {
20
+ ops.pages.list()
21
+ .then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
22
+ .catch(() => setError("Couldn't reach the server."));
23
+ }, [ops]);
24
+ useEffect(load, [load]);
25
+ const rows = pages ? pagesTreeRows(pages, registry, locale, collapsed) : [];
26
+ const creatable = openTemplates(registry.pages);
27
+ const noOpen = creatable.length === 0;
28
+ const toggle = (id) => setCollapsed((c) => { const next = new Set(c); if (next.has(id))
29
+ next.delete(id);
30
+ else
31
+ next.add(id); return next; });
32
+ const openEditor = (id) => { window.location.href = `${BASE}/pages/${encodeURIComponent(id)}`; };
33
+ /** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
34
+ const run = async (work, then) => {
35
+ let result;
36
+ try {
37
+ result = await work();
38
+ }
39
+ catch {
40
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
41
+ }
42
+ if (!result.ok) {
43
+ window.alert(result.message);
44
+ return;
45
+ }
46
+ then?.(result.data);
47
+ load();
48
+ };
49
+ const pickTemplate = () => {
50
+ if (noOpen)
51
+ return null;
52
+ if (creatable.length === 1)
53
+ return creatable[0].name;
54
+ return window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) || null;
55
+ };
56
+ const newNode = (parentId, kind) => {
57
+ if (kind === "page" && noOpen)
58
+ return;
59
+ const slug = window.prompt(`Slug for the new ${kind} (e.g. about):`);
60
+ if (!slug)
61
+ return;
62
+ const template = kind === "page" ? pickTemplate() : null;
63
+ if (kind === "page" && !template)
64
+ return;
65
+ void run(() => ops.pages.create({ parentId, slug, template }), (page) => { if (template)
66
+ openEditor(page.id); });
67
+ };
68
+ const addPageHere = (row) => {
69
+ const template = pickTemplate();
70
+ if (!template)
71
+ return;
72
+ void run(() => ops.pages.setTemplate(row.id, template), (page) => openEditor(page.id));
73
+ };
74
+ const rename = (row) => {
75
+ const title = window.prompt("Title:", row.record.title);
76
+ if (title === null)
77
+ return;
78
+ // The site root's slug is the config's homePageSlug — title only here.
79
+ if (row.isHome) {
80
+ void run(() => ops.pages.rename(row.id, { locale, title }));
81
+ return;
82
+ }
83
+ const slug = window.prompt("Slug (the URL segment):", row.record.slug);
84
+ if (slug === null)
85
+ return;
86
+ void run(() => ops.pages.rename(row.id, { locale, title, slug }));
87
+ };
88
+ const move = (row, dir) => void run(() => ops.pages.move(row.id, { parentId: row.parentId, index: row.index + dir }));
89
+ const destroy = (row) => {
90
+ if (!window.confirm(`Delete "${row.title}"?`))
91
+ return;
92
+ void run(() => ops.pages.delete(row.id));
93
+ };
94
+ const menuItems = (row) => {
95
+ const items = [];
96
+ if (row.kind === "mount" && row.template !== null)
97
+ items.push({ label: "Edit index page", run: () => openEditor(row.id) });
98
+ if (row.canHaveChildren && !noOpen)
99
+ items.push({ label: "New subpage", run: () => newNode(row.id, "page") });
100
+ if (row.canHaveChildren)
101
+ items.push({ label: "New subfolder", run: () => newNode(row.id, "folder") });
102
+ if (row.kind === "folder" && !noOpen)
103
+ items.push({ label: "Add page here", run: () => addPageHere(row) });
104
+ if (!row.fixed)
105
+ items.push({ label: "Rename…", run: () => rename(row) });
106
+ if (!row.fixed && !row.isHome && row.index > 0)
107
+ items.push({ label: "Move up", run: () => move(row, -1) });
108
+ if (!row.fixed && !row.isHome && row.index < row.siblingCount - 1)
109
+ items.push({ label: "Move down", run: () => move(row, 1) });
110
+ if (!row.fixed && !row.isHome && !row.hasChildren)
111
+ items.push({ label: "Delete", run: () => destroy(row) });
112
+ return items;
113
+ };
114
+ const onRow = (row) => {
115
+ if (row.kind === "mount" && row.collection)
116
+ window.location.href = `${BASE}/${row.collection}`;
117
+ else if (row.template !== null)
118
+ openEditor(row.id);
119
+ else if (row.hasChildren)
120
+ toggle(row.id);
121
+ };
122
+ if (error)
123
+ return _jsx(ErrorPane, { message: error });
124
+ const items = menu ? menuItems(menu.row) : [];
125
+ return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", count: pages?.length, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New folder" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
126
+ { label: "Title", width: "minmax(160px,1fr)" },
127
+ { label: "Path", width: "minmax(140px,260px)" },
128
+ { label: "Template", width: "minmax(100px,160px)" },
129
+ { label: "Status", width: "110px" },
130
+ { label: "", width: "32px" },
131
+ ], empty: pages ? "No pages yet." : "Loading…", rows: rows.map((row) => ({
132
+ key: row.id,
133
+ onClick: () => onRow(row),
134
+ cells: [
135
+ _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: row.depth * 20 }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
136
+ _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p"),
137
+ _jsx("span", { className: "sm-cell sm-cell--muted", children: row.templateTitle }, "k"),
138
+ row.status
139
+ ? _jsx(StatusBadge, { status: row.status }, "s")
140
+ : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
141
+ _jsx("button", { type: "button", className: "sm-rowmenu", "aria-label": "Row menu", onClick: (e) => { e.stopPropagation(); setMenu({ row, x: e.clientX, y: e.clientY }); }, onKeyDown: (e) => e.stopPropagation(), children: "\u00B7\u00B7\u00B7" }, "m"),
142
+ ],
143
+ })) }), menu && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setMenu(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
144
+ position: "absolute",
145
+ left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
146
+ top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
147
+ }, children: [items.map((item) => (_jsx("button", { type: "button", className: "sm-picker__item", onClick: () => { setMenu(null); item.run(); }, children: item.label }, item.label))), items.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "Owned by code \u2014 nothing to change here." }))] }) }))] }));
148
+ }
@@ -0,0 +1 @@
1
+ export declare const BASE = "/admin";
@@ -0,0 +1,4 @@
1
+ // packages/smoodly/src/admin/shell/base.ts
2
+ // The admin's mount path — shared by every shell view and route link so
3
+ // there is exactly one place that knows it.
4
+ export const BASE = "/admin";
@@ -0,0 +1,39 @@
1
+ import type { PageRecord } from "../../store.ts";
2
+ import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
3
+ /** Registrations editors may create pages against: fixed ones are
4
+ * materialized by Smoodly, never offered. */
5
+ export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
6
+ /** The collection a ROOT record is the mount of, if any — known from the
7
+ * registry, never from a column (spec 2026-09-05 §4). */
8
+ export declare function mountOf(record: {
9
+ parentId: string | null;
10
+ slug: string;
11
+ }, registry: AdminRegistry): AdminCollection | null;
12
+ /** Fixed pages and mounts: never renamed, moved or deleted by editors. */
13
+ export declare function isFixedNode(record: {
14
+ parentId: string | null;
15
+ slug: string;
16
+ }, registry: AdminRegistry): boolean;
17
+ export type PagesTreeRow = {
18
+ id: string;
19
+ parentId: string | null;
20
+ depth: number;
21
+ /** Position among the siblings, and how many there are — for Move up/down. */
22
+ index: number;
23
+ siblingCount: number;
24
+ hasChildren: boolean;
25
+ expanded: boolean;
26
+ title: string;
27
+ path: string;
28
+ kind: "page" | "folder" | "mount";
29
+ collection: string | null;
30
+ fixed: boolean;
31
+ /** The record serving "/" — never moved or deleted, and its default-locale slug is the config's. */
32
+ isHome: boolean;
33
+ template: string | null;
34
+ templateTitle: string;
35
+ status: "published" | "draft edits" | "draft" | null;
36
+ canHaveChildren: boolean;
37
+ record: PageRecord;
38
+ };
39
+ export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegistry, locale: string, collapsed: ReadonlySet<string>): PagesTreeRow[];
@@ -0,0 +1,65 @@
1
+ // Pure view-model for the Pages list — the tree of the design system's
2
+ // TreeTable (indent per depth, chevron on parents, the derived path as
3
+ // the slug column). The shell is not DOM-tested; every decision that
4
+ // matters lives here.
5
+ import { titleFor } from "../../localize.js";
6
+ import { buildPageTree } from "../../paths.js";
7
+ /** Registrations editors may create pages against: fixed ones are
8
+ * materialized by Smoodly, never offered. */
9
+ export function openTemplates(templates) {
10
+ return templates.filter((t) => t.slug === undefined);
11
+ }
12
+ /** The collection a ROOT record is the mount of, if any — known from the
13
+ * registry, never from a column (spec 2026-09-05 §4). */
14
+ export function mountOf(record, registry) {
15
+ if (record.parentId !== null)
16
+ return null;
17
+ return registry.collections.find((c) => c.path?.[registry.locales.default] === record.slug) ?? null;
18
+ }
19
+ /** Fixed pages and mounts: never renamed, moved or deleted by editors. */
20
+ export function isFixedNode(record, registry) {
21
+ return record.parentId === null && (registry.pages.some((t) => t.slug === record.slug) || mountOf(record, registry) !== null);
22
+ }
23
+ export function pagesTreeRows(records, registry, locale, collapsed) {
24
+ const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
25
+ const templateTitle = (name) => registry.pages.find((t) => t.name === name)?.title ?? name;
26
+ const rows = [];
27
+ const walk = (nodes, depth, parentId) => {
28
+ nodes.forEach((node, index) => {
29
+ const r = node.record;
30
+ const mount = mountOf(r, registry);
31
+ const isHome = r.parentId === null && r.slug === registry.homePageSlug;
32
+ const expanded = !collapsed.has(r.id);
33
+ rows.push({
34
+ id: r.id,
35
+ parentId,
36
+ depth,
37
+ index,
38
+ siblingCount: nodes.length,
39
+ hasChildren: node.children.length > 0,
40
+ expanded,
41
+ title: titleFor(r, locale, registry.locales),
42
+ path: node.path,
43
+ kind: mount ? "mount" : r.template === null ? "folder" : "page",
44
+ collection: mount?.name ?? null,
45
+ fixed: isFixedNode(r, registry),
46
+ isHome,
47
+ template: r.template,
48
+ templateTitle: mount
49
+ ? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
50
+ : r.template === null ? "Folder" : templateTitle(r.template),
51
+ status: r.template === null
52
+ ? null
53
+ : r.publishedVersionId
54
+ ? r.publishedVersionId === r.draftVersionId ? "published" : "draft edits"
55
+ : "draft",
56
+ canHaveChildren: !mount && !isHome && depth + 1 < registry.pageDepth,
57
+ record: r,
58
+ });
59
+ if (expanded)
60
+ walk(node.children, depth + 1, r.id);
61
+ });
62
+ };
63
+ walk(buildPageTree(records, locale, opts), 0, null);
64
+ return rows;
65
+ }