smoodly 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -36
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/custom-section.d.ts +10 -0
- package/dist/admin/custom-section.js +10 -0
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +6 -6
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +322 -62
- package/dist/admin/editor/GlobalInspector.d.ts +24 -0
- package/dist/admin/editor/GlobalInspector.js +13 -0
- package/dist/admin/editor/InsertPicker.d.ts +6 -5
- package/dist/admin/editor/InsertPicker.js +6 -4
- package/dist/admin/editor/InspectorFields.d.ts +21 -0
- package/dist/admin/editor/InspectorFields.js +22 -0
- package/dist/admin/editor/Outline.d.ts +0 -1
- package/dist/admin/editor/Outline.js +3 -3
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/global-edit.d.ts +39 -0
- package/dist/admin/editor/global-edit.js +124 -0
- package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
- package/dist/admin/editor/useGlobalEdit.js +18 -0
- package/dist/admin/forms/FieldWidget.d.ts +14 -1
- package/dist/admin/forms/FieldWidget.js +38 -11
- package/dist/admin/forms/ListField.d.ts +9 -1
- package/dist/admin/forms/ListField.js +16 -8
- package/dist/admin/forms/RichtextElementCard.js +11 -5
- package/dist/admin/forms/RowsWidget.d.ts +13 -0
- package/dist/admin/forms/RowsWidget.js +26 -0
- package/dist/admin/forms/SpanBar.d.ts +5 -0
- package/dist/admin/forms/SpanBar.js +37 -0
- package/dist/admin/forms/richtext-insert.d.ts +2 -2
- package/dist/admin/forms/richtext-insert.js +3 -3
- package/dist/admin/forms/richtext-nodes.js +1 -1
- package/dist/admin/forms/tabs.d.ts +20 -8
- package/dist/admin/forms/tabs.js +19 -23
- package/dist/admin/forms/useRichtextEditor.js +8 -1
- package/dist/admin/global-items.d.ts +9 -0
- package/dist/admin/{shared-items.js → global-items.js} +29 -14
- package/dist/admin/index.d.ts +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +242 -56
- package/dist/admin/ops.d.ts +38 -6
- package/dist/admin/serialize.d.ts +18 -7
- package/dist/admin/serialize.js +27 -5
- package/dist/admin/shell/AdminApp.js +7 -7
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/GlobalFields.d.ts +24 -0
- package/dist/admin/shell/GlobalFields.js +19 -0
- package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
- package/dist/admin/shell/GlobalForm.js +258 -0
- package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
- package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
- package/dist/admin/shell/IconRail.js +14 -7
- package/dist/admin/shell/PagesList.js +4 -8
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
- package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/tree-ops.d.ts +10 -8
- package/dist/admin/tree-ops.js +28 -16
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +16 -1
- package/dist/admin/ui/primitives.js +20 -4
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +93 -13
- package/dist/admin/validate.d.ts +27 -2
- package/dist/admin/validate.js +47 -10
- package/dist/collections.d.ts +3 -9
- package/dist/collections.js +1 -2
- package/dist/conditions.d.ts +33 -0
- package/dist/conditions.js +79 -0
- package/dist/config.d.ts +26 -19
- package/dist/config.js +26 -25
- package/dist/css.d.ts +7 -0
- package/dist/css.js +57 -0
- package/dist/custom/CustomSection.d.ts +26 -0
- package/dist/custom/CustomSection.js +20 -0
- package/dist/custom/css.d.ts +3 -0
- package/dist/custom/css.js +28 -0
- package/dist/custom/index.d.ts +26 -0
- package/dist/custom/index.js +16 -0
- package/dist/custom/rows.d.ts +28 -0
- package/dist/custom/rows.js +129 -0
- package/dist/custom/schema.d.ts +32 -0
- package/dist/custom/schema.js +39 -0
- package/dist/entry-store.d.ts +42 -17
- package/dist/entry-store.js +107 -40
- package/dist/fields.d.ts +5 -2
- package/dist/fields.js +4 -2
- package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
- package/dist/{shared-id.js → global-id.js} +4 -4
- package/dist/global.d.ts +57 -0
- package/dist/global.js +51 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.js +10 -5
- package/dist/media.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/page-renderer.d.ts +4 -2
- package/dist/next/page-renderer.js +17 -13
- package/dist/next/queries.d.ts +6 -6
- package/dist/next/queries.js +7 -7
- package/dist/page.js +2 -2
- package/dist/pairing.d.ts +13 -7
- package/dist/pairing.js +4 -4
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +4 -4
- package/dist/render.d.ts +13 -1
- package/dist/render.js +10 -5
- package/dist/resolve.d.ts +11 -6
- package/dist/resolve.js +22 -12
- package/dist/revalidate.d.ts +4 -4
- package/dist/revalidate.js +4 -4
- package/dist/richtext-render.js +2 -2
- package/dist/richtext-walk.d.ts +5 -5
- package/dist/richtext-walk.js +4 -4
- package/dist/schema.d.ts +15 -10
- package/dist/schema.js +19 -7
- package/dist/section-wrapper.d.ts +18 -6
- package/dist/section-wrapper.js +24 -17
- package/dist/site.d.ts +17 -5
- package/dist/site.js +36 -12
- package/dist/sql-space.js +1 -1
- package/dist/sql.js +19 -16
- package/dist/store.d.ts +45 -13
- package/dist/store.js +88 -12
- package/dist/styles.d.ts +27 -0
- package/dist/styles.js +60 -0
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +115 -39
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +90 -20
- package/dist/zones.d.ts +9 -7
- package/dist/zones.js +5 -5
- package/package.json +1 -1
- package/dist/admin/shared-items.d.ts +0 -9
- package/dist/admin/shell/SharedForm.js +0 -134
- package/dist/shared.d.ts +0 -46
- package/dist/shared.js +0 -36
|
@@ -28,32 +28,40 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
28
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
29
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
30
30
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
-
import {
|
|
31
|
+
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
|
|
32
32
|
import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
33
33
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
34
34
|
import { revealField } from "../forms/reveal.js";
|
|
35
35
|
import { tabOf, tabsFor } from "../forms/tabs.js";
|
|
36
|
-
import {
|
|
36
|
+
import { InspectorFields, valueOf } from "./InspectorFields.js";
|
|
37
|
+
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
37
38
|
import { useDraftAutosave } from "./autosave.js";
|
|
38
39
|
import { CanvasOverlay } from "./CanvasOverlay.js";
|
|
39
40
|
import { InlineRichtext } from "./InlineRichtext.js";
|
|
40
41
|
import { InsertPicker } from "./InsertPicker.js";
|
|
42
|
+
import { customNode, customNodeLabel, customSection } from "../custom-section.js";
|
|
43
|
+
import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
|
|
41
44
|
import { NoticePane } from "./NoticePane.js";
|
|
42
45
|
import { Outline } from "./Outline.js";
|
|
43
46
|
import { PageInspector } from "./PageInspector.js";
|
|
47
|
+
import { GlobalInspector } from "./GlobalInspector.js";
|
|
44
48
|
import { localeFromUrl } from "./locale-url.js";
|
|
45
49
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
46
50
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
47
51
|
import { useInlineSession } from "./useInlineSession.js";
|
|
48
52
|
import { usePageLocale } from "./usePageLocale.js";
|
|
53
|
+
import { useGlobalEdit } from "./useGlobalEdit.js";
|
|
49
54
|
import { BASE } from "../shell/base.js";
|
|
50
|
-
import { RouteLink } from "../shell/RouteLink.js";
|
|
51
55
|
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
52
56
|
import { entryTitle } from "../shell/entries-list.js";
|
|
53
57
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
54
58
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
55
|
-
import {
|
|
59
|
+
import { PublishControl } from "../ui/PublishControl.js";
|
|
60
|
+
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
61
|
+
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
|
|
56
62
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
63
|
+
/** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
|
|
64
|
+
export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ? "&" : "?"}smoodly-version=${encodeURIComponent(versionId)}`;
|
|
57
65
|
export function EditorView({ ops, registry, id }) {
|
|
58
66
|
const [record, setRecord] = useState(null);
|
|
59
67
|
const [tree, setTree] = useState(null);
|
|
@@ -76,10 +84,22 @@ export function EditorView({ ops, registry, id }) {
|
|
|
76
84
|
const [tab, setTab] = useState("Content");
|
|
77
85
|
const [banner, setBanner] = useState(null);
|
|
78
86
|
const [published, setPublished] = useState(false);
|
|
87
|
+
/** An action of the page's control in flight. */
|
|
88
|
+
const [pageBusy, setPageBusy] = useState(null);
|
|
89
|
+
/** History's rows while the modal is open (spec 2026-09-13 history §5). */
|
|
90
|
+
const [history, setHistory] = useState(null);
|
|
91
|
+
/** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
|
|
92
|
+
const [dirty, setDirty] = useState(false);
|
|
93
|
+
/** Any edit on this canvas yet: the autosave indicator stays hidden until the first one (§6). */
|
|
94
|
+
const [touched, setTouched] = useState(false);
|
|
95
|
+
const [globalBusy, setGlobalBusy] = useState(null);
|
|
96
|
+
/** A global edit since the item's last save ack; and any global edit on this canvas yet. */
|
|
97
|
+
const [globalDirty, setGlobalDirty] = useState(false);
|
|
98
|
+
const [globalTouched, setGlobalTouched] = useState(false);
|
|
79
99
|
const [refOptions, setRefOptions] = useState({});
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
const [
|
|
100
|
+
// Global item name → row id, loaded once per editor mount: the picker's
|
|
101
|
+
// Global group and a bound locked zone need the id to place a node.
|
|
102
|
+
const [globalIds, setGlobalIds] = useState(null);
|
|
83
103
|
const iframeRef = useRef(null);
|
|
84
104
|
const scrollerRef = useRef(null);
|
|
85
105
|
const treeRef = useRef(null);
|
|
@@ -93,11 +113,33 @@ export function EditorView({ ops, registry, id }) {
|
|
|
93
113
|
// now, or once an inline session ends (spec 2026-09-10 §7).
|
|
94
114
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
95
115
|
read: () => treeRef.current,
|
|
96
|
-
persist: (t) =>
|
|
116
|
+
persist: async (t) => {
|
|
117
|
+
const target = treeLocaleRef.current;
|
|
118
|
+
const r = await ops.pages.saveDraft(id, target, t);
|
|
119
|
+
if (r.ok) {
|
|
120
|
+
// The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
|
|
121
|
+
setRecord((rec) => rec?.locales[target]
|
|
122
|
+
? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
|
|
123
|
+
: rec);
|
|
124
|
+
setDirty(false);
|
|
125
|
+
}
|
|
126
|
+
return r;
|
|
127
|
+
},
|
|
97
128
|
onSaved: () => inlineRef.current?.onSaved(),
|
|
98
129
|
});
|
|
130
|
+
// Edits to a global block go to the global entry through their own
|
|
131
|
+
// autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
|
|
132
|
+
// through the same inline-session deferral as the page's.
|
|
133
|
+
const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
|
|
134
|
+
const patchGlobal = (name, path, value) => {
|
|
135
|
+
globalEdit.patch(name, path, value);
|
|
136
|
+
setGlobalDirty(true);
|
|
137
|
+
setGlobalTouched(true);
|
|
138
|
+
};
|
|
139
|
+
/** Both targets, page first: a locale switch must leave nothing pending on either. Page Publish keeps `flush()` — it never touches a global item. */
|
|
140
|
+
const flushAll = useCallback(async () => (await flush()) && (await globalEdit.flush()), [flush, globalEdit.flush]);
|
|
99
141
|
const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
|
|
100
|
-
registry, ops, id, record, setRecord, flush,
|
|
142
|
+
registry, ops, id, record, setRecord, flush: flushAll,
|
|
101
143
|
fail: (text) => setBanner({ text }),
|
|
102
144
|
clearBanner: () => setBanner(null),
|
|
103
145
|
reload: () => setReloadKey((k) => k + 1),
|
|
@@ -134,6 +176,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
134
176
|
setSelectedId(null);
|
|
135
177
|
setHoverId(null);
|
|
136
178
|
setFolder(false);
|
|
179
|
+
globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
|
|
180
|
+
setDirty(false);
|
|
181
|
+
setTouched(false);
|
|
182
|
+
setGlobalDirty(false);
|
|
183
|
+
setGlobalTouched(false);
|
|
137
184
|
// ops is a server action — it can reject outright (offline, 500,
|
|
138
185
|
// aborted), not just resolve to a typed error. Either failure mode
|
|
139
186
|
// must land on the same full-pane error, never a hung load.
|
|
@@ -225,18 +272,28 @@ export function EditorView({ ops, registry, id }) {
|
|
|
225
272
|
const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
|
|
226
273
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
227
274
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
275
|
+
// The custom section (spec 2026-09-14 §6) is the registry's one synthetic section.
|
|
276
|
+
const sectionOf = useCallback((type) => (type === CUSTOM_NODE_TYPE ? customSection(registry) : registry.sections.find((s) => s.name === type) ?? null), [registry]);
|
|
277
|
+
/** The global item a placement node places, or null for any other node. */
|
|
278
|
+
const globalOf = useCallback((node) => {
|
|
279
|
+
if (node.type !== GLOBAL_NODE_TYPE)
|
|
232
280
|
return null;
|
|
233
|
-
return registry.
|
|
281
|
+
return registry.globals.find((s) => s.name === node.fields?.item) ?? null;
|
|
234
282
|
}, [registry]);
|
|
283
|
+
/** The global item behind a node id: a placement's, by the tree, or a layout-rendered
|
|
284
|
+
* item's, by its `global:` id (spec 2026-09-13 §1). Reads the refs — safe at event time. */
|
|
285
|
+
const globalItemOf = useCallback((id) => {
|
|
286
|
+
const node = treeRef.current ? findNode(treeRef.current, id) : null;
|
|
287
|
+
if (node)
|
|
288
|
+
return globalOf(node);
|
|
289
|
+
const name = globalNameOf(id);
|
|
290
|
+
return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
|
|
291
|
+
}, [globalOf]);
|
|
235
292
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
236
293
|
const refTargets = useMemo(() => {
|
|
237
294
|
const out = {};
|
|
238
295
|
for (const section of registry.sections) {
|
|
239
|
-
for (const ns of ["fields", "
|
|
296
|
+
for (const ns of ["fields", "settings"]) {
|
|
240
297
|
for (const [key, d] of Object.entries(section[ns] ?? {})) {
|
|
241
298
|
if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string") {
|
|
242
299
|
out[`${section.name}.${ns}.${key}`] = d.target;
|
|
@@ -272,26 +329,36 @@ export function EditorView({ ops, registry, id }) {
|
|
|
272
329
|
return () => { cancelled = true; };
|
|
273
330
|
}, [ops, refTargets, registry, locale]);
|
|
274
331
|
useEffect(() => {
|
|
275
|
-
if (registryRef.current.
|
|
276
|
-
|
|
332
|
+
if (registryRef.current.globals.length === 0) {
|
|
333
|
+
setGlobalIds({});
|
|
277
334
|
return;
|
|
278
335
|
}
|
|
279
336
|
let cancelled = false;
|
|
280
|
-
// A failed load leaves the map empty: the picker's
|
|
337
|
+
// A failed load leaves the map empty: the picker's Global group and
|
|
281
338
|
// any zone bound to an item degrade to nothing to place, not a stuck
|
|
282
339
|
// editor — so say why in the console, like the ref options above.
|
|
283
|
-
ops.
|
|
340
|
+
ops.globals.list()
|
|
284
341
|
.then((r) => {
|
|
285
342
|
if (cancelled)
|
|
286
343
|
return;
|
|
287
344
|
if (!r.ok)
|
|
288
345
|
console.error(r.message);
|
|
289
|
-
|
|
346
|
+
setGlobalIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
|
|
290
347
|
})
|
|
291
348
|
.catch((e) => { console.error(e); if (!cancelled)
|
|
292
|
-
|
|
349
|
+
setGlobalIds({}); });
|
|
293
350
|
return () => { cancelled = true; };
|
|
294
351
|
}, [ops]);
|
|
352
|
+
// A selected global block loads its item for the canvas locale, once (spec 2026-09-13 §3).
|
|
353
|
+
const selectedItemName = selectedId ? globalItemOf(selectedId)?.name ?? null : null;
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (selectedItemName)
|
|
356
|
+
void globalEdit.load(selectedItemName, locale);
|
|
357
|
+
}, [selectedItemName, locale]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (globalEdit.loadError)
|
|
360
|
+
setBanner({ text: globalEdit.loadError });
|
|
361
|
+
}, [globalEdit.loadError]);
|
|
295
362
|
// Selecting from the outline must reveal the block. The canvas column is
|
|
296
363
|
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
297
364
|
// the full document), so scroll it to the rect the bridge reported.
|
|
@@ -311,12 +378,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
311
378
|
return next;
|
|
312
379
|
});
|
|
313
380
|
mark();
|
|
381
|
+
setDirty(true);
|
|
382
|
+
setTouched(true);
|
|
314
383
|
}, [mark]);
|
|
315
384
|
// ── inline editing (spec 2026-09-10) ──────────────────────────────────
|
|
316
385
|
const inline = useInlineSession({
|
|
317
386
|
iframeRef, postToCanvas,
|
|
318
387
|
patch: (node, path, value) => {
|
|
319
|
-
|
|
388
|
+
const item = globalItemOf(node);
|
|
389
|
+
if (item)
|
|
390
|
+
patchGlobal(item.name, path, value);
|
|
391
|
+
else
|
|
392
|
+
mutate((t) => patchNodePath(t, node, path, value));
|
|
320
393
|
// A second element marked with the same field follows the one being typed in.
|
|
321
394
|
if (typeof value === "string")
|
|
322
395
|
inlineRef.current?.writeText(node, path, value);
|
|
@@ -359,37 +432,42 @@ export function EditorView({ ops, registry, id }) {
|
|
|
359
432
|
};
|
|
360
433
|
const onFieldClick = (id, path, x, y) => {
|
|
361
434
|
const node = tree ? findNode(tree, id) : null;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
435
|
+
// A global block's fields are the item's — the section's own (spec 2026-09-13 §5); any other block's are its section's.
|
|
436
|
+
const item = globalItemOf(id);
|
|
437
|
+
const schema = item ?? (node ? sectionOf(node.type) : null);
|
|
438
|
+
if (!schema)
|
|
439
|
+
return;
|
|
440
|
+
const descriptor = descriptorAt(schema, path);
|
|
366
441
|
if (!descriptor) {
|
|
367
442
|
if (process.env.NODE_ENV !== "production") {
|
|
368
|
-
console.warn(`smoodly: "${
|
|
443
|
+
console.warn(`smoodly: "${item?.name ?? node?.type}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
|
|
369
444
|
}
|
|
370
445
|
return;
|
|
371
446
|
}
|
|
372
447
|
if (inline.session && inline.session.node === id && inline.session.path === path)
|
|
373
448
|
return;
|
|
374
|
-
|
|
449
|
+
// A custom section's columns edit in the inspector for now (register 2026-09-15): the click reveals
|
|
450
|
+
// the column's editor rather than opening an inline session on the canvas.
|
|
451
|
+
const kind = node?.type === CUSTOM_NODE_TYPE ? null
|
|
452
|
+
: descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
|
|
375
453
|
if (kind && inline.start({ node: id, path, kind }, { x, y }))
|
|
376
454
|
return;
|
|
377
455
|
// Any other field, or a text mark on a container: the inspector reveals its widget.
|
|
378
|
-
reveal(path,
|
|
456
|
+
reveal(path, schema);
|
|
379
457
|
};
|
|
380
|
-
// A zone bound to a
|
|
458
|
+
// A zone bound to a global item needs the item's row id (ops.globals.list),
|
|
381
459
|
// which can land after the draft did. Fill then; nothing else changes.
|
|
382
460
|
useEffect(() => {
|
|
383
|
-
if (!tree || !
|
|
461
|
+
if (!tree || !globalIds || !record)
|
|
384
462
|
return;
|
|
385
463
|
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
386
|
-
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections,
|
|
464
|
+
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
|
|
387
465
|
if (filled !== tree)
|
|
388
466
|
mutate(() => filled);
|
|
389
467
|
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
390
468
|
// or a new draft loads, not on every keystroke.
|
|
391
469
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
-
}, [
|
|
470
|
+
}, [globalIds, record, mutate, tree === null]);
|
|
393
471
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
394
472
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
395
473
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
@@ -399,11 +477,17 @@ export function EditorView({ ops, registry, id }) {
|
|
|
399
477
|
setPicker(null);
|
|
400
478
|
select(node.id);
|
|
401
479
|
};
|
|
402
|
-
const
|
|
403
|
-
const
|
|
480
|
+
const doInsertCustom = (zone, index) => {
|
|
481
|
+
const node = customNode(registry, newNodeId(CUSTOM_NODE_TYPE));
|
|
482
|
+
mutate((t) => insertNode(t, zone, index, node));
|
|
483
|
+
setPicker(null);
|
|
484
|
+
select(node.id);
|
|
485
|
+
};
|
|
486
|
+
const doInsertGlobal = (zone, index, item) => {
|
|
487
|
+
const ref = globalIds?.[item.name];
|
|
404
488
|
if (!ref)
|
|
405
489
|
return;
|
|
406
|
-
const node = placementNode(item, ref, newNodeId(
|
|
490
|
+
const node = placementNode(item, ref, newNodeId(GLOBAL_NODE_TYPE));
|
|
407
491
|
mutate((t) => insertNode(t, zone, index, node));
|
|
408
492
|
setPicker(null);
|
|
409
493
|
select(node.id);
|
|
@@ -415,13 +499,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
415
499
|
if (!zone || !zoneCanTake(zone))
|
|
416
500
|
return;
|
|
417
501
|
const index = nodesIn(zone).findIndex((n) => n.id === node.id) + 1;
|
|
502
|
+
// Deep copies: a shallow one would share every list (a custom section's rows) between the original and the copy.
|
|
418
503
|
const copy = {
|
|
419
504
|
...node,
|
|
420
505
|
id: newNodeId(node.type),
|
|
421
|
-
fields:
|
|
422
|
-
|
|
423
|
-
|
|
506
|
+
fields: structuredClone(node.fields),
|
|
507
|
+
settings: node.settings ? structuredClone(node.settings) : undefined,
|
|
508
|
+
styles: node.styles ? structuredClone(node.styles) : undefined,
|
|
424
509
|
};
|
|
510
|
+
if (node.css === undefined)
|
|
511
|
+
delete copy.css;
|
|
425
512
|
mutate((t) => insertNode(t, zone, index, copy));
|
|
426
513
|
select(copy.id);
|
|
427
514
|
};
|
|
@@ -438,16 +525,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
438
525
|
const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
|
|
439
526
|
const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
|
|
440
527
|
const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
|
|
528
|
+
const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css));
|
|
441
529
|
/** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
|
|
442
530
|
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
443
531
|
// ── publish ──────────────────────────────────────────────────────────
|
|
444
532
|
const doPublish = async () => {
|
|
445
533
|
setBanner(null);
|
|
534
|
+
setPageBusy("publish");
|
|
446
535
|
// Never publish behind a pending debounce.
|
|
447
536
|
const flushed = await flush();
|
|
448
537
|
if (!flushed) {
|
|
449
538
|
// The pre-publish flush failed (or is still behind, mid-retry) —
|
|
450
539
|
// publishing now would ship a stale draft. Bail instead.
|
|
540
|
+
setPageBusy(null);
|
|
451
541
|
setBanner({ text: "Couldn't save your latest changes — publish cancelled." });
|
|
452
542
|
return;
|
|
453
543
|
}
|
|
@@ -461,6 +551,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
461
551
|
// outright instead of resolving to a typed error.
|
|
462
552
|
result = { ok: false, code: "internal", message: "Publish failed — check your connection and try again." };
|
|
463
553
|
}
|
|
554
|
+
setPageBusy(null);
|
|
464
555
|
if (result.ok) {
|
|
465
556
|
setRecord(result.data);
|
|
466
557
|
setPublished(true);
|
|
@@ -470,6 +561,135 @@ export function EditorView({ ops, registry, id }) {
|
|
|
470
561
|
setBanner({ text: result.message, fields: result.fields });
|
|
471
562
|
}
|
|
472
563
|
};
|
|
564
|
+
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
|
|
565
|
+
const doDiscard = async () => {
|
|
566
|
+
if (!window.confirm("Discard your changes and restore the published version?"))
|
|
567
|
+
return;
|
|
568
|
+
setBanner(null);
|
|
569
|
+
setPageBusy("discard");
|
|
570
|
+
if (!(await flushAll())) {
|
|
571
|
+
setPageBusy(null);
|
|
572
|
+
setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
let result;
|
|
576
|
+
try {
|
|
577
|
+
result = await ops.pages.discardDraft(id, locale);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
581
|
+
}
|
|
582
|
+
setPageBusy(null);
|
|
583
|
+
if (!result.ok) {
|
|
584
|
+
setBanner({ text: result.message });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
setRecord(result.data);
|
|
588
|
+
setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
|
|
589
|
+
};
|
|
590
|
+
/** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
|
|
591
|
+
const doUnpublish = async () => {
|
|
592
|
+
setBanner(null);
|
|
593
|
+
setPageBusy("unpublish");
|
|
594
|
+
let result;
|
|
595
|
+
try {
|
|
596
|
+
result = await ops.pages.unpublish(id, locale);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
600
|
+
}
|
|
601
|
+
setPageBusy(null);
|
|
602
|
+
if (!result.ok) {
|
|
603
|
+
setBanner({ text: result.message });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
setRecord(result.data);
|
|
607
|
+
};
|
|
608
|
+
/** History: flush first so the list's draft row is current, then list the locale's versions. */
|
|
609
|
+
const doHistory = async () => {
|
|
610
|
+
setBanner(null);
|
|
611
|
+
const row = record?.locales[locale];
|
|
612
|
+
if (!row)
|
|
613
|
+
return;
|
|
614
|
+
if (!(await flushAll())) {
|
|
615
|
+
setBanner({ text: "Couldn't save your latest changes — history not opened." });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
let r;
|
|
619
|
+
try {
|
|
620
|
+
r = await ops.pages.listVersions(id, locale);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
624
|
+
}
|
|
625
|
+
if (!r.ok) {
|
|
626
|
+
setBanner({ text: r.message });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
setHistory(historyRows(r.data, row));
|
|
630
|
+
};
|
|
631
|
+
/** Restore: the version's tree becomes the draft; the canvas reloads from the store, as after Discard. */
|
|
632
|
+
const doRestore = async (versionId) => {
|
|
633
|
+
let result;
|
|
634
|
+
try {
|
|
635
|
+
result = await ops.pages.restore(id, locale, versionId);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
639
|
+
}
|
|
640
|
+
if (!result.ok) {
|
|
641
|
+
setBanner({ text: result.message });
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
setRecord(result.data);
|
|
645
|
+
setReloadKey((k) => k + 1);
|
|
646
|
+
return true;
|
|
647
|
+
};
|
|
648
|
+
/** The global card's own actions (spec 2026-09-13 §5, publish flow §2): the item in the canvas locale, never the page. */
|
|
649
|
+
const doPublishGlobal = async (name) => {
|
|
650
|
+
setBanner(null);
|
|
651
|
+
setGlobalBusy("publish");
|
|
652
|
+
const r = await globalEdit.setStatus(name, "published");
|
|
653
|
+
setGlobalBusy(null);
|
|
654
|
+
if (!r.ok)
|
|
655
|
+
setBanner({ text: r.message, fields: r.fields });
|
|
656
|
+
};
|
|
657
|
+
const doUnpublishGlobal = async (name) => {
|
|
658
|
+
setBanner(null);
|
|
659
|
+
setGlobalBusy("unpublish");
|
|
660
|
+
const r = await globalEdit.setStatus(name, "draft");
|
|
661
|
+
setGlobalBusy(null);
|
|
662
|
+
if (!r.ok)
|
|
663
|
+
setBanner({ text: r.message, fields: r.fields });
|
|
664
|
+
};
|
|
665
|
+
const doDiscardGlobal = async (name) => {
|
|
666
|
+
if (!window.confirm("Discard your changes and restore the published version?"))
|
|
667
|
+
return;
|
|
668
|
+
setBanner(null);
|
|
669
|
+
setGlobalBusy("discard");
|
|
670
|
+
if (!(await globalEdit.flush())) {
|
|
671
|
+
setGlobalBusy(null);
|
|
672
|
+
setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
let r;
|
|
676
|
+
try {
|
|
677
|
+
r = await ops.globals.discardDraft(name, locale);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
681
|
+
}
|
|
682
|
+
setGlobalBusy(null);
|
|
683
|
+
if (!r.ok) {
|
|
684
|
+
setBanner({ text: r.message });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// The item reloads from the store; the canvas refreshes through the same path as a save ack.
|
|
688
|
+
globalEdit.reset();
|
|
689
|
+
setGlobalDirty(false);
|
|
690
|
+
void globalEdit.load(name, locale);
|
|
691
|
+
inlineRef.current?.onSaved();
|
|
692
|
+
};
|
|
473
693
|
// ── keyboard ─────────────────────────────────────────────────────────
|
|
474
694
|
useEffect(() => {
|
|
475
695
|
const onKey = (e) => {
|
|
@@ -493,26 +713,37 @@ export function EditorView({ ops, registry, id }) {
|
|
|
493
713
|
}
|
|
494
714
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
495
715
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
496
|
-
const
|
|
716
|
+
const selectedGlobal = selected ? globalOf(selected) : null;
|
|
717
|
+
// A layout-rendered item: selected by its `global:` id, in no zone (spec 2026-09-13 §1).
|
|
718
|
+
const layoutItem = selectedId && !selected ? globalItemOf(selectedId) : null;
|
|
719
|
+
const selectedItem = selectedGlobal ?? layoutItem;
|
|
497
720
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
498
721
|
const selLocked = policyOf(selZone)?.kind === "locked";
|
|
499
722
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
500
|
-
const selRect =
|
|
723
|
+
const selRect = selectedId ? rectById.get(selectedId) : undefined;
|
|
501
724
|
const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
|
|
725
|
+
const hoverGlobal = hoverId ? globalItemOf(hoverId) !== null : false;
|
|
502
726
|
const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
|
|
503
727
|
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
504
728
|
const editing = fieldRectOf(inline.session);
|
|
505
729
|
const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
|
|
506
|
-
const selection = selected && selRect && (selectedSection ||
|
|
730
|
+
const selection = selected && selRect && (selectedSection || selectedGlobal)
|
|
507
731
|
? {
|
|
508
732
|
node: selected, rect: selRect,
|
|
509
|
-
title: selectedSection?.title ??
|
|
510
|
-
|
|
733
|
+
title: selectedSection?.title ?? selectedGlobal?.title ?? selected.type,
|
|
734
|
+
global: Boolean(selectedGlobal), locked: selLocked,
|
|
511
735
|
}
|
|
512
|
-
:
|
|
736
|
+
: layoutItem && selectedId && selRect
|
|
737
|
+
? {
|
|
738
|
+
node: { id: selectedId, type: GLOBAL_NODE_TYPE, fields: { item: layoutItem.name } },
|
|
739
|
+
rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
|
|
740
|
+
}
|
|
741
|
+
: null;
|
|
513
742
|
const labelOf = (node) => {
|
|
514
|
-
|
|
515
|
-
|
|
743
|
+
if (node.type === CUSTOM_NODE_TYPE)
|
|
744
|
+
return customNodeLabel(node);
|
|
745
|
+
const global = globalOf(node);
|
|
746
|
+
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
|
|
516
747
|
};
|
|
517
748
|
// Reveal is defined here, where the rect map exists; select() calls it
|
|
518
749
|
// through the ref so its own identity stays stable.
|
|
@@ -525,42 +756,71 @@ export function EditorView({ ops, registry, id }) {
|
|
|
525
756
|
scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
|
526
757
|
};
|
|
527
758
|
const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
|
|
528
|
-
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
759
|
+
const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
|
|
529
760
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
530
|
-
const
|
|
761
|
+
const activeGroups = tabs.find((t) => t.tab === activeTab)?.groups ?? [];
|
|
531
762
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
|
|
532
763
|
{
|
|
533
764
|
label: "Pages", href: `${BASE}/pages`,
|
|
534
765
|
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
535
766
|
},
|
|
536
767
|
{ label },
|
|
537
|
-
] }) }), 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" })),
|
|
768
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _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(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), 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-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
538
769
|
const { session } = inline.richtext;
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
|
|
770
|
+
const item = globalItemOf(session.node);
|
|
771
|
+
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
772
|
+
const schema = item ?? (node ? sectionOf(node.type) : null);
|
|
773
|
+
const fields = item ? globalEdit.items[item.name]?.fields : node?.fields;
|
|
774
|
+
if (!schema || !fields)
|
|
542
775
|
return null;
|
|
543
|
-
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(
|
|
544
|
-
|
|
776
|
+
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
|
|
777
|
+
inline.markChanged();
|
|
778
|
+
if (item)
|
|
779
|
+
patchGlobal(item.name, session.path, doc);
|
|
780
|
+
else
|
|
781
|
+
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
782
|
+
}, onEscape: inline.end }));
|
|
783
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _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) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
545
784
|
!isFixedNode(record, registry) &&
|
|
546
785
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
547
786
|
? () => { void doRemoveLocale(); }
|
|
548
|
-
: undefined })) : null,
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
787
|
+
: undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && selectedSection && (_jsx(InspectorFields, { groups: activeGroups, values: selected, render: ({ key, ns, descriptor }, { emptyLabel, scope }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: valueOf(selected, { key, ns, descriptor }), emptyLabel: emptyLabel, scope: scope, refOptions: ns === "fields" || ns === "settings" ? refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor) : undefined, inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
788
|
+
reveal(p, selectedSection); }, onChange: (value) => {
|
|
789
|
+
if (ns === "css")
|
|
790
|
+
doPatchCss(selected.id, value);
|
|
791
|
+
else
|
|
792
|
+
doPatch(selected.id, ns, key, value);
|
|
793
|
+
if (ns === "fields")
|
|
794
|
+
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
795
|
+
} }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
796
|
+
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
797
|
+
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
798
|
+
if (ns === "fields") {
|
|
799
|
+
const current = globalEdit.items[selectedItem.name];
|
|
800
|
+
if (current)
|
|
801
|
+
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
802
|
+
}
|
|
803
|
+
}, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
|
|
554
804
|
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
555
805
|
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
|
|
556
806
|
})), onAddFirst: (zone) => {
|
|
557
807
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
558
808
|
if (first)
|
|
559
809
|
doInsert(zone, 0, first);
|
|
560
|
-
} }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry,
|
|
810
|
+
} }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
|
|
561
811
|
}
|
|
562
812
|
/** The selected node's ref field (if any) resolves to its target
|
|
563
813
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
814
|
+
/** Where a global item's widget writes (spec 2026-09-13 §3, 2026-09-14 §5): fields at the key, the other namespaces under their reserved keys. */
|
|
815
|
+
function globalPath(ns, key) {
|
|
816
|
+
if (ns === "fields")
|
|
817
|
+
return key;
|
|
818
|
+
if (ns === "settings")
|
|
819
|
+
return `${SETTINGS_KEY}.${key}`;
|
|
820
|
+
if (ns === "styles")
|
|
821
|
+
return `${STYLES_KEY}.${key}`;
|
|
822
|
+
return CSS_KEY;
|
|
823
|
+
}
|
|
564
824
|
function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
|
|
565
825
|
if (descriptor.type !== "ref" && descriptor.type !== "refList")
|
|
566
826
|
return undefined;
|