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
@@ -1,33 +1,39 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- // The Smoodly editor: dark instrument around a bright canvas.
3
+ // The Smoodly editor: a calm instrument around a bright canvas.
4
4
  //
5
5
  // The canvas is an iframe of the SITE's own route in draft mode (pixel-
6
6
  // identical by construction, DESIGN.md §2), sized to its full document
7
- // height so the admin page scrolls, not the iframe — which is what lets
7
+ // height so the canvas COLUMN scrolls, not the iframe — which is what lets
8
8
  // the geometry the bridge reports map 1:1 onto the overlay layer above
9
9
  // it. Every gesture mutates client tree state through Task 4's pure ops;
10
10
  // a debounced saveDraft persists, and the bridge refreshes the iframe on
11
11
  // the save ack (refresh-on-change preview, DESIGN.md §3).
12
12
  //
13
+ // Chrome follows the design system's visual-editor screen: top bar, toned
14
+ // canvas around a white page, 300px inspector holding fields then outline.
15
+ //
13
16
  // Core code: no next/* imports live here — the bridge is the only piece
14
17
  // that knows about Next, and it ships to the site, not the admin.
15
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
- import { pagePath } from "../page-path.js";
17
19
  import { FieldWidget } from "../forms/FieldWidget.js";
18
20
  import { tabsFor } from "../forms/tabs.js";
19
21
  import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
20
22
  import { useDraftAutosave } from "./autosave.js";
21
- const ACCENT = "#7c5cff";
22
- const mono = "'Geist Mono', ui-monospace, monospace";
23
- const ui = "Geist, system-ui, sans-serif";
24
- const SIDEBAR = 344;
23
+ import { PageSettings } from "./PageSettings.js";
24
+ import { isFixedNode } from "../shell/pages-tree.js";
25
+ import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, Segmented, Tabs, TopBar, cx, } from "../ui/primitives.js";
26
+ export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
25
27
  const GEOMETRY_TIMEOUT_MS = 4000;
26
- export function EditorView({ ops, registry, slug }) {
28
+ export function EditorView({ ops, registry, id }) {
27
29
  const [record, setRecord] = useState(null);
28
30
  const [tree, setTree] = useState(null);
29
31
  const [loadError, setLoadError] = useState(null);
30
- const [iframeSrc, setIframeSrc] = useState(null);
32
+ const [ready, setReady] = useState(false);
33
+ const [folder, setFolder] = useState(false);
34
+ const [locale, setLocale] = useState(registry.locales.default);
35
+ const [paths, setPaths] = useState({});
36
+ const [urls, setUrls] = useState({});
31
37
  const [rects, setRects] = useState([]);
32
38
  const [emptyZones, setEmptyZones] = useState([]);
33
39
  const [docH, setDocH] = useState(900);
@@ -39,7 +45,9 @@ export function EditorView({ ops, registry, slug }) {
39
45
  const [banner, setBanner] = useState(null);
40
46
  const [published, setPublished] = useState(false);
41
47
  const [refOptions, setRefOptions] = useState({});
48
+ const [hoverPlus, setHoverPlus] = useState(null);
42
49
  const iframeRef = useRef(null);
50
+ const scrollerRef = useRef(null);
43
51
  const treeRef = useRef(null);
44
52
  const registryRef = useRef(registry);
45
53
  registryRef.current = registry;
@@ -47,13 +55,16 @@ export function EditorView({ ops, registry, slug }) {
47
55
  // retry on failure, and the bridge refreshes the iframe on the save ack.
48
56
  const { saveState, mark, flush } = useDraftAutosave({
49
57
  read: () => treeRef.current,
50
- persist: (t) => ops.pages.saveDraft(slug, t),
58
+ persist: (t) => ops.pages.saveDraft(id, t),
51
59
  onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
52
60
  });
53
- // The URL that serves this page the config's homePageSlug lives at the
54
- // root, everything else at /<slug>. Shown as the label too.
55
- const path = pagePath(slug, registry.homePageSlug);
61
+ // The URL that serves this page, per locale, from the store's paths
62
+ // index the label is the CMS path, the canvas and "View live" take
63
+ // the site URL (href applied server-side by pages.get).
64
+ const path = paths[locale] ?? paths[registry.locales.default] ?? "/";
56
65
  const label = path;
66
+ const siteUrl = urls[locale] ?? urls[registry.locales.default] ?? path;
67
+ const iframeSrc = ready ? withEditorParam(siteUrl) : null;
57
68
  // ── load: draft mode FIRST, then the record; only then point the iframe
58
69
  // at the site route (an iframe loaded before the cookie exists would
59
70
  // render the published tree).
@@ -81,7 +92,7 @@ export function EditorView({ ops, registry, slug }) {
81
92
  }
82
93
  let result;
83
94
  try {
84
- result = await ops.pages.get(slug);
95
+ result = await ops.pages.get(id);
85
96
  }
86
97
  catch {
87
98
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -92,6 +103,11 @@ export function EditorView({ ops, registry, slug }) {
92
103
  setLoadError(result.message);
93
104
  return;
94
105
  }
106
+ // A folder has no draft tree — it gets the notice pane, not a canvas.
107
+ if (result.data.draft === null) {
108
+ setFolder(true);
109
+ return;
110
+ }
95
111
  // Locked zones can't be filled by any gesture, so fill the empty
96
112
  // ones here; a change marks the draft dirty so the debounced save
97
113
  // persists it and the canvas shows the same tree as the outline.
@@ -103,15 +119,44 @@ export function EditorView({ ops, registry, slug }) {
103
119
  const template = reg.pages.find((p) => p.name === result.data.record.template);
104
120
  const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
105
121
  setRecord(result.data.record);
122
+ setPaths(result.data.paths);
123
+ setUrls(result.data.urls);
106
124
  setTree(draft);
107
125
  treeRef.current = draft;
108
126
  if (draft !== result.data.draft)
109
127
  mark();
110
- setIframeSrc(`${pagePath(slug, registry.homePageSlug)}?smoodly-editor=1`);
128
+ setReady(true);
111
129
  })();
112
130
  return () => { cancelled = true; };
113
- }, [ops, slug]);
131
+ }, [ops, id]);
132
+ // A new canvas URL (a locale switch, a rename) means new geometry — the
133
+ // previous page's rects are stale the instant the iframe starts loading
134
+ // a different route, so clear them alongside resetting geometry.
135
+ useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
114
136
  useEffect(() => { treeRef.current = tree; }, [tree]);
