smoodly 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/admin/auth.d.ts +10 -0
  4. package/dist/admin/auth.js +28 -0
  5. package/dist/admin/client-ops.d.ts +2 -0
  6. package/dist/admin/client-ops.js +18 -0
  7. package/dist/admin/editor/EditorView.d.ts +7 -0
  8. package/dist/admin/editor/EditorView.js +391 -0
  9. package/dist/admin/editor/autosave.d.ts +31 -0
  10. package/dist/admin/editor/autosave.js +108 -0
  11. package/dist/admin/editor/protocol.d.ts +37 -0
  12. package/dist/admin/editor/protocol.js +1 -0
  13. package/dist/admin/forms/FieldWidget.d.ts +14 -0
  14. package/dist/admin/forms/FieldWidget.js +54 -0
  15. package/dist/admin/forms/tabs.d.ts +11 -0
  16. package/dist/admin/forms/tabs.js +20 -0
  17. package/dist/admin/index.d.ts +10 -0
  18. package/dist/admin/index.js +8 -0
  19. package/dist/admin/next/bridge.d.ts +1 -0
  20. package/dist/admin/next/bridge.js +66 -0
  21. package/dist/admin/next/create-admin.d.ts +18 -0
  22. package/dist/admin/next/create-admin.js +14 -0
  23. package/dist/admin/next/index.d.ts +6 -0
  24. package/dist/admin/next/index.js +3 -0
  25. package/dist/admin/next/op-handler.d.ts +11 -0
  26. package/dist/admin/next/op-handler.js +23 -0
  27. package/dist/admin/ops-impl.d.ts +20 -0
  28. package/dist/admin/ops-impl.js +189 -0
  29. package/dist/admin/ops.d.ts +52 -0
  30. package/dist/admin/ops.js +1 -0
  31. package/dist/admin/page-path.d.ts +1 -0
  32. package/dist/admin/page-path.js +6 -0
  33. package/dist/admin/richtext.d.ts +14 -0
  34. package/dist/admin/richtext.js +17 -0
  35. package/dist/admin/serialize.d.ts +40 -0
  36. package/dist/admin/serialize.js +49 -0
  37. package/dist/admin/shell/AdminApp.d.ts +9 -0
  38. package/dist/admin/shell/AdminApp.js +122 -0
  39. package/dist/admin/shell/EntryForm.d.ts +8 -0
  40. package/dist/admin/shell/EntryForm.js +96 -0
  41. package/dist/admin/shell/ListView.d.ts +15 -0
  42. package/dist/admin/shell/ListView.js +14 -0
  43. package/dist/admin/shell/LoginView.d.ts +5 -0
  44. package/dist/admin/shell/LoginView.js +47 -0
  45. package/dist/admin/shell/pages-list.d.ts +22 -0
  46. package/dist/admin/shell/pages-list.js +31 -0
  47. package/dist/admin/shell/session.d.ts +19 -0
  48. package/dist/admin/shell/session.js +37 -0
  49. package/dist/admin/supabase-auth.d.ts +7 -0
  50. package/dist/admin/supabase-auth.js +23 -0
  51. package/dist/admin/tree-ops.d.ts +19 -0
  52. package/dist/admin/tree-ops.js +112 -0
  53. package/dist/admin/validate.d.ts +10 -0
  54. package/dist/admin/validate.js +19 -0
  55. package/dist/client.d.ts +11 -0
  56. package/dist/client.js +19 -0
  57. package/dist/collections.d.ts +39 -0
  58. package/dist/collections.js +22 -0
  59. package/dist/config.d.ts +49 -0
  60. package/dist/config.js +54 -0
  61. package/dist/entry-store.d.ts +74 -0
  62. package/dist/entry-store.js +132 -0
  63. package/dist/env.d.ts +16 -0
  64. package/dist/env.js +22 -0
  65. package/dist/fields.d.ts +67 -0
  66. package/dist/fields.js +41 -0
  67. package/dist/index.d.ts +103 -0
  68. package/dist/index.js +29 -0
  69. package/dist/next/index.d.ts +3 -0
  70. package/dist/next/index.js +2 -0
  71. package/dist/next/meta.d.ts +10 -0
  72. package/dist/next/meta.js +37 -0
  73. package/dist/next/page-renderer.d.ts +36 -0
  74. package/dist/next/page-renderer.js +69 -0
  75. package/dist/page.d.ts +22 -0
  76. package/dist/page.js +35 -0
  77. package/dist/pairing.d.ts +33 -0
  78. package/dist/pairing.js +26 -0
  79. package/dist/ref-index.d.ts +19 -0
  80. package/dist/ref-index.js +0 -0
  81. package/dist/refs.d.ts +20 -0
  82. package/dist/refs.js +49 -0
  83. package/dist/render.d.ts +48 -0
  84. package/dist/render.js +41 -0
  85. package/dist/resolve.d.ts +22 -0
  86. package/dist/resolve.js +128 -0
  87. package/dist/revalidate.d.ts +8 -0
  88. package/dist/revalidate.js +34 -0
  89. package/dist/schema.d.ts +57 -0
  90. package/dist/schema.js +40 -0
  91. package/dist/section-wrapper.d.ts +11 -0
  92. package/dist/section-wrapper.js +19 -0
  93. package/dist/sql-space.d.ts +6 -0
  94. package/dist/sql-space.js +172 -0
  95. package/dist/sql.d.ts +3 -0
  96. package/dist/sql.js +162 -0
  97. package/dist/store.d.ts +73 -0
  98. package/dist/store.js +145 -0
  99. package/dist/supabase-entry-store.d.ts +26 -0
  100. package/dist/supabase-entry-store.js +191 -0
  101. package/dist/supabase-store.d.ts +26 -0
  102. package/dist/supabase-store.js +180 -0
  103. package/dist/zones.d.ts +31 -0
  104. package/dist/zones.js +23 -0
  105. package/package.json +50 -0
