smoodly 0.0.28 → 0.0.29
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 +48 -8
- 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/EditorView.js +67 -15
- 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/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/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/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/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/render.js +7 -2
- 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
|
@@ -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";
|
|
@@ -63,6 +63,11 @@ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
|
63
63
|
import { draftToken } from "../../draft-token.js";
|
|
64
64
|
import { isStale, staleInfoOf } from "../shell/stale.js";
|
|
65
65
|
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
66
|
+
import { DriftNotice } from "../ui/DriftNotice.js";
|
|
67
|
+
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
68
|
+
import { UnknownNode } from "./UnknownNode.js";
|
|
69
|
+
import { isBroken, pageDrift } from "../drift.js";
|
|
70
|
+
import { driftMessage, misfitErrors } from "../drift-copy.js";
|
|
66
71
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
|
|
67
72
|
import { editorsOf } from "../shell/presence.js";
|
|
68
73
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -84,6 +89,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
84
89
|
const [reloadKey, setReloadKey] = useState(0);
|
|
85
90
|
const [paths, setPaths] = useState({});
|
|
86
91
|
const [redirects, setRedirects] = useState({});
|
|
92
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
87
93
|
const [urls, setUrls] = useState({});
|
|
88
94
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
89
95
|
const [parentLocales, setParentLocales] = useState([]);
|
|
@@ -251,6 +257,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
251
257
|
setRecord(result.data.record);
|
|
252
258
|
setPaths(result.data.paths);
|
|
253
259
|
setRedirects(result.data.redirects);
|
|
260
|
+
setLiveFindings(result.data.liveFindings);
|
|
254
261
|
setUrls(result.data.urls);
|
|
255
262
|
setParentLocales(result.data.parentLocales);
|
|
256
263
|
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
@@ -290,6 +297,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
290
297
|
setRecord(result.data.record);
|
|
291
298
|
setPaths(result.data.paths);
|
|
292
299
|
setRedirects(result.data.redirects);
|
|
300
|
+
setLiveFindings(result.data.liveFindings);
|
|
293
301
|
setUrls(result.data.urls);
|
|
294
302
|
setParentLocales(result.data.parentLocales);
|
|
295
303
|
}, [ops, id, locale]);
|
|
@@ -513,6 +521,21 @@ export function EditorView({ ops, registry, id }) {
|
|
|
513
521
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
514
522
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
515
523
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
524
|
+
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry, grouped by
|
|
525
|
+
// the first path segment — a node id, `zone:<name>` or `page`.
|
|
526
|
+
const findings = useMemo(() => (record && tree ? pageDrift(registry, record.template, tree) : []), [record, tree, registry]);
|
|
527
|
+
const findingsAt = useMemo(() => {
|
|
528
|
+
const by = new Map();
|
|
529
|
+
for (const f of findings) {
|
|
530
|
+
const key = f.path.split(".")[0];
|
|
531
|
+
by.set(key, [...(by.get(key) ?? []), f]);
|
|
532
|
+
}
|
|
533
|
+
return by;
|
|
534
|
+
}, [findings]);
|
|
535
|
+
const markOf = (nodeId) => {
|
|
536
|
+
const fs = findingsAt.get(nodeId);
|
|
537
|
+
return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
|
|
538
|
+
};
|
|
516
539
|
const doInsert = (zone, index, section) => {
|
|
517
540
|
const node = nodeFromSample(section, newNodeId(section.name));
|
|
518
541
|
mutate((t) => insertNode(t, zone, index, node));
|
|
@@ -794,10 +817,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
794
817
|
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
795
818
|
const editing = fieldRectOf(inline.session);
|
|
796
819
|
const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
|
|
797
|
-
const selection = selected && selRect
|
|
820
|
+
const selection = selected && selRect
|
|
798
821
|
? {
|
|
822
|
+
// An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
|
|
799
823
|
node: selected, rect: selRect,
|
|
800
|
-
title: selectedSection?.title ?? selectedGlobal?.title ?? selected
|
|
824
|
+
title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
|
|
801
825
|
global: Boolean(selectedGlobal), locked: selLocked,
|
|
802
826
|
}
|
|
803
827
|
: layoutItem && selectedId && selRect
|
|
@@ -806,12 +830,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
806
830
|
rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
|
|
807
831
|
}
|
|
808
832
|
: null;
|
|
809
|
-
|
|
833
|
+
function labelOf(node) {
|
|
810
834
|
if (node.type === CUSTOM_NODE_TYPE)
|
|
811
835
|
return customNodeLabel(node);
|
|
812
836
|
const global = globalOf(node);
|
|
813
|
-
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
|
|
814
|
-
}
|
|
837
|
+
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
|
|
838
|
+
}
|
|
815
839
|
// Reveal is defined here, where the rect map exists; select() calls it
|
|
816
840
|
// through the ref so its own identity stays stable.
|
|
817
841
|
revealRef.current = (id) => {
|
|
@@ -832,7 +856,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
832
856
|
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
857
|
},
|
|
834
858
|
{ 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 || 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, onClick: (e) => { if (e.target === e.currentTarget)
|
|
859
|
+
] }) }), 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 || 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, onClick: (e) => { if (e.target === e.currentTarget)
|
|
836
860
|
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 && (() => {
|
|
837
861
|
const { session } = inline.richtext;
|
|
838
862
|
const item = globalItemOf(session.node);
|
|
@@ -848,11 +872,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
848
872
|
else
|
|
849
873
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
850
874
|
}, 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 &&
|
|
875
|
+
})()] }) })] }), _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
876
|
!isFixedNode(record, registry) &&
|
|
853
877
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
854
878
|
? () => { 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)
|
|
879
|
+
: 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
880
|
reveal(p, selectedSection); }, onChange: (value) => {
|
|
857
881
|
if (ns === "css")
|
|
858
882
|
doPatchCss(selected.id, value);
|
|
@@ -860,7 +884,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
860
884
|
doPatch(selected.id, ns, key, value);
|
|
861
885
|
if (ns === "fields")
|
|
862
886
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
863
|
-
} }, `${selected.id}.${ns}.${key}`)) })),
|
|
887
|
+
} }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
|
|
888
|
+
// Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
|
|
889
|
+
_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
890
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
865
891
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
866
892
|
if (ns === "fields") {
|
|
@@ -868,10 +894,23 @@ export function EditorView({ ops, registry, id }) {
|
|
|
868
894
|
if (current)
|
|
869
895
|
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
870
896
|
}
|
|
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
|
-
|
|
897
|
+
}, 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: [
|
|
898
|
+
...zoneOrder.map((zone) => ({
|
|
899
|
+
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
900
|
+
note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
|
|
901
|
+
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
|
|
902
|
+
})),
|
|
903
|
+
// Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
|
|
904
|
+
...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
|
|
905
|
+
zone, locked: false, canTake: false, removed: true,
|
|
906
|
+
nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
|
|
907
|
+
})),
|
|
908
|
+
], onClearZone: async (zone) => {
|
|
909
|
+
const count = tree?.zones[zone]?.length ?? 0;
|
|
910
|
+
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 })))
|
|
911
|
+
return;
|
|
912
|
+
mutate((t) => clearZone(t, zone));
|
|
913
|
+
}, onAddFirst: (zone) => {
|
|
875
914
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
876
915
|
if (first)
|
|
877
916
|
doInsert(zone, 0, first);
|
|
@@ -879,6 +918,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
879
918
|
}
|
|
880
919
|
/** The selected node's ref field (if any) resolves to its target
|
|
881
920
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
921
|
+
/** A node's misfit markers for one namespace's widgets (spec 2026-09-27 editor safety §5): a widget's
|
|
922
|
+
* path is its key within the namespace, so `settings.` / `styles.` come off and the rest stay fields'. */
|
|
923
|
+
function misfitErrorsIn(findings, nodeId, ns) {
|
|
924
|
+
const all = misfitErrors(findings, nodeId);
|
|
925
|
+
const out = {};
|
|
926
|
+
for (const [path, message] of Object.entries(all)) {
|
|
927
|
+
if (ns === "fields" && !path.startsWith("settings.") && !path.startsWith("styles."))
|
|
928
|
+
out[path] = message;
|
|
929
|
+
else if (ns !== "fields" && path.startsWith(`${ns}.`))
|
|
930
|
+
out[path.slice(ns.length + 1)] = message;
|
|
931
|
+
}
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
882
934
|
/** 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
935
|
function globalPath(ns, key) {
|
|
884
936
|
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
|
+
}
|
|
@@ -11,3 +11,6 @@ export declare function getAtPath(fields: Record<string, unknown> | undefined, p
|
|
|
11
11
|
* siblings keep their identity — and keeps arrays arrays. Missing
|
|
12
12
|
* objects along the way are created. */
|
|
13
13
|
export declare function setAtPath(fields: Record<string, unknown> | undefined, path: string, value: unknown): Record<string, unknown>;
|
|
14
|
+
/** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
|
|
15
|
+
* Copies only the spine; a missing path returns an equal copy. */
|
|
16
|
+
export declare function unsetAtPath(fields: Record<string, unknown> | undefined, path: string): Record<string, unknown>;
|
|
@@ -47,3 +47,29 @@ export function setAtPath(fields, path, value) {
|
|
|
47
47
|
};
|
|
48
48
|
return set(fields ?? {}, splitPath(path));
|
|
49
49
|
}
|
|
50
|
+
/** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
|
|
51
|
+
* Copies only the spine; a missing path returns an equal copy. */
|
|
52
|
+
export function unsetAtPath(fields, path) {
|
|
53
|
+
const unset = (obj, segments) => {
|
|
54
|
+
if (obj === null || typeof obj !== "object")
|
|
55
|
+
return obj;
|
|
56
|
+
const [head, ...rest] = segments;
|
|
57
|
+
if (Array.isArray(obj)) {
|
|
58
|
+
const i = Number(head);
|
|
59
|
+
if (rest.length === 0 || !(i in obj))
|
|
60
|
+
return obj;
|
|
61
|
+
const copy = obj.slice();
|
|
62
|
+
copy[i] = unset(obj[i], rest);
|
|
63
|
+
return copy;
|
|
64
|
+
}
|
|
65
|
+
const o = obj;
|
|
66
|
+
if (!(head in o))
|
|
67
|
+
return o;
|
|
68
|
+
if (rest.length === 0) {
|
|
69
|
+
const { [head]: _gone, ...keep } = o;
|
|
70
|
+
return keep;
|
|
71
|
+
}
|
|
72
|
+
return { ...o, [head]: unset(o[head], rest) };
|
|
73
|
+
};
|
|
74
|
+
return unset(fields ?? {}, splitPath(path));
|
|
75
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { Finding } from "../drift.ts";
|
|
3
|
+
export declare function OrphanFields({ orphans, strip, onClear }: {
|
|
4
|
+
orphans: Finding[];
|
|
5
|
+
/** The node-id prefix to drop from the shown key and from the path Clear gets. */
|
|
6
|
+
strip?: string;
|
|
7
|
+
onClear(path: string): void;
|
|
8
|
+
}): ReactElement | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } 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 OrphanFields({ orphans, strip, onClear }) {
|
|
6
|
+
const rows = orphans.filter((f) => f.kind === "orphan-field");
|
|
7
|
+
if (rows.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
const local = (p) => (strip && p.startsWith(`${strip}.`) ? p.slice(strip.length + 1) : p);
|
|
10
|
+
return (_jsxs("details", { className: "sm-orphans", "data-testid": "orphan-fields", children: [_jsx("summary", { className: "sm-orphans__head", children: "No longer in the schema" }), rows.map((f) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: local(f.path) }), _jsx("span", { className: "sm-orphans__value", children: shown(f.value) }), _jsx(Button, { variant: "secondary", onClick: () => { void navigator.clipboard?.writeText(shown(f.value)); }, children: "Copy" }), _jsx(Button, { variant: "secondary", onClick: () => onClear(local(f.path)), children: "Clear" })] }, f.path)))] }));
|
|
11
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// One widget for f.ref and f.refList (spec 2026-09-17 §2): a search input,
|
|
4
|
-
// the picked entries under it. A refList's rows drag like a list's
|
|
4
|
+
// the picked entries under it. A refList's rows drag like a list's; a single
|
|
5
|
+
// ref shows its pick inside the input, open and remove beside it (2026-09-27).
|
|
5
6
|
import { ExternalLink } from "lucide-react";
|
|
6
7
|
import { Combobox } from "../ui/Combobox.js";
|
|
7
8
|
import { Grip, IconButton, cx } from "../ui/primitives.js";
|
|
@@ -10,6 +11,7 @@ import { useSortable } from "../ui/useSortable.js";
|
|
|
10
11
|
import { useAdminHref } from "../shell/base.js";
|
|
11
12
|
import { moveItem } from "./list.js";
|
|
12
13
|
import { pickRef, refCandidates, refIds, refRows, refValue, statusTone } from "./ref-picker.js";
|
|
14
|
+
const ABSENT = "Not available in this locale";
|
|
13
15
|
const titleCase = (name) => name.replace(/^./, (c) => c.toUpperCase());
|
|
14
16
|
function StatusDot({ status }) {
|
|
15
17
|
if (status === "")
|
|
@@ -26,5 +28,7 @@ export function RefPicker({ value, onChange, options, multiple, max, target }) {
|
|
|
26
28
|
const set = (next) => onChange(refValue(next, multiple));
|
|
27
29
|
const sortable = useSortable({ count: multiple ? rows.length : 0, onDrop: (from, to) => set(moveItem(ids, from, to)) });
|
|
28
30
|
const present = (options ?? []).filter((o) => !o.absent).length;
|
|
29
|
-
|
|
31
|
+
const openLink = (r) => (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) }));
|
|
32
|
+
const single = multiple ? undefined : rows[0];
|
|
33
|
+
return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), selected: single && `${single.title}${single.state === "absent" ? ` · ${ABSENT}` : ""}`, suffix: single && (_jsxs(_Fragment, { children: [_jsx(StatusDot, { status: single.status }), openLink(single), _jsx(IconButton, { label: "Remove", onClick: () => set([]), children: "\u00D7" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), multiple && rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsxs("span", { className: "sm-refrow__note", children: [" \u00B7 ", ABSENT] })] }), _jsx(StatusDot, { status: r.status }), openLink(r), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
|
|
30
34
|
}
|
|
@@ -11,13 +11,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
// document's element nodes. Collapse is component state only: a card
|
|
12
12
|
// starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
|
|
13
13
|
// inserted element opens, so its fields can be filled straight away.
|
|
14
|
+
// Drift (spec 2026-09-27 editor safety §5): an unregistered element's card
|
|
15
|
+
// takes the broken stripe and lists its stored fields read-only; a
|
|
16
|
+
// registered one the field's toolset leaves out says so in its head.
|
|
14
17
|
import { useState } from "react";
|
|
15
18
|
import { NodeViewWrapper } from "@tiptap/react";
|
|
16
19
|
import { isHidden } from "../../conditions.js";
|
|
17
|
-
import { IconButton, SectionLabel } from "../ui/primitives.js";
|
|
20
|
+
import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
|
|
18
21
|
import { FieldWidget } from "./FieldWidget.js";
|
|
19
22
|
import { useElements } from "./elements-context.js";
|
|
20
|
-
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
23
|
+
import { cardClass, elementCardState, useRichtextForm } from "./richtext-form-context.js";
|
|
21
24
|
import { elementIndexAt } from "./richtext-insert.js";
|
|
22
25
|
/** No field holds a value yet — the state right after an insert. */
|
|
23
26
|
function isBlank(fields) {
|
|
@@ -31,6 +34,9 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
|
|
|
31
34
|
const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
|
|
32
35
|
const settings = (node.attrs.settings && typeof node.attrs.settings === "object" ? node.attrs.settings : {});
|
|
33
36
|
const [open, setOpen] = useState(() => isBlank(fields));
|
|
37
|
+
// As drift decides it: an element whose contexts dropped "richtext" is outside every toolset.
|
|
38
|
+
const allowed = form.elements?.filter((n) => elements.find((e) => e.name === n)?.contexts.includes("richtext"));
|
|
39
|
+
const state = elementCardState(name, Boolean(element), allowed);
|
|
34
40
|
const pos = getPos();
|
|
35
41
|
const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
|
|
36
42
|
const base = `${form.path}.elements.${index}`;
|
|
@@ -39,5 +45,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
|
|
|
39
45
|
const scope = { fields, settings };
|
|
40
46
|
const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
|
|
41
47
|
const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
|
|
42
|
-
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
|
|
48
|
+
return (_jsxs(NodeViewWrapper, { className: cx(cardClass(selected, form.canvas), state.unknown && "sm-rtcard--broken"), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), state.outside && _jsx("span", { className: "sm-hint", children: "Not in this field's toolset" }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && !element && (_jsx("div", { className: "sm-rtcard__body", contentEditable: false, children: Object.entries(fields).map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: typeof value === "string" ? value : JSON.stringify(value) })] }, key))) })), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
|
|
43
49
|
}
|
|
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// flag, so its own cards stay admin-styled.
|
|
9
9
|
import { useMemo } from "react";
|
|
10
10
|
import { EditorContent } from "@tiptap/react";
|
|
11
|
+
import { allowedOf } from "../../toolset.js";
|
|
11
12
|
import { RichtextToolbar } from "./RichtextToolbar.js";
|
|
12
13
|
import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
|
|
13
14
|
import { RichtextLinkRow, RichtextMenu } from "./RichtextPopovers.js";
|
|
@@ -17,7 +18,8 @@ import { useRichtextEditor } from "./useRichtextEditor.js";
|
|
|
17
18
|
export function RichtextWidget({ value, toolset, onChange, errors, path = "" }) {
|
|
18
19
|
const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-richtext__content" });
|
|
19
20
|
const parent = useRichtextForm();
|
|
20
|
-
const
|
|
21
|
+
const allowed = useMemo(() => allowedOf(toolset).elements, [toolset]);
|
|
22
|
+
const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas, elements: allowed }), [errors, path, parent.canvas, allowed]);
|
|
21
23
|
return (_jsx(RichtextFormProvider, { value: formInfo, children: _jsxs("div", { className: "sm-richtext", children: [_jsxs("div", { className: "sm-richtext__head", children: [_jsx(RichtextToolbar, { allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run }), rt.menu.open && _jsx(RichtextMenu, { menu: rt.menu })] }), rt.link.open && _jsx(RichtextLinkRow, { link: rt.link }), _jsxs("div", { className: "sm-richtext__body", children: [_jsx(EditorContent, { editor: rt.editor }), rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { top: rt.slash.state.rect ? rt.slash.state.rect.bottom - (editorTop(rt.editor) ?? 0) + 4 : 0 }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) }))] }), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }) }));
|
|
22
24
|
}
|
|
23
25
|
/** The editor's top in viewport coordinates, to place the slash menu relative to the body wrapper. */
|
|
@@ -27,7 +27,7 @@ export function TagsField({ value, onChange, max, suggestions, label, initialAdd
|
|
|
27
27
|
timer.current = setTimeout(() => setFlash(null), 600);
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
|
-
return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
|
|
30
|
+
return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", listClassName: "sm-combo__list--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
|
|
31
31
|
return next; commit(next); return ""; }, onInputKey: (e, query) => {
|
|
32
32
|
if (e.key !== "Backspace" || query !== "" || tags.length === 0)
|
|
33
33
|
return false;
|