smoodly 0.0.28 → 0.0.30
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 +123 -17
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/CanvasWarning.d.ts +5 -0
- package/dist/admin/editor/CanvasWarning.js +10 -0
- package/dist/admin/editor/EditorView.js +113 -26
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +6 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.js +26 -16
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
- package/dist/admin/editor/canvas-diagnosis.js +106 -0
- package/dist/admin/editor/crash-copy.d.ts +7 -0
- package/dist/admin/editor/crash-copy.js +13 -0
- package/dist/admin/editor/protocol.d.ts +2 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
- package/dist/admin/editor/useCanvasBridge.js +14 -7
- package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/next/bridge.js +4 -2
- package/dist/admin/next/index.d.ts +0 -1
- package/dist/admin/next/index.js +6 -1
- package/dist/admin/ops-impl.js +40 -9
- package/dist/admin/ops.d.ts +13 -0
- package/dist/admin/shell/AddRedirectModal.js +1 -1
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +20 -3
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin/shell/SettingsView.js +6 -2
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/settings.d.ts +4 -0
- package/dist/admin/shell/settings.js +5 -2
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/ActionMenu.d.ts +20 -0
- package/dist/admin/ui/ActionMenu.js +33 -7
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.js +3 -3
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +8 -0
- package/dist/admin/ui/confirm.js +17 -4
- package/dist/admin/ui/theme.js +29 -3
- package/dist/admin/validate.js +15 -47
- package/dist/custom/CustomSection.d.ts +1 -0
- package/dist/custom/CustomSection.js +4 -1
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +14 -0
- package/dist/next/canvas-boundary.d.ts +16 -0
- package/dist/next/canvas-boundary.js +25 -0
- package/dist/next/page-renderer.d.ts +3 -1
- package/dist/next/page-renderer.js +33 -4
- package/dist/pairing.js +10 -3
- package/dist/render.d.ts +12 -1
- package/dist/render.js +15 -4
- package/dist/section-crash.d.ts +11 -0
- package/dist/section-crash.js +16 -0
- package/dist/section-guard.d.ts +27 -0
- package/dist/section-guard.js +41 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +8 -0
- package/dist/supabase-entry-store.d.ts +2 -1
- package/dist/supabase-entry-store.js +27 -5
- package/dist/supabase-store.d.ts +2 -1
- package/dist/supabase-store.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The warning above a canvas Smoodly could not show (spec 2026-09-27
|
|
3
|
+
// shadowed-page detector §5): the editor's sentence, then the line for
|
|
4
|
+
// whoever fixes it, so the editor can forward it as is.
|
|
5
|
+
import { Banner } from "../ui/primitives.js";
|
|
6
|
+
import { canvasMessage } from "./canvas-diagnosis.js";
|
|
7
|
+
export function CanvasWarning({ cause, path }) {
|
|
8
|
+
const { editor, developer } = canvasMessage(cause, path);
|
|
9
|
+
return (_jsxs(Banner, { tone: "warn", children: [_jsx("p", { children: editor }), _jsxs("p", { children: [_jsx("strong", { children: "For your developer:" }), " ", developer] })] }));
|
|
10
|
+
}
|
|
@@ -30,13 +30,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
30
30
|
import { Eye } from "lucide-react";
|
|
31
31
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
32
32
|
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
|
|
33
|
-
import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
33
|
+
import { descriptorAt, getAtPath, unsetAtPath } from "../field-paths.js";
|
|
34
34
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
35
35
|
import { revealField } from "../forms/reveal.js";
|
|
36
36
|
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
37
37
|
import { tabOf, tabsFor } from "../forms/tabs.js";
|
|
38
38
|
import { InspectorFields, valueOf } from "./InspectorFields.js";
|
|
39
|
-
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
39
|
+
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, clearZone, unsetNodePath, } from "../tree-ops.js";
|
|
40
40
|
import { useDraftAutosave } from "./autosave.js";
|
|
41
41
|
import { CanvasOverlay } from "./CanvasOverlay.js";
|
|
42
42
|
import { InlineRichtext } from "./InlineRichtext.js";
|
|
@@ -50,6 +50,9 @@ import { GlobalInspector } from "./GlobalInspector.js";
|
|
|
50
50
|
import { localeFromUrl } from "./locale-url.js";
|
|
51
51
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
52
52
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
53
|
+
import { useCanvasDiagnosis } from "./useCanvasDiagnosis.js";
|
|
54
|
+
import { CanvasWarning } from "./CanvasWarning.js";
|
|
55
|
+
import { crashLines, publishCrashConfirm } from "./crash-copy.js";
|
|
53
56
|
import { useInlineSession } from "./useInlineSession.js";
|
|
54
57
|
import { usePageLocale } from "./usePageLocale.js";
|
|
55
58
|
import { useGlobalEdit } from "./useGlobalEdit.js";
|
|
@@ -63,6 +66,11 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
|
63
66
|
import { draftToken } from "../../draft-token.js";
|
|
64
67
|
import { isStale, staleInfoOf } from "../shell/stale.js";
|
|
65
68
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
69
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
70
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
71
|
+
import { UnknownNode } from "./UnknownNode.js";
|
|
72
|
+
import { isBroken, pageDrift } from "../drift.js";
|
|
73
|
+
import { driftMessage, misfitErrors } from "../drift-copy.js";
|
|
66
74
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
|
|
67
75
|
import { editorsOf } from "../shell/presence.js";
|
|
68
76
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -84,6 +92,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
84
92
|
const [reloadKey, setReloadKey] = useState(0);
|
|
85
93
|
const [paths, setPaths] = useState({});
|
|
86
94
|
const [redirects, setRedirects] = useState({});
|
|
95
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
87
96
|
const [urls, setUrls] = useState({});
|
|
88
97
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
89
98
|
const [parentLocales, setParentLocales] = useState([]);
|
|
@@ -112,6 +121,10 @@ export function EditorView({ ops, registry, id }) {
|
|
|
112
121
|
/** A global edit since the item's last save ack; and any global edit on this canvas yet. */
|
|
113
122
|
const [globalDirty, setGlobalDirty] = useState(false);
|
|
114
123
|
const [globalTouched, setGlobalTouched] = useState(false);
|
|
124
|
+
/** The editor changed something on this canvas. The locked-zone fill on load is not such a change: when
|
|
125
|
+
* another editor's fill landed first, the refusal and their saved broadcast load their version quietly. */
|
|
126
|
+
const editedRef = useRef(false);
|
|
127
|
+
editedRef.current = touched || globalTouched;
|
|
115
128
|
// Global item name → row id, loaded once per editor mount: the picker's
|
|
116
129
|
// Global group and a bound locked zone need the id to place a node.
|
|
117
130
|
const [globalIds, setGlobalIds] = useState(null);
|
|
@@ -143,8 +156,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
143
156
|
setDirty(false);
|
|
144
157
|
presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
|
|
145
158
|
}
|
|
146
|
-
else if (isStale(r))
|
|
147
|
-
setStale(staleInfoOf(r));
|
|
159
|
+
else if (isStale(r) && editedRef.current)
|
|
160
|
+
setStale(staleInfoOf(r)); // untouched: the effect below reloads quietly
|
|
148
161
|
return r;
|
|
149
162
|
},
|
|
150
163
|
onSaved: () => inlineRef.current?.onSaved(),
|
|
@@ -182,7 +195,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
182
195
|
// Canvas-originated selection: the node is under the cursor already, so
|
|
183
196
|
// this path deliberately skips the scroll every other selection does.
|
|
184
197
|
// (`onCanvasSelect` is declared further down; the handler runs at event time.)
|
|
185
|
-
const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
|
|
198
|
+
const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas, onFrameLoad, crashed } = useCanvasBridge({
|
|
186
199
|
iframeRef, iframeSrc,
|
|
187
200
|
onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
|
|
188
201
|
onHover: (id, path) => {
|
|
@@ -191,6 +204,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
191
204
|
},
|
|
192
205
|
onRendered: () => inlineRef.current?.onRendered(),
|
|
193
206
|
});
|
|
207
|
+
// A canvas the bridge never spoke from is diagnosed and named (spec 2026-09-27 shadowed-page detector).
|
|
208
|
+
const diagnosis = useCanvasDiagnosis(iframeSrc, geometry);
|
|
194
209
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
195
210
|
// at the site route (an iframe loaded before the cookie exists would
|
|
196
211
|
// render the published tree).
|
|
@@ -251,6 +266,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
251
266
|
setRecord(result.data.record);
|
|
252
267
|
setPaths(result.data.paths);
|
|
253
268
|
setRedirects(result.data.redirects);
|
|
269
|
+
setLiveFindings(result.data.liveFindings);
|
|
254
270
|
setUrls(result.data.urls);
|
|
255
271
|
setParentLocales(result.data.parentLocales);
|
|
256
272
|
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
@@ -290,6 +306,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
290
306
|
setRecord(result.data.record);
|
|
291
307
|
setPaths(result.data.paths);
|
|
292
308
|
setRedirects(result.data.redirects);
|
|
309
|
+
setLiveFindings(result.data.liveFindings);
|
|
293
310
|
setUrls(result.data.urls);
|
|
294
311
|
setParentLocales(result.data.parentLocales);
|
|
295
312
|
}, [ops, id, locale]);
|
|
@@ -326,6 +343,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
326
343
|
const name = globalNameOf(id);
|
|
327
344
|
return name ? registryRef.current.globals.find((s) => s.name === name) ?? null : null;
|
|
328
345
|
}, [globalOf]);
|
|
346
|
+
// The crashed sections' banner lines (spec 2026-09-27 section guard §6): the section's title from the
|
|
347
|
+
// registry, a placed global's item title, the node type when the registry has no name for it.
|
|
348
|
+
const crashTitleOf = (id) => {
|
|
349
|
+
const node = treeRef.current ? findNode(treeRef.current, id) : null;
|
|
350
|
+
if (!node)
|
|
351
|
+
return "Section";
|
|
352
|
+
return globalItemOf(id)?.title ?? sectionOf(node.type)?.title ?? node.type;
|
|
353
|
+
};
|
|
354
|
+
const crashedLines = crashLines(crashed, crashTitleOf);
|
|
329
355
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
330
356
|
const refTargets = useMemo(() => {
|
|
331
357
|
const out = {};
|
|
@@ -390,16 +416,26 @@ export function EditorView({ ops, registry, id }) {
|
|
|
390
416
|
}, [globalEdit.stale]);
|
|
391
417
|
/** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
|
|
392
418
|
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
393
|
-
|
|
419
|
+
/** Their version without the freeze: for a view with nothing of the editor's own to lose. */
|
|
420
|
+
const loadTheirsQuietly = useCallback(() => { reset(); keepSelectionRef.current = true; setReloadKey((k) => k + 1); }, [reset]);
|
|
421
|
+
// A refused save the editor never made — the locked-zone fill, when another editor's fill landed first.
|
|
422
|
+
// Decided by editedRef, not by `stale`: the refusal's setStale and the autosave's state can render apart.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (saveState === "stale" && !editedRef.current)
|
|
425
|
+
loadTheirsQuietly();
|
|
426
|
+
}, [saveState, loadTheirsQuietly]);
|
|
427
|
+
// Someone else saved this page (§5): clean or untouched → their version silently; the editor's own unsaved edits → the freeze at once.
|
|
394
428
|
useSavedElsewhere(target, (event) => {
|
|
395
429
|
if (stale)
|
|
396
430
|
return;
|
|
397
|
-
if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
|
|
398
|
-
setStale({ by: event.email, at: Date.now() });
|
|
399
|
-
else {
|
|
431
|
+
if (!(dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")) {
|
|
400
432
|
keepSelectionRef.current = true;
|
|
401
433
|
setReloadKey((k) => k + 1);
|
|
402
434
|
}
|
|
435
|
+
else if (editedRef.current)
|
|
436
|
+
setStale({ by: event.email, at: Date.now() });
|
|
437
|
+
else
|
|
438
|
+
loadTheirsQuietly(); // only the fill is pending: drop it
|
|
403
439
|
});
|
|
404
440
|
// Selecting from the outline must reveal the block. The canvas column is
|
|
405
441
|
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
@@ -504,15 +540,34 @@ export function EditorView({ ops, registry, id }) {
|
|
|
504
540
|
return;
|
|
505
541
|
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
506
542
|
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds);
|
|
507
|
-
|
|
508
|
-
|
|
543
|
+
// Saved like the fill on load, and like it not an edit of the editor's (editedRef).
|
|
544
|
+
if (filled !== tree) {
|
|
545
|
+
setTree(filled);
|
|
546
|
+
treeRef.current = filled;
|
|
547
|
+
mark();
|
|
548
|
+
}
|
|
509
549
|
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
510
550
|
// or a new draft loads, not on every keystroke.
|
|
511
551
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
512
|
-
}, [globalIds, record,
|
|
552
|
+
}, [globalIds, record, mark, tree === null]);
|
|
513
553
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
514
554
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
515
555
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
556
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry, grouped by
|
|
557
|
+
// the first path segment — a node id, `zone:<name>` or `page`.
|
|
558
|
+
const findings = useMemo(() => (record && tree ? pageDrift(registry, record.template, tree) : []), [record, tree, registry]);
|
|
559
|
+
const findingsAt = useMemo(() => {
|
|
560
|
+
const by = new Map();
|
|
561
|
+
for (const f of findings) {
|
|
562
|
+
const key = f.path.split(".")[0];
|
|
563
|
+
by.set(key, [...(by.get(key) ?? []), f]);
|
|
564
|
+
}
|
|
565
|
+
return by;
|
|
566
|
+
}, [findings]);
|
|
567
|
+
const markOf = (nodeId) => {
|
|
568
|
+
const fs = findingsAt.get(nodeId);
|
|
569
|
+
return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
|
|
570
|
+
};
|
|
516
571
|
const doInsert = (zone, index, section) => {
|
|
517
572
|
const node = nodeFromSample(section, newNodeId(section.name));
|
|
518
573
|
mutate((t) => insertNode(t, zone, index, node));
|
|
@@ -579,6 +634,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
579
634
|
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
580
635
|
// ── publish ──────────────────────────────────────────────────────────
|
|
581
636
|
const doPublish = async () => {
|
|
637
|
+
// A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
|
|
638
|
+
if (crashedLines.length > 0 && !(await confirm(publishCrashConfirm(crashedLines))))
|
|
639
|
+
return;
|
|
582
640
|
setBanner(null);
|
|
583
641
|
setPageBusy("publish");
|
|
584
642
|
// Never publish behind a pending debounce.
|
|
@@ -794,10 +852,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
794
852
|
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
795
853
|
const editing = fieldRectOf(inline.session);
|
|
796
854
|
const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
|
|
797
|
-
const selection = selected && selRect
|
|
855
|
+
const selection = selected && selRect
|
|
798
856
|
? {
|
|
857
|
+
// An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
|
|
799
858
|
node: selected, rect: selRect,
|
|
800
|
-
title: selectedSection?.title ?? selectedGlobal?.title ?? selected
|
|
859
|
+
title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
|
|
801
860
|
global: Boolean(selectedGlobal), locked: selLocked,
|
|
802
861
|
}
|
|
803
862
|
: layoutItem && selectedId && selRect
|
|
@@ -806,12 +865,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
806
865
|
rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
|
|
807
866
|
}
|
|
808
867
|
: null;
|
|
809
|
-
|
|
868
|
+
function labelOf(node) {
|
|
810
869
|
if (node.type === CUSTOM_NODE_TYPE)
|
|
811
870
|
return customNodeLabel(node);
|
|
812
871
|
const global = globalOf(node);
|
|
813
|
-
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
|
|
814
|
-
}
|
|
872
|
+
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
|
|
873
|
+
}
|
|
815
874
|
// Reveal is defined here, where the rect map exists; select() calls it
|
|
816
875
|
// through the ref so its own identity stays stable.
|
|
817
876
|
revealRef.current = (id) => {
|
|
@@ -832,8 +891,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
832
891
|
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); } })),
|
|
833
892
|
},
|
|
834
893
|
{ label },
|
|
835
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), 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", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner ||
|
|
836
|
-
setSelectedId(null); }, 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 && (() => {
|
|
894
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), 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", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_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 ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
895
|
+
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: onFrameLoad, 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 && (() => {
|
|
837
896
|
const { session } = inline.richtext;
|
|
838
897
|
const item = globalItemOf(session.node);
|
|
839
898
|
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
@@ -848,11 +907,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
848
907
|
else
|
|
849
908
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
850
909
|
}, onEscape: inline.end }));
|
|
851
|
-
})()] }) })] }), _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, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
910
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : 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, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_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], redirects: redirects[locale] ?? [], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
852
911
|
!isFixedNode(record, registry) &&
|
|
853
912
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
854
913
|
? () => { void doRemoveLocale(); }
|
|
855
|
-
: 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)
|
|
914
|
+
: undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && findingsAt.get(selected.id)?.filter((f) => f.kind === "zone-disallowed").map((f) => (_jsx("p", { className: "sm-outline-note", children: driftMessage(f) }, f.path))), 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, errors: ns === "css" ? undefined : misfitErrorsIn(findingsAt.get(selected.id) ?? [], selected.id, ns), 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)
|
|
856
915
|
reveal(p, selectedSection); }, onChange: (value) => {
|
|
857
916
|
if (ns === "css")
|
|
858
917
|
doPatchCss(selected.id, value);
|
|
@@ -860,7 +919,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
860
919
|
doPatch(selected.id, ns, key, value);
|
|
861
920
|
if (ns === "fields")
|
|
862
921
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
863
|
-
} }, `${selected.id}.${ns}.${key}`)) })),
|
|
922
|
+
} }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
|
|
923
|
+
// Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
|
|
924
|
+
_jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, 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)
|
|
864
925
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
865
926
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
866
927
|
if (ns === "fields") {
|
|
@@ -868,10 +929,23 @@ export function EditorView({ ops, registry, id }) {
|
|
|
868
929
|
if (current)
|
|
869
930
|
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
870
931
|
}
|
|
871
|
-
}, 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:
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
932
|
+
}, 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: [
|
|
933
|
+
...zoneOrder.map((zone) => ({
|
|
934
|
+
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
935
|
+
note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
|
|
936
|
+
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
|
|
937
|
+
})),
|
|
938
|
+
// Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
|
|
939
|
+
...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
|
|
940
|
+
zone, locked: false, canTake: false, removed: true,
|
|
941
|
+
nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
|
|
942
|
+
})),
|
|
943
|
+
], onClearZone: async (zone) => {
|
|
944
|
+
const count = tree?.zones[zone]?.length ?? 0;
|
|
945
|
+
if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
|
|
946
|
+
return;
|
|
947
|
+
mutate((t) => clearZone(t, zone));
|
|
948
|
+
}, onAddFirst: (zone) => {
|
|
875
949
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
876
950
|
if (first)
|
|
877
951
|
doInsert(zone, 0, first);
|
|
@@ -879,6 +953,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
879
953
|
}
|
|
880
954
|
/** The selected node's ref field (if any) resolves to its target
|
|
881
955
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
956
|
+
/** A node's misfit markers for one namespace's widgets (spec 2026-09-27 editor safety §5): a widget's
|
|
957
|
+
* path is its key within the namespace, so `settings.` / `styles.` come off and the rest stay fields'. */
|
|
958
|
+
function misfitErrorsIn(findings, nodeId, ns) {
|
|
959
|
+
const all = misfitErrors(findings, nodeId);
|
|
960
|
+
const out = {};
|
|
961
|
+
for (const [path, message] of Object.entries(all)) {
|
|
962
|
+
if (ns === "fields" && !path.startsWith("settings.") && !path.startsWith("styles."))
|
|
963
|
+
out[path] = message;
|
|
964
|
+
else if (ns !== "fields" && path.startsWith(`${ns}.`))
|
|
965
|
+
out[path.slice(ns.length + 1)] = message;
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
882
969
|
/** 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. */
|
|
883
970
|
function globalPath(ns, key) {
|
|
884
971
|
if (ns === "fields")
|
|
@@ -11,6 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
import { useEffect, useMemo } from "react";
|
|
12
12
|
import { createPortal } from "react-dom";
|
|
13
13
|
import { EditorContent, useEditorState } from "@tiptap/react";
|
|
14
|
+
import { allowedOf } from "../../toolset.js";
|
|
14
15
|
import { MediaPicker } from "../forms/MediaPicker.js";
|
|
15
16
|
import { RichtextInsertMenu } from "../forms/RichtextInsertMenu.js";
|
|
16
17
|
import { RichtextLinkRow, RichtextMenu } from "../forms/RichtextPopovers.js";
|
|
@@ -20,7 +21,8 @@ import { FloatingToolbar } from "./FloatingToolbar.js";
|
|
|
20
21
|
export function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }) {
|
|
21
22
|
const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-inline-richtext", onEscape });
|
|
22
23
|
const { editor } = rt;
|
|
23
|
-
const
|
|
24
|
+
const allowed = useMemo(() => allowedOf(toolset).elements, [toolset]);
|
|
25
|
+
const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true, elements: allowed }), [mount.session.path, allowed]);
|
|
24
26
|
// The caret where the click landed, once the editor exists.
|
|
25
27
|
useEffect(() => {
|
|
26
28
|
if (!editor)
|
|
@@ -6,7 +6,12 @@ type ZoneRows = {
|
|
|
6
6
|
nodes: {
|
|
7
7
|
node: TreeNode;
|
|
8
8
|
label: string;
|
|
9
|
+
mark?: "broken" | "orphan";
|
|
9
10
|
}[];
|
|
11
|
+
/** A stored zone the page type no longer declares: one row with Clear zone. */
|
|
12
|
+
removed?: boolean;
|
|
13
|
+
/** A zone rule the stored nodes break — "Zone `main` allows 3 sections and has 5." */
|
|
14
|
+
note?: string;
|
|
10
15
|
};
|
|
11
16
|
type Handlers = {
|
|
12
17
|
selectedId: string | null;
|
|
@@ -16,6 +21,8 @@ type Handlers = {
|
|
|
16
21
|
/** The drop and the grip's arrow keys: `index` in the resulting order. */
|
|
17
22
|
onMoveTo(id: string, index: number): void;
|
|
18
23
|
onRemove(id: string): void;
|
|
24
|
+
/** A removed zone's Clear zone. */
|
|
25
|
+
onClearZone?(zone: string): void;
|
|
19
26
|
};
|
|
20
27
|
export declare function Outline({ zones, ...handlers }: {
|
|
21
28
|
zones: ZoneRows[];
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
|
|
3
|
+
import { Button, Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
|
|
4
4
|
import { useSortable } from "../ui/useSortable.js";
|
|
5
5
|
export function Outline({ zones, ...handlers }) {
|
|
6
6
|
return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Page Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
|
|
7
7
|
}
|
|
8
|
-
function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMoveTo, onRemove }) {
|
|
8
|
+
function OutlineZone({ zone, locked, canTake, nodes, removed, note, selectedId, onSelect, onAddFirst, onMoveTo, onRemove, onClearZone }) {
|
|
9
9
|
const sortable = useSortable({
|
|
10
|
-
count: locked ? 0 : nodes.length,
|
|
10
|
+
count: locked || removed ? 0 : nodes.length,
|
|
11
11
|
onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
|
|
12
12
|
});
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if (removed) {
|
|
14
|
+
return (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, "data-testid": "removed-zone", children: _jsxs("div", { className: "sm-outline-row sm-outline-row--orphan sm-outline-row--removed", children: [_jsxs("span", { className: "sm-outline-row__label", children: ["Removed zone `", zone, "` \u00B7 ", nodes.length, " ", nodes.length === 1 ? "section" : "sections"] }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => onClearZone?.(zone), children: "Clear zone" })] }) }));
|
|
15
|
+
}
|
|
16
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), note && _jsx("p", { className: "sm-outline-note", children: note }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
|
|
17
|
+
onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label, mark }) => (_jsxs("div", { className: cx("sm-outline-row sm-outline-row--locked sm-outline-row--clickable", mark && `sm-outline-row--${mark}`), "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label, mark }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", mark && `sm-outline-row--${mark}`, sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
|
|
15
18
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { PageTree } from "../../render.tsx";
|
|
2
2
|
import type { PageRecord } from "../../store.ts";
|
|
3
3
|
import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
4
|
+
import type { Finding } from "../drift.ts";
|
|
4
5
|
export type PageTab = "SEO & Social" | "Settings";
|
|
5
6
|
export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
|
|
6
|
-
export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }: {
|
|
7
|
+
export declare function PageInspector({ record, locale, registry, tree, path, fixed, redirects, drift, onRename, onRemoveLocale, onPatch, onClearPageField }: {
|
|
7
8
|
record: PageRecord;
|
|
8
9
|
locale: string;
|
|
9
10
|
registry: AdminRegistry;
|
|
@@ -21,4 +22,8 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
|
|
|
21
22
|
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
22
23
|
onRemoveLocale?: () => void;
|
|
23
24
|
onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
|
|
25
|
+
/** The page fields' drift (spec 2026-09-27 editor safety §5): paths under `page.fields.`. */
|
|
26
|
+
drift?: Finding[];
|
|
27
|
+
/** An orphan page field's Clear. */
|
|
28
|
+
onClearPageField?(key: string): void;
|
|
24
29
|
}): import("react").JSX.Element;
|
|
@@ -7,18 +7,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
// reset when the page changes.
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
9
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
10
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
11
|
+
import { misfitErrors } from "../drift-copy.js";
|
|
10
12
|
import { Button, Tabs } from "../ui/primitives.js";
|
|
11
13
|
import { PageSettings } from "./PageSettings.js";
|
|
12
14
|
import { SeoSettings } from "./SeoSettings.js";
|
|
13
15
|
export function pageTabsFor(template) {
|
|
14
16
|
return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
|
|
15
17
|
}
|
|
16
|
-
export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, onRename, onRemoveLocale, onPatch }) {
|
|
18
|
+
export function PageInspector({ record, locale, registry, tree, path, fixed, redirects, drift = [], onRename, onRemoveLocale, onPatch, onClearPageField }) {
|
|
17
19
|
const template = registry.pages.find((p) => p.name === record.template);
|
|
18
20
|
const tabs = pageTabsFor(template);
|
|
19
21
|
const [tab, setTab] = useState(tabs[0]);
|
|
20
22
|
useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
21
23
|
const active = tabs.includes(tab) ? tab : tabs[0];
|
|
22
24
|
const title = record.locales[locale]?.title ?? "";
|
|
23
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
25
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], errors: misfitErrors(drift, "page.fields"), onChange: (value) => onPatch("fields", key, value) }, key))), onClearPageField && _jsx(OrphanFields, { orphans: drift, strip: "page.fields", onClear: onClearPageField }), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
|
|
24
26
|
}
|
|
@@ -7,7 +7,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
// the Settings tab of PageInspector while no block is selected; the SEO
|
|
8
8
|
// tab and the remove-from-locale button live there (spec 2026-09-10 page
|
|
9
9
|
// fields §5).
|
|
10
|
-
import { useEffect, useState } from "react";
|
|
10
|
+
import { useEffect, useRef, useState } from "react";
|
|
11
11
|
import { updatedLine } from "../ui/format.js";
|
|
12
12
|
import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
|
|
13
13
|
import { confirmLiveUrl } from "../shell/live-url.js";
|
|
@@ -18,6 +18,8 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
|
|
|
18
18
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
19
19
|
const [error, setError] = useState(null);
|
|
20
20
|
const adminHref = useAdminHref();
|
|
21
|
+
/** One commit at a time: Enter commits, then the confirm's autofocus blurs the input, and that blur must not ask again. */
|
|
22
|
+
const committing = useRef(false);
|
|
21
23
|
const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
22
24
|
// The record changes identity on every rename; re-seed the inputs from it.
|
|
23
25
|
useEffect(() => {
|
|
@@ -26,25 +28,33 @@ export function PageSettings({ record, locale, registry, path, fixed, redirects,
|
|
|
26
28
|
if (!row)
|
|
27
29
|
return null;
|
|
28
30
|
const commit = async (key) => {
|
|
29
|
-
|
|
30
|
-
const next = draft[key];
|
|
31
|
-
if (next === row[key])
|
|
31
|
+
if (committing.current)
|
|
32
32
|
return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!live.ok) {
|
|
42
|
-
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
33
|
+
committing.current = true;
|
|
34
|
+
try {
|
|
35
|
+
setError(null);
|
|
36
|
+
const next = draft[key];
|
|
37
|
+
if (next === row[key])
|
|
38
|
+
return;
|
|
39
|
+
if (next === "") {
|
|
40
|
+
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
43
41
|
return;
|
|
44
42
|
}
|
|
45
|
-
redirect
|
|
43
|
+
// A published slug is a live URL: one confirm with the redirect box, Cancel restores the input (members management §5, redirects §6).
|
|
44
|
+
let redirect = true;
|
|
45
|
+
if (key === "slug") {
|
|
46
|
+
const live = await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path });
|
|
47
|
+
if (!live.ok) {
|
|
48
|
+
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
redirect = live.redirect;
|
|
52
|
+
}
|
|
53
|
+
setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
committing.current = false;
|
|
46
57
|
}
|
|
47
|
-
setError(await onRename(key === "slug" ? { slug: next, redirect } : { title: next }));
|
|
48
58
|
};
|
|
49
59
|
const onKey = (key) => (e) => {
|
|
50
60
|
if (e.key === "Enter") {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Button } from "../ui/primitives.js";
|
|
4
|
+
const shown = (v) => (typeof v === "string" ? v : JSON.stringify(v));
|
|
5
|
+
export function UnknownNode({ node, label, onRemove }) {
|
|
6
|
+
const values = Object.entries(node.fields ?? {});
|
|
7
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-title", children: label }) }), _jsxs("div", { className: "sm-inspector__body", "data-testid": "unknown-node", children: [_jsxs("p", { className: "sm-hint", children: ["The code no longer registers `", node.type, "`, so it can't be edited or shown on the site."] }), values.map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: shown(value) }), _jsx(Button, { variant: "secondary", onClick: () => { void navigator.clipboard?.writeText(shown(value)); }, children: "Copy" })] }, key))), _jsx(Button, { variant: "danger", style: { alignSelf: "flex-start" }, onClick: onRemove, children: "Remove section" })] })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** The meta Smoodly's page renderer writes in draft mode (§3). */
|
|
2
|
+
export declare const DRAFT_META_NAME = "smoodly-draft";
|
|
3
|
+
/** The attribute on the <style> the editor bridge server-renders. */
|
|
4
|
+
export declare const BRIDGE_ATTR = "data-smoodly-bridge";
|
|
5
|
+
export type CanvasProbe = {
|
|
6
|
+
kind: "failed";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "response";
|
|
9
|
+
requested: string;
|
|
10
|
+
url: string;
|
|
11
|
+
status: number;
|
|
12
|
+
redirected: boolean;
|
|
13
|
+
html: string;
|
|
14
|
+
frameOptions: string | null;
|
|
15
|
+
csp: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type CanvasCause = {
|
|
18
|
+
kind: "unreachable";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "redirect";
|
|
21
|
+
to: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "notFound";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "crashed";
|
|
26
|
+
status: number;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "refused";
|
|
29
|
+
status: number;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "shadowed";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "noSearchParams";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "framing";
|
|
36
|
+
header: string;
|
|
37
|
+
value: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "script";
|
|
40
|
+
};
|
|
41
|
+
/** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
|
|
42
|
+
export declare function probeOf(requested: string, res: Response): Promise<CanvasProbe>;
|
|
43
|
+
/** The first matching cause, in the spec's order (§4). */
|
|
44
|
+
export declare function diagnoseCanvas(probe: CanvasProbe): CanvasCause;
|
|
45
|
+
/** The warning's two lines for a cause (§5, verbatim). */
|
|
46
|
+
export declare function canvasMessage(cause: CanvasCause, path: string): {
|
|
47
|
+
editor: string;
|
|
48
|
+
developer: string;
|
|
49
|
+
};
|