@@ -0,0 +1,391 @@
1
+ "use client";
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.
4
+ //
5
+ // The canvas is an iframe of the SITE's own route in draft mode (pixel-
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
8
+ // the geometry the bridge reports map 1:1 onto the overlay layer above
9
+ // it. Every gesture mutates client tree state through Task 4's pure ops;
10
+ // a debounced saveDraft persists, and the bridge refreshes the iframe on
11
+ // the save ack (refresh-on-change preview, DESIGN.md §3).
12
+ //
13
+ // Core code: no next/* imports live here — the bridge is the only piece
14
+ // that knows about Next, and it ships to the site, not the admin.
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import { pagePath } from "../page-path.js";
17
+ import { FieldWidget } from "../forms/FieldWidget.js";
18
+ import { tabsFor } from "../forms/tabs.js";
19
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
20
+ 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;
25
+ const GEOMETRY_TIMEOUT_MS = 4000;
26
+ export function EditorView({ ops, registry, slug }) {
27
+ const [record, setRecord] = useState(null);
28
+ const [tree, setTree] = useState(null);
29
+ const [loadError, setLoadError] = useState(null);
30
+ const [iframeSrc, setIframeSrc] = useState(null);
31
+ const [rects, setRects] = useState([]);
32
+ const [emptyZones, setEmptyZones] = useState([]);
33
+ const [docH, setDocH] = useState(900);
34
+ const [geometry, setGeometry] = useState("waiting");
35
+ const [selectedId, setSelectedId] = useState(null);
36
+ const [hoverId, setHoverId] = useState(null);
37
+ const [picker, setPicker] = useState(null);
38
+ const [tab, setTab] = useState("Content");
39
+ const [banner, setBanner] = useState(null);
40
+ const [published, setPublished] = useState(false);
41
+ const [refOptions, setRefOptions] = useState({});
42
+ const iframeRef = useRef(null);
43
+ const treeRef = useRef(null);
44
+ const registryRef = useRef(registry);
45
+ registryRef.current = registry;
46
+ // Every edit marks the draft dirty; a debounced saveDraft persists, with
47
+ // retry on failure, and the bridge refreshes the iframe on the save ack.
48
+ const { saveState, mark, flush } = useDraftAutosave({
49
+ read: () => treeRef.current,
50
+ persist: (t) => ops.pages.saveDraft(slug, t),
51
+ onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
52
+ });
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);
56
+ const label = path;
57
+ // ── load: draft mode FIRST, then the record; only then point the iframe
58
+ // at the site route (an iframe loaded before the cookie exists would
59
+ // render the published tree).
60
+ useEffect(() => {
61
+ let cancelled = false;
62
+ (async () => {
63
+ // ops is a server action — it can reject outright (offline, 500,
64
+ // aborted), not just resolve to a typed error. Either failure mode
65
+ // must land on the same full-pane error, never a hung load.
66
+ let previewResult;
67
+ try {
68
+ previewResult = await ops.preview.enable();
69
+ }
70
+ catch {
71
+ previewResult = { ok: false, code: "internal", message: "Couldn't reach the server." };
72
+ }
73
+ if (cancelled)
74
+ return;
75
+ if (!previewResult.ok) {
76
+ // Draft mode didn't actually turn on — pointing the iframe at the
77
+ // site route now would silently render the PUBLISHED tree, which
78
+ // contradicts the editor's core promise. Fail the whole load.
79
+ setLoadError(previewResult.message);
80
+ return;
81
+ }
82
+ let result;
83
+ try {
84
+ result = await ops.pages.get(slug);
85
+ }
86
+ catch {
87
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
88
+ }
89
+ if (cancelled)
90
+ return;
91
+ if (!result.ok) {
92
+ setLoadError(result.message);
93
+ return;
94
+ }
95
+ // Locked zones can't be filled by any gesture, so fill the empty
96
+ // ones here; a change marks the draft dirty so the debounced save
97
+ // persists it and the canvas shows the same tree as the outline.
98
+ // Read through the ref: `registry` is a server-component prop, so
99
+ // its identity changes on every RSC refresh — and preview.enable's
100
+ // cookie write triggers one. As a dependency it would loop this
101
+ // effect forever (same reason AdminApp pins `ops` with useState).
102
+ const reg = registryRef.current;
103
+ const template = reg.pages.find((p) => p.name === result.data.record.template);
104
+ const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
105
+ setRecord(result.data.record);
106
+ setTree(draft);
107
+ treeRef.current = draft;
108
+ if (draft !== result.data.draft)
109
+ mark();
110
+ setIframeSrc(`${pagePath(slug, registry.homePageSlug)}?smoodly-editor=1`);
111
+ })();
112
+ return () => { cancelled = true; };
113
+ }, [ops, slug]);
114
+ useEffect(() => { treeRef.current = tree; }, [tree]);
115
+ const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
116
+ const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
117
+ const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
118
+ const sectionOf = useCallback((type) => registry.sections.find((s) => s.name === type) ?? null, [registry]);
119
+ // ── ref fields inside sections: same option loading as EntryForm ──────
120
+ const refTargets = useMemo(() => {
121
+ const out = {};
122
+ for (const section of registry.sections) {
123
+ for (const ns of ["fields", "styles", "meta"]) {
124
+ for (const [key, d] of Object.entries(section[ns] ?? {})) {
125
+ if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string") {
126
+ out[`${section.name}.${ns}.${key}`] = d.target;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ return out;
132
+ }, [registry]);
133
+ useEffect(() => {
134
+ for (const target of new Set(Object.values(refTargets))) {
135
+ const targetMeta = registry.collections.find((c) => c.name === target);
136
+ ops.entries.list(target).then((r) => {
137
+ if (!r.ok)
138
+ return;
139
+ const options = r.data.map((e) => ({
140
+ id: e.id,
141
+ title: String((targetMeta?.titleField && e.fields[targetMeta.titleField]) ?? e.id),
142
+ status: e.status,
143
+ }));
144
+ setRefOptions((prev) => ({ ...prev, [target]: options }));
145
+ }).catch((e) => {
146
+ // A rejected transport just leaves this target's options empty —
147
+ // ref fields degrade to "nothing to pick from," not a stuck load.
148
+ console.error(e);
149
+ });
150
+ }
151
+ }, [ops, refTargets, registry]);
152
+ // ── canvas messaging ─────────────────────────────────────────────────
153
+ const postToCanvas = useCallback((m) => {
154
+ iframeRef.current?.contentWindow?.postMessage(m, "*");
155
+ }, []);
156
+ useEffect(() => {
157
+ const onMessage = (e) => {
158
+ const m = e.data;
159
+ if (m?.source !== "smoodly")
160
+ return;
161
+ if (e.source !== iframeRef.current?.contentWindow)
162
+ return;
163
+ if (m.kind === "rects") {
164
+ setRects(m.nodes);
165
+ setEmptyZones(m.emptyZones);
166
+ setDocH(m.docH);
167
+ setGeometry("ok");
168
+ }
169
+ else if (m.kind === "select") {
170
+ // Canvas-originated: the node is under the cursor already, so this
171
+ // path deliberately skips the scroll every other selection does.
172
+ setSelectedId(m.id);
173
+ }
174
+ else if (m.kind === "hover") {
175
+ setHoverId(m.id);
176
+ }
177
+ };
178
+ window.addEventListener("message", onMessage);
179
+ return () => window.removeEventListener("message", onMessage);
180
+ }, []);
181
+ // Bridge degradation: geometry silence is survivable — the iframe and
182
+ // the outline keep working, only the overlay goes dark.
183
+ useEffect(() => {
184
+ if (!iframeSrc || geometry !== "waiting")
185
+ return;
186
+ const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
187
+ return () => clearTimeout(t);
188
+ }, [iframeSrc, geometry]);
189
+ const select = useCallback((id) => {
190
+ setSelectedId(id);
191
+ if (id)
192
+ postToCanvas({ source: "smoodly", kind: "scroll", id });
193
+ }, [postToCanvas]);
194
+ // ── persistence ──────────────────────────────────────────────────────
195
+ const mutate = useCallback((fn) => {
196
+ setTree((t) => {
197
+ if (!t)
198
+ return t;
199
+ const next = fn(t);
200
+ treeRef.current = next;
201
+ return next;
202
+ });
203
+ mark();
204
+ }, [mark]);
205
+ // ── tree gestures ────────────────────────────────────────────────────
206
+ const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
207
+ const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
208
+ const doInsert = (zone, index, section) => {
209
+ const node = nodeFromSample(section, newNodeId(section.name));
210
+ mutate((t) => insertNode(t, zone, index, node));
211
+ setPicker(null);
212
+ select(node.id);
213
+ };
214
+ const doDuplicate = (node) => {
215
+ if (!tree)
216
+ return;
217
+ const zone = zoneOf(tree, node.id);
218
+ if (!zone || !zoneCanTake(zone))
219
+ return;
220
+ const index = nodesIn(zone).findIndex((n) => n.id === node.id) + 1;
221
+ const copy = {
222
+ ...node,
223
+ id: newNodeId(node.type),
224
+ fields: { ...node.fields },
225
+ styles: node.styles ? { ...node.styles } : undefined,
226
+ meta: node.meta ? { ...node.meta } : undefined,
227
+ };
228
+ mutate((t) => insertNode(t, zone, index, copy));
229
+ select(copy.id);
230
+ };
231
+ const doRemove = (id, confirm = true) => {
232
+ if (!tree)
233
+ return;
234
+ if (policyOf(zoneOf(tree, id))?.kind === "locked")
235
+ return;
236
+ if (confirm && !window.confirm("Delete this section?"))
237
+ return;
238
+ mutate((t) => removeNode(t, id));
239
+ setSelectedId((current) => (current === id ? null : current));
240
+ };
241
+ const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
242
+ const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
243
+ // ── publish ──────────────────────────────────────────────────────────
244
+ const doPublish = async () => {
245
+ setBanner(null);
246
+ // Never publish behind a pending debounce.
247
+ const flushed = await flush();
248
+ if (!flushed) {
249
+ // The pre-publish flush failed (or is still behind, mid-retry) —
250
+ // publishing now would ship a stale draft. Bail instead.
251
+ setBanner({ text: "Couldn't save your latest changes — publish cancelled." });
252
+ return;
253
+ }
254
+ let result;
255
+ try {
256
+ result = await ops.pages.publish(slug);
257
+ }
258
+ catch (e) {
259
+ console.error(e);
260
+ // Same transport concern as the autosave persist: the server action can reject
261
+ // outright instead of resolving to a typed error.
262
+ result = { ok: false, code: "internal", message: "Publish failed — check your connection and try again." };
263
+ }
264
+ if (result.ok) {
265
+ setRecord(result.data);
266
+ setPublished(true);
267
+ setTimeout(() => setPublished(false), 1600);
268
+ }
269
+ else {
270
+ setBanner({ text: result.message, fields: result.fields });
271
+ }
272
+ };
273
+ // ── keyboard ─────────────────────────────────────────────────────────
274
+ useEffect(() => {
275
+ const onKey = (e) => {
276
+ if (e.key === "Escape") {
277
+ setPicker(null);
278
+ setSelectedId(null);
279
+ }
280
+ };
281
+ window.addEventListener("keydown", onKey);
282
+ return () => window.removeEventListener("keydown", onKey);
283
+ }, []);
284
+ 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" })] }) }));
286
+ }
287
+ const selected = tree && selectedId ? findNode(tree, selectedId) : null;
288
+ const selectedSection = selected ? sectionOf(selected.type) : null;
289
+ const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
290
+ const selLocked = policyOf(selZone)?.kind === "locked";
291
+ const rectById = new Map(rects.map((r) => [r.id, r]));
292
+ const selRect = selected ? rectById.get(selected.id) : undefined;
293
+ const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
294
+ const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
295
+ // "+" affordances: one per legal boundary in every insertable zone.
296
+ const plusButtons = [];
297
+ for (const zone of zoneOrder) {
298
+ if (!zoneCanTake(zone))
299
+ continue;
300
+ // Carry each member's real position in the tree's zone array through
301
+ // the rect filter — under refresh-on-change the rects lag the tree by
302
+ // up to a save cycle, so a filtered-array position isn't a valid
303
+ // insertNode index once a node is missing its rect.
304
+ const members = nodesIn(zone)
305
+ .map((n, treeIndex) => ({ rect: rectById.get(n.id), treeIndex }))
306
+ .filter((m) => Boolean(m.rect));
307
+ if (members.length === 0) {
308
+ const zr = emptyZones.find((r) => r.zone === zone);
309
+ if (zr)
310
+ plusButtons.push({ zone, index: 0, y: zr.y + zr.h / 2 });
311
+ }
312
+ else {
313
+ members.forEach(({ rect, treeIndex }, i) => {
314
+ plusButtons.push({ zone, index: treeIndex, y: rect.y - 1 });
315
+ if (i === members.length - 1)
316
+ plusButtons.push({ zone, index: treeIndex + 1, y: rect.y + rect.h + 1 });
317
+ });
318
+ }
319
+ }
320
+ const tabs = selectedSection ? tabsFor(selectedSection) : [];
321
+ const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
322
+ 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)))] })] }));
367
+ }
368
+ /** The selected node's ref field (if any) resolves to its target
369
+ * collection's loaded options — the EntryForm shape, section-scoped. */
370
+ function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
371
+ if (descriptor.type !== "ref" && descriptor.type !== "refList")
372
+ return undefined;
373
+ const target = targets[`${section.name}.${ns}.${key}`];
374
+ return target ? loaded[target] ?? [] : [];
375
+ }
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,31 @@
1
+ import type { OpResult } from "../ops.ts";
2
+ export type SaveState = "saved" | "saving" | "error";
3
+ export type DraftAutosave = {
4
+ /** Record a local change; a debounced save follows. */
5
+ mark(): void;
6
+ /** Save now if anything is unsaved. Resolves true once the store holds
7
+ * the latest revision — the pre-publish flush needs that to decide
8
+ * whether it is safe to proceed. */
9
+ flush(): Promise<boolean>;
10
+ getState(): SaveState;
11
+ subscribe(listener: () => void): () => void;
12
+ /** Cancel pending timers; call on unmount. */
13
+ dispose(): void;
14
+ };
15
+ export declare function createDraftAutosave<T>(opts: {
16
+ read: () => T | null;
17
+ persist: (value: T) => Promise<OpResult<unknown>>;
18
+ onSaved?: () => void;
19
+ debounceMs?: number;
20
+ retryBaseMs?: number;
21
+ retryCapMs?: number;
22
+ }): DraftAutosave;
23
+ export type DraftAutosaveOptions<T> = Parameters<typeof createDraftAutosave<T>>[0];
24
+ /** React glue: one controller per mount, always calling the latest
25
+ * `read`/`persist`/`onSaved` (their identities change on every render —
26
+ * `ops` and `slug` are props), timers cleared on unmount. */
27
+ export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
28
+ saveState: SaveState;
29
+ mark: () => void;
30
+ flush: () => Promise<boolean>;
31
+ };
@@ -0,0 +1,108 @@
1
+ // Draft autosave: a debounced, retrying persister with a tiny observable
2
+ // state. Framework-free so it is unit-testable with fake timers; the
3
+ // React glue lives in `useDraftAutosave` below.
4
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ export function createDraftAutosave(opts) {
6
+ const debounceMs = opts.debounceMs ?? 800;
7
+ const retryBaseMs = opts.retryBaseMs ?? 1000;
8
+ const retryCapMs = opts.retryCapMs ?? 10000;
9
+ let debounceTimer = null;
10
+ let retryTimer = null;
11
+ let retryDelay = retryBaseMs;
12
+ let revision = 0;
13
+ let savedRevision = 0;
14
+ let state = "saved";
15
+ const listeners = new Set();
16
+ const setState = (next) => {
17
+ state = next;
18
+ for (const l of listeners)
19
+ l();
20
+ };
21
+ const save = async () => {
22
+ const current = opts.read();
23
+ if (current === null)
24
+ return true;
25
+ if (retryTimer) {
26
+ clearTimeout(retryTimer);
27
+ retryTimer = null;
28
+ }
29
+ const rev = revision;
30
+ setState("saving");
31
+ let result;
32
+ try {
33
+ result = await opts.persist(current);
34
+ }
35
+ catch (e) {
36
+ console.error(e);
37
+ // persist is a server action — it can reject outright (offline, 500,
38
+ // aborted), not just resolve to a typed error. Fold that into the
39
+ // same failure path so it isn't a stuck "saving" state.
40
+ result = { ok: false, code: "internal", message: "" };
41
+ }
42
+ if (result.ok) {
43
+ savedRevision = rev;
44
+ retryDelay = retryBaseMs;
45
+ setState("saved");
46
+ opts.onSaved?.();
47
+ return true;
48
+ }
49
+ else {
50
+ // Keep local state; retry with a doubling, capped delay.
51
+ setState("error");
52
+ const delay = retryDelay;
53
+ retryDelay = Math.min(delay * 2, retryCapMs);
54
+ retryTimer = setTimeout(() => { retryTimer = null; void save(); }, delay);
55
+ return false;
56
+ }
57
+ };
58
+ return {
59
+ getState: () => state,
60
+ subscribe(listener) {
61
+ listeners.add(listener);
62
+ return () => { listeners.delete(listener); };
63
+ },
64
+ mark() {
65
+ revision += 1;
66
+ if (debounceTimer)
67
+ clearTimeout(debounceTimer);
68
+ debounceTimer = setTimeout(() => { debounceTimer = null; void save(); }, debounceMs);
69
+ },
70
+ flush() {
71
+ if (revision === savedRevision)
72
+ return Promise.resolve(true);
73
+ if (debounceTimer) {
74
+ clearTimeout(debounceTimer);
75
+ debounceTimer = null;
76
+ }
77
+ return save();
78
+ },
79
+ dispose() {
80
+ if (debounceTimer) {
81
+ clearTimeout(debounceTimer);
82
+ debounceTimer = null;
83
+ }
84
+ if (retryTimer) {
85
+ clearTimeout(retryTimer);
86
+ retryTimer = null;
87
+ }
88
+ },
89
+ };
90
+ }
91
+ /** React glue: one controller per mount, always calling the latest
92
+ * `read`/`persist`/`onSaved` (their identities change on every render —
93
+ * `ops` and `slug` are props), timers cleared on unmount. */
94
+ export function useDraftAutosave(opts) {
95
+ const latest = useRef(opts);
96
+ latest.current = opts;
97
+ const [controller] = useState(() => createDraftAutosave({
98
+ read: () => latest.current.read(),
99
+ persist: (v) => latest.current.persist(v),
100
+ onSaved: () => latest.current.onSaved?.(),
101
+ debounceMs: opts.debounceMs,
102
+ retryBaseMs: opts.retryBaseMs,
103
+ retryCapMs: opts.retryCapMs,
104
+ }));
105
+ useEffect(() => () => controller.dispose(), [controller]);
106
+ const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
107
+ return { saveState, mark: controller.mark, flush: controller.flush };
108
+ }
@@ -0,0 +1,37 @@
1
+ export type BridgeRect = {
2
+ id: string;
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ };
8
+ export type ZoneRect = {
9
+ zone: string;
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ h: number;
14
+ };
15
+ export type FromBridge = {
16
+ source: "smoodly";
17
+ kind: "rects";
18
+ nodes: BridgeRect[];
19
+ emptyZones: ZoneRect[];
20
+ docH: number;
21
+ } | {
22
+ source: "smoodly";
23
+ kind: "select";
24
+ id: string | null;
25
+ } | {
26
+ source: "smoodly";
27
+ kind: "hover";
28
+ id: string | null;
29
+ };
30
+ export type ToBridge = {
31
+ source: "smoodly";
32
+ kind: "refresh";
33
+ } | {
34
+ source: "smoodly";
35
+ kind: "scroll";
36
+ id: string;
37
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { ReactElement } from "react";
2
+ import type { AdminField } from "../serialize.ts";
3
+ export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, }: {
4
+ descriptor: AdminField;
5
+ value: unknown;
6
+ onChange(value: unknown): void;
7
+ error?: string;
8
+ refOptions?: {
9
+ id: string;
10
+ title: string;
11
+ status: string;
12
+ }[];
13
+ fieldKey: string;
14
+ }): ReactElement;