137
+ // After a rename only the record and the URL indexes are stale — never
138
+ // re-read the tree here: the draft in memory may be ahead of the store.
139
+ const reload = useCallback(async () => {
140
+ const result = await ops.pages.get(id).catch(() => null);
141
+ if (!result || !result.ok)
142
+ return;
143
+ setRecord(result.data.record);
144
+ setPaths(result.data.paths);
145
+ setUrls(result.data.urls);
146
+ }, [ops, id]);
147
+ const onRename = async (renameLocale, patch) => {
148
+ let result;
149
+ try {
150
+ result = await ops.pages.rename(id, { locale: renameLocale, ...patch });
151
+ }
152
+ catch {
153
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
154
+ }
155
+ if (!result.ok)
156
+ return result.message;
157
+ await reload();
158
+ return null;
159
+ };
115
160
  const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
116
161
  const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
117
162
  const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
@@ -186,11 +231,15 @@ export function EditorView({ ops, registry, slug }) {
186
231
  const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
187
232
  return () => clearTimeout(t);
188
233
  }, [iframeSrc, geometry]);
234
+ // Selecting from the outline must reveal the block. The canvas column is
235
+ // the scroller now (the iframe has no scrollbar of its own — it is sized to
236
+ // the full document), so scroll it to the rect the bridge reported.
237
+ const revealRef = useRef(() => { });
189
238
  const select = useCallback((id) => {
190
239
  setSelectedId(id);
191
240
  if (id)
192
- postToCanvas({ source: "smoodly", kind: "scroll", id });
193
- }, [postToCanvas]);
241
+ revealRef.current(id);
242
+ }, []);
194
243
  // ── persistence ──────────────────────────────────────────────────────
195
244
  const mutate = useCallback((fn) => {
196
245
  setTree((t) => {
@@ -253,7 +302,7 @@ export function EditorView({ ops, registry, slug }) {
253
302
  }
254
303
  let result;
255
304
  try {
256
- result = await ops.pages.publish(slug);
305
+ result = await ops.pages.publish(id);
257
306
  }
258
307
  catch (e) {
259
308
  console.error(e);
@@ -282,7 +331,10 @@ export function EditorView({ ops, registry, slug }) {
282
331
  return () => window.removeEventListener("keydown", onKey);
283
332
  }, []);
284
333
  if (loadError) {
285
- return (_jsx("div", { style: { ...shell, display: "grid", placeItems: "center", padding: 40 }, children: _jsxs("div", { style: { textAlign: "center" }, children: [_jsx("p", { style: { fontSize: 15, marginBottom: 14 }, children: loadError }), _jsx("a", { href: "/admin/pages", style: { color: ACCENT, fontSize: 14 }, children: "\u2190 Back to pages" })] }) }));
334
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(BannerBox, { tone: "error", children: loadError }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
335
+ }
336
+ if (folder) {
337
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(BannerBox, { tone: "warn", children: "This is a folder \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
286
338
  }
287
339
  const selected = tree && selectedId ? findNode(tree, selectedId) : null;
288
340
  const selectedSection = selected ? sectionOf(selected.type) : null;
@@ -292,6 +344,16 @@ export function EditorView({ ops, registry, slug }) {
292
344
  const selRect = selected ? rectById.get(selected.id) : undefined;
293
345
  const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
294
346
  const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
347
+ // Reveal is defined here, where the rect map exists; select() calls it
348
+ // through the ref so its own identity stays stable.
349
+ revealRef.current = (id) => {
350
+ const rect = rectById.get(id);
351
+ const scroller = scrollerRef.current;
352
+ if (!rect || !scroller)
353
+ return;
354
+ const top = rect.y - Math.max(0, (scroller.clientHeight - rect.h) / 2);
355
+ scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
356
+ };
295
357
  // "+" affordances: one per legal boundary in every insertable zone.
296
358
  const plusButtons = [];
297
359
  for (const zone of zoneOrder) {
@@ -320,50 +382,57 @@ export function EditorView({ ops, registry, slug }) {
320
382
  const tabs = selectedSection ? tabsFor(selectedSection) : [];
321
383
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
322
384
  const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
323
- const saveLabel = saveState === "saving" ? "Saving…" : saveState === "error" ? "Save failed retrying" : "Saved";
324
- return (_jsxs("div", { style: shell, children: [_jsxs("header", { style: {
325
- position: "sticky", top: 0, zIndex: 50, display: "flex", alignItems: "center",
326
- justifyContent: "space-between", padding: "0 20px", height: 56,
327
- background: "rgba(14,12,20,0.85)", backdropFilter: "blur(12px)",
328
- borderBottom: "1px solid rgba(255,255,255,0.07)",
329
- }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 16 }, children: [_jsxs("a", { href: "/admin/pages", title: "Back to pages", style: { color: "inherit", textDecoration: "none", fontWeight: 600, fontSize: 16, letterSpacing: "-0.02em" }, children: ["\u2190 smoodly", _jsx("span", { style: { color: ACCENT }, children: "." })] }), _jsx("span", { style: { fontFamily: mono, fontSize: 12, opacity: 0.55 }, children: label })] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { style: {
330
- fontFamily: mono, fontSize: 11, letterSpacing: "0.1em",
331
- color: saveState === "error" ? "#ffb4b4" : "#bdb3e6", opacity: saveState === "error" ? 1 : 0.7,
332
- }, children: saveLabel }), _jsx("a", { href: path, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", style: { ...btn({ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", color: "#e8e4f4" }), textDecoration: "none" }, children: "View live" }), _jsx("button", { onClick: () => { void doPublish(); }, style: btn({ background: published ? "#22c55e" : ACCENT, color: "#fff", fontWeight: 600 }), children: published ? "Published ✓" : "Publish" })] })] }), banner && (_jsxs("div", { style: {
333
- // Right margin clears the fixed sidebar — the dismiss control
334
- // must never sit underneath it.
335
- margin: `14px ${SIDEBAR}px 0 20px`, padding: "12px 16px", borderRadius: 10,
336
- background: "rgba(239,68,68,0.14)", border: "1px solid rgba(239,68,68,0.35)",
337
- color: "#ffd9d9", fontSize: 13.5, display: "flex", gap: 14, alignItems: "flex-start",
338
- }, children: [_jsxs("div", { style: { flex: 1 }, children: [_jsx("p", { style: { margin: 0 }, children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { style: { margin: "8px 0 0", paddingLeft: 18, fontFamily: mono, fontSize: 12 }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] }), _jsx("button", { onClick: () => setBanner(null), "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", fontSize: 15 }, children: "\u2715" })] })), _jsxs("main", { style: { maxWidth: 1080, margin: "0 auto", padding: "24px 24px 80px", marginRight: SIDEBAR, transition: "margin 0.2s" }, children: [_jsxs("div", { style: { display: "flex", justifyContent: "space-between", fontFamily: mono, fontSize: 12, letterSpacing: "0.12em", opacity: 0.5, padding: "0 4px 12px" }, children: [_jsxs("span", { children: [label, " \u00B7 ", registry.locales.default] }), _jsxs("span", { children: [blockCount, " BLOCKS"] })] }), geometry === "missing" && (_jsx("p", { style: {
339
- fontFamily: mono, fontSize: 11.5, letterSpacing: "0.06em", margin: "0 0 10px",
340
- padding: "8px 12px", borderRadius: 8, background: "rgba(255,196,84,0.12)",
341
- border: "1px solid rgba(255,196,84,0.3)", color: "#ffd9a0",
342
- }, children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." })), _jsxs("div", { style: { position: "relative", borderRadius: 16, overflow: "hidden", boxShadow: "0 24px 80px rgba(0,0,0,0.5)", background: "#fff", minHeight: 240 }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { style: { height: 240, display: "grid", placeItems: "center", color: "#8b84a3", fontFamily: mono, fontSize: 12 }, children: "LOADING CANVAS\u2026" })), _jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [hoverRect && (_jsx("div", { style: { position: "absolute", left: hoverRect.x, top: hoverRect.y, width: hoverRect.w, height: hoverRect.h, outline: `1.5px solid ${ACCENT}66`, outlineOffset: -1.5 } })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: { position: "absolute", left: selRect.x, top: selRect.y, width: selRect.w, height: selRect.h, outline: `2px solid ${ACCENT}`, outlineOffset: -2, borderRadius: 2 } }), _jsxs("span", { style: { position: "absolute", left: selRect.x + 10, top: selRect.y - 11, fontFamily: mono, fontSize: 10, letterSpacing: "0.14em", background: ACCENT, color: "#fff", borderRadius: 6, padding: "3px 8px", pointerEvents: "auto" }, children: [selectedSection.title.toUpperCase(), selLocked && " · LOCKED"] }), _jsx("div", { style: { position: "absolute", left: selRect.x + selRect.w - 10, top: selRect.y + 10, transform: "translateX(-100%)", display: "flex", gap: 2, background: "#17141f", borderRadius: 9, padding: 3, pointerEvents: "auto", boxShadow: "0 6px 24px rgba(0,0,0,0.4)" }, children: selLocked ? (_jsx("span", { style: { fontFamily: mono, fontSize: 10, color: "#bdb3e6", padding: "4px 8px" }, children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconBtn, { label: "\u2191", title: "Move up", onClick: () => doMove(selected.id, -1) }), _jsx(IconBtn, { label: "\u2193", title: "Move down", onClick: () => doMove(selected.id, 1) }), _jsx(IconBtn, { label: "\u29C9", title: "Duplicate", onClick: () => doDuplicate(selected) }), _jsx(IconBtn, { label: "\u2715", title: "Delete", onClick: () => doRemove(selected.id) })] })) })] })), plusButtons.map((p) => (_jsx("button", { title: `Add to ${p.zone}`, onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), style: {
343
- position: "absolute", left: "50%", top: p.y, transform: "translate(-50%,-50%)",
344
- width: 28, height: 28, borderRadius: "50%", background: ACCENT, color: "#fff",
345
- border: "2px solid #fff", fontSize: 16, lineHeight: 1, cursor: "pointer",
346
- pointerEvents: "auto", boxShadow: "0 2px 10px rgba(0,0,0,0.35)", opacity: 0.92,
347
- }, children: "+" }, `${p.zone}-${p.index}`)))] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { onClick: (e) => e.stopPropagation(), style: {
348
- position: "absolute", left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)), top: picker.y,
349
- width: 300, background: "#17141f", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14,
350
- padding: 8, boxShadow: "0 24px 80px rgba(0,0,0,0.6)",
351
- }, children: [_jsxs("p", { style: { fontFamily: mono, fontSize: 10, letterSpacing: "0.14em", opacity: 0.5, margin: "6px 10px 8px" }, children: ["ADD TO ", picker.zone.toUpperCase()] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { onClick: () => doInsert(picker.zone, picker.index, section), onMouseEnter: (e) => (e.currentTarget.style.background = "rgba(124,92,255,0.14)"), onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), style: { display: "flex", gap: 12, alignItems: "center", width: "100%", textAlign: "left", background: "transparent", border: 0, borderRadius: 10, padding: "10px 10px", cursor: "pointer", color: "#e8e4f4" }, children: [_jsx("span", { style: { width: 34, height: 34, borderRadius: 9, background: "rgba(124,92,255,0.18)", color: ACCENT, display: "grid", placeItems: "center", fontSize: 16 }, children: section.icon ?? "" }), _jsxs("span", { children: [_jsx("span", { style: { display: "block", fontSize: 14, fontWeight: 600 }, children: section.title }), section.description && _jsx("span", { style: { display: "block", fontSize: 12, opacity: 0.55 }, children: section.description })] })] }, section.name))), allowedSections(policyOf(picker.zone), registry.sections).length === 0 && (_jsx("p", { style: { fontSize: 13, opacity: 0.6, margin: 10 }, children: "This zone doesn't accept insertions." }))] }) })), _jsxs("aside", { style: {
352
- position: "fixed", top: 56, right: 0, bottom: 0, width: SIDEBAR - 24,
353
- background: "#141119", borderLeft: "1px solid rgba(255,255,255,0.07)",
354
- padding: 18, overflow: "auto", zIndex: 40, boxSizing: "border-box",
355
- }, children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }, children: [_jsxs("span", { style: { fontWeight: 600 }, children: [selectedSection.icon ?? "▤", " ", selectedSection.title] }), _jsx("button", { onClick: () => setSelectedId(null), "aria-label": "Clear selection", style: { background: "transparent", border: 0, color: "#8b84a3", cursor: "pointer", fontSize: 16 }, children: "\u2715" })] }), tabs.length > 0 && (_jsx("div", { style: { display: "flex", gap: 6, marginBottom: 16, flexWrap: "wrap" }, children: tabs.map((t) => (_jsx("button", { onClick: () => setTab(t.tab), style: btn(t.tab === activeTab
356
- ? { background: ACCENT, color: "#fff" }
357
- : { background: "rgba(255,255,255,0.06)", color: "#bdb3e6" }), children: t.tab }, t.tab))) })), entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`)))] })) : (_jsx("p", { style: { fontFamily: mono, fontSize: 11, letterSpacing: "0.1em", opacity: 0.45, margin: "2px 0 0" }, children: "NOTHING SELECTED \u2014 CLICK A SECTION" })), _jsx("div", { style: { borderTop: "1px solid rgba(255,255,255,0.08)", margin: "22px 0 14px" } }), _jsx("p", { style: { fontFamily: mono, fontSize: 10, letterSpacing: "0.14em", opacity: 0.45, margin: "0 0 10px" }, children: "OUTLINE" }), zoneOrder.map((zone) => (_jsxs("div", { style: { marginBottom: 14 }, children: [_jsxs("p", { style: { fontFamily: mono, fontSize: 10, letterSpacing: "0.12em", opacity: 0.35, margin: "0 0 6px" }, children: [zone.toUpperCase(), policyOf(zone)?.kind === "locked" && " · LOCKED"] }), nodesIn(zone).length === 0 && (_jsx("p", { style: { fontSize: 12, opacity: 0.35, margin: "0 0 4px" }, children: "empty" })), nodesIn(zone).map((node, i) => {
358
- const section = sectionOf(node.type);
359
- const isSelected = node.id === selectedId;
360
- const locked = policyOf(zone)?.kind === "locked";
361
- return (_jsxs("div", { onClick: () => select(node.id), style: {
362
- display: "flex", alignItems: "center", gap: 4, padding: "6px 8px", borderRadius: 8,
363
- marginBottom: 3, cursor: "pointer", fontSize: 13,
364
- background: isSelected ? "rgba(124,92,255,0.22)" : "rgba(255,255,255,0.04)",
365
- }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: section?.title ?? node.type }), !locked && (_jsxs(_Fragment, { children: [_jsx(IconBtn, { label: "\u2191", title: "Move up", disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); } }), _jsx(IconBtn, { label: "\u2193", title: "Move down", disabled: i === nodesIn(zone).length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); } }), _jsx(IconBtn, { label: "\u2715", title: "Delete", onClick: (e) => { e.stopPropagation(); doRemove(node.id); } })] }))] }, node.id));
366
- })] }, zone)))] })] }));
385
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: "/admin/pages", items: [{ label: "Pages", href: "/admin/pages" }, { label }] }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), registry.locales.supported.length > 1 ? (_jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: setLocale })) : (_jsxs("span", { children: ["\u00B7 ", locale] })), registry.locales.supported.length > 1 && paths[locale] === undefined && ready && (_jsxs("span", { children: ["not in this locale \u2014 showing ", registry.locales.default] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-mono", style: {
386
+ height: 240, display: "grid", placeItems: "center",
387
+ color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
388
+ }, children: "LOADING CANVAS\u2026" })), _jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed sm-mono", style: {
389
+ position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
390
+ height: Math.max(56, z.h), fontSize: "var(--text-xs)",
391
+ letterSpacing: ".04em", textTransform: "uppercase", color: "var(--gray-500)",
392
+ borderColor: "var(--gray-300)",
393
+ }, children: [z.zone, " \u00B7 empty"] }, `empty-${z.zone}`))), hoverRect && (_jsx("div", { style: {
394
+ position: "absolute", left: hoverRect.x, top: hoverRect.y,
395
+ width: hoverRect.w, height: hoverRect.h,
396
+ outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
397
+ outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
398
+ } })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
399
+ position: "absolute", left: selRect.x, top: selRect.y,
400
+ width: selRect.w, height: selRect.h,
401
+ outline: "var(--outline-selection)", outlineOffset: -1.5,
402
+ borderRadius: "var(--radius-sm)",
403
+ } }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection.title, selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
404
+ position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
405
+ transform: "translateX(-100%)", pointerEvents: "auto",
406
+ }, children: selLocked ? (_jsx("span", { className: "sm-mono", style: { fontSize: "var(--text-xs)", color: "#8C94A8", padding: "4px 8px" }, children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => doMove(selected.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => doMove(selected.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => doDuplicate(selected), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => doRemove(selected.id), children: "\u00D7" })] })) })] })), plusButtons.map((p) => {
407
+ const key = `${p.zone}-${p.index}`;
408
+ const hot = hoverPlus === key;
409
+ return (_jsxs("div", { style: { position: "absolute", left: 0, right: 0, top: p.y }, children: [hot && _jsx("div", { className: "sm-guide", style: { margin: "0 12px" } }), _jsxs("div", { style: {
410
+ position: "absolute", left: "50%", top: 0, transform: "translate(-50%,-50%)",
411
+ display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
412
+ pointerEvents: "auto",
413
+ }, children: [_jsx("button", { className: "sm-plus", "aria-label": `Add to ${p.zone}`, onMouseEnter: () => setHoverPlus(key), onMouseLeave: () => setHoverPlus((k) => (k === key ? null : k)), onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), children: "+" }), hot && _jsxs("span", { className: "sm-tooltip", children: ["Add to ", p.zone] })] })] }, key));
414
+ })] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, registry: registry, paths: paths, fixed: isFixedNode(record, registry), onRename: onRename }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
415
+ const locked = policyOf(zone)?.kind === "locked";
416
+ const nodes = nodesIn(zone);
417
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", zoneCanTake(zone) && "sm-outline-row--clickable"), onClick: () => {
418
+ if (!zoneCanTake(zone))
419
+ return;
420
+ const first = allowedSections(policyOf(zone), registry.sections)[0];
421
+ if (first)
422
+ doInsert(zone, 0, first);
423
+ }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), zoneCanTake(zone) && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600 }, children: "+ Add block" })] })), nodes.map((node, i) => {
424
+ const section = sectionOf(node.type);
425
+ const isSelected = node.id === selectedId;
426
+ if (locked) {
427
+ return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
428
+ }
429
+ return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
430
+ })] }, zone));
431
+ })] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
432
+ position: "absolute",
433
+ left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
434
+ top: Math.min(picker.y, Math.max(12, window.innerHeight - 240)),
435
+ }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), allowedSections(policyOf(picker.zone), registry.sections).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
367
436
  }
368
437
  /** The selected node's ref field (if any) resolves to its target
369
438
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -373,19 +442,3 @@ function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
373
442
  const target = targets[`${section.name}.${ns}.${key}`];
374
443
  return target ? loaded[target] ?? [] : [];
375
444
  }
376
- const shell = {
377
- fontFamily: ui,
378
- background: "radial-gradient(1200px 500px at 70% -10%, #221c33 0%, #0e0c14 55%)",
379
- minHeight: "100vh",
380
- color: "#e8e4f4",
381
- };
382
- function btn(extra) {
383
- return { fontFamily: "inherit", fontSize: 13, border: 0, borderRadius: 9, padding: "8px 14px", cursor: "pointer", ...extra };
384
- }
385
- function IconBtn({ label, title, onClick, disabled }) {
386
- return (_jsx("button", { onClick: onClick, title: title, disabled: disabled, onMouseEnter: (e) => { if (!disabled)
387
- e.currentTarget.style.background = "rgba(255,255,255,0.12)"; }, onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), style: {
388
- width: 28, height: 26, background: "transparent", border: 0, color: "#e8e4f4",
389
- borderRadius: 6, cursor: disabled ? "default" : "pointer", fontSize: 13, opacity: disabled ? 0.3 : 1,
390
- }, children: label }));
391
- }
@@ -0,0 +1,13 @@
1
+ import type { PageRecord } from "../../store.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function PageSettings({ record, registry, paths, fixed, onRename }: {
4
+ record: PageRecord;
5
+ registry: AdminRegistry;
6
+ paths: Record<string, string>;
7
+ fixed: boolean;
8
+ /** Resolves to an error message, or null when the rename landed. */
9
+ onRename(locale: string, patch: {
10
+ title?: string;
11
+ slug?: string;
12
+ }): Promise<string | null>;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Page settings (spec 2026-09-05 §7): title and slug per locale, saved
4
+ // through pages.rename. SEO/meta fields are OQ #17's own design pass.
5
+ // Rendered in the inspector while no block is selected.
6
+ import { useEffect, useState } from "react";
7
+ import { Field, SectionLabel } from "../ui/primitives.js";
8
+ export function PageSettings({ record, registry, paths, fixed, onRename }) {
9
+ const { default: def, supported } = registry.locales;
10
+ const stored = (locale, key) => locale === def ? record[key] : (record.i18n?.[locale]?.[key] ?? "");
11
+ const [draft, setDraft] = useState({});
12
+ const [error, setError] = useState(null);
13
+ const isHome = record.parentId === null && record.slug === registry.homePageSlug;
14
+ // The record changes identity on every rename; re-seed the inputs from it.
15
+ useEffect(() => {
16
+ setDraft(Object.fromEntries(supported.map((l) => [l, { title: stored(l, "title"), slug: stored(l, "slug") }])));
17
+ }, [record]); // eslint-disable-line react-hooks/exhaustive-deps
18
+ const commit = async (locale, key) => {
19
+ setError(null);
20
+ const next = draft[locale]?.[key] ?? "";
21
+ if (next === stored(locale, key))
22
+ return;
23
+ if (next === "") {
24
+ if (locale === def) {
25
+ setError(`The default locale needs a ${key}.`);
26
+ return;
27
+ }
28
+ return; // a blank translation falls back to the default locale
29
+ }
30
+ setError(await onRename(locale, { [key]: next }));
31
+ };
32
+ const set = (locale, key, value) => setDraft((d) => ({ ...d, [locale]: { ...(d[locale] ?? { title: "", slug: "" }), [key]: value } }));
33
+ const onKey = (locale, key) => (e) => {
34
+ if (e.key === "Enter") {
35
+ e.preventDefault();
36
+ void commit(locale, key);
37
+ }
38
+ };
39
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(SectionLabel, { children: "Page" }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), supported.map((locale) => (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [supported.length > 1 && _jsx("span", { className: "sm-label", children: locale }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft[locale]?.title ?? "", placeholder: locale === def ? "" : stored(def, "title"), onChange: (e) => set(locale, "title", e.target.value), onBlur: () => void commit(locale, "title"), onKeyDown: onKey(locale, "title") }) }), _jsx(Field, { label: "Slug", hint: paths[locale] ?? "not published in this locale", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft[locale]?.slug ?? "", placeholder: locale === def ? "" : stored(def, "slug"), onChange: (e) => set(locale, "slug", e.target.value), onBlur: () => void commit(locale, "slug"), onKeyDown: onKey(locale, "slug") }) })] }, locale)))] }));
40
+ }
@@ -0,0 +1,23 @@
1
+ import type { ResolvedConfig } from "../config.ts";
2
+ import type { PageI18n, PageRecord, PageStore } from "../store.ts";
3
+ export type FixedNode = {
4
+ /** The default-locale segment (the record's `slug`). */
5
+ slug: string;
6
+ /** Other locales' segments, only where they differ from the default. */
7
+ i18n: PageI18n | null;
8
+ title: string;
9
+ /** A per-locale collection path names its locales; everything else is present everywhere. */
10
+ locales: string[] | null;
11
+ /** The fixed page registration's name; null for a plain mount (a folder). */
12
+ template: string | null;
13
+ /** The collection whose `path` this node is; null for a plain fixed page. */
14
+ collection: string | null;
15
+ };
16
+ export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
17
+ /** The spec a ROOT record materializes, or null for an editor-owned node. */
18
+ export declare function fixedNodeOf(record: {
19
+ parentId: string | null;
20
+ slug: string;
21
+ }, config: ResolvedConfig): FixedNode | null;
22
+ /** Create every fixed node that has no root record yet, then list. */
23
+ export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
@@ -0,0 +1,83 @@
1
+ import { perLocaleSegments } from "../paths.js";
2
+ export function fixedNodeSpecs(config) {
3
+ const { default: def, supported } = config.locales;
4
+ const perLocale = (v) => perLocaleSegments(v, supported);
5
+ const i18nOf = (segments) => {
6
+ const out = {};
7
+ for (const [locale, segment] of Object.entries(segments)) {
8
+ if (locale !== def && segment !== segments[def])
9
+ out[locale] = { slug: segment };
10
+ }
11
+ return Object.keys(out).length > 0 ? out : null;
12
+ };
13
+ const specs = new Map();
14
+ for (const p of config.registry.pages ?? []) {
15
+ if (p.schema.kind !== "page" || p.schema.slug === undefined)
16
+ continue;
17
+ const segments = perLocale(p.schema.slug);
18
+ specs.set(segments[def], {
19
+ slug: segments[def],
20
+ i18n: i18nOf(segments),
21
+ title: p.schema.title ?? p.schema.name,
22
+ locales: null,
23
+ template: p.schema.name,
24
+ collection: null,
25
+ });
26
+ }
27
+ for (const c of config.registry.collections ?? []) {
28
+ if (c.path === undefined)
29
+ continue;
30
+ const segments = perLocale(c.path);
31
+ const indexPage = specs.get(segments[def]);
32
+ if (indexPage) {
33
+ // defineConfig proved the page sits at the collection's segment in
34
+ // every locale: that page IS the mount — the collection's index page.
35
+ indexPage.collection = c.name;
36
+ continue;
37
+ }
38
+ specs.set(segments[def], {
39
+ slug: segments[def],
40
+ i18n: i18nOf(segments),
41
+ title: c.title,
42
+ locales: typeof c.path === "string" ? null : Object.keys(segments),
43
+ template: null,
44
+ collection: c.name,
45
+ });
46
+ }
47
+ return [...specs.values()];
48
+ }
49
+ /** The spec a ROOT record materializes, or null for an editor-owned node. */
50
+ export function fixedNodeOf(record, config) {
51
+ if (record.parentId !== null)
52
+ return null;
53
+ return fixedNodeSpecs(config).find((s) => s.slug === record.slug) ?? null;
54
+ }
55
+ /** Create every fixed node that has no root record yet, then list. */
56
+ export async function ensureFixedNodes(pages, config) {
57
+ const all = await pages.listPages();
58
+ const taken = new Set(all.filter((p) => p.parentId === null).map((p) => p.slug));
59
+ let attempted = false;
60
+ for (const spec of fixedNodeSpecs(config)) {
61
+ if (taken.has(spec.slug))
62
+ continue;
63
+ attempted = true;
64
+ try {
65
+ await pages.createPage({
66
+ slug: spec.slug, template: spec.template, title: spec.title, i18n: spec.i18n, locales: spec.locales,
67
+ });
68
+ }
69
+ catch (e) {
70
+ // One spec that cannot land — a path an editor already claimed in some
71
+ // locale — must not take the whole list down: warn, skip it, keep
72
+ // materializing the rest. Two admins loading at once can also race on
73
+ // this same create; the loser's slugTaken is the other one winning,
74
+ // not a failure, so it skips silently — no warn.
75
+ const message = e instanceof Error ? e.message : String(e);
76
+ if (!message.includes("already exists")) {
77
+ console.warn(`smoodly: could not materialize "${spec.slug}": ${message}`);
78
+ }
79
+ }
80
+ }
81
+ // A create was tried, so the pre-race list is stale either way.
82
+ return attempted ? pages.listPages() : all;
83
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ReactElement } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, }: {
3
+ export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, prefix, }: {
4
4
  descriptor: AdminField;
5
5
  value: unknown;
6
6
  onChange(value: unknown): void;
@@ -11,4 +11,5 @@ export declare function FieldWidget({ descriptor: d, value, onChange, error, ref
11
11
  status: string;
12
12
  }[];
13
13
  fieldKey: string;
14
+ prefix?: string;
14
15
  }): ReactElement;