smoodly 0.0.27 → 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 +75 -3
- package/dist/admin/client-ops.js +2 -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 +70 -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 +9 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +33 -15
- 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 +124 -14
- package/dist/admin/ops.d.ts +53 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +2 -3
- 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 +32 -7
- 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/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +9 -7
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +22 -14
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +16 -3
- package/dist/admin/shell/settings.js +16 -9
- 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.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +5 -4
- 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 +21 -2
- package/dist/admin/ui/confirm.js +26 -9
- package/dist/admin/ui/theme.js +32 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +31 -6
- package/dist/entry-store.js +39 -11
- package/dist/index.d.ts +2 -2
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/render.js +7 -2
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql.js +19 -1
- package/dist/store.d.ts +39 -5
- package/dist/store.js +32 -8
- package/dist/supabase-entry-store.d.ts +9 -3
- package/dist/supabase-entry-store.js +39 -13
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +15 -3
- package/dist/supabase-store.js +42 -8
- package/migrations/20260926150000_smoodly_v4.sql +41 -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";
|
|
@@ -83,6 +88,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
83
88
|
const [folder, setFolder] = useState(false);
|
|
84
89
|
const [reloadKey, setReloadKey] = useState(0);
|
|
85
90
|
const [paths, setPaths] = useState({});
|
|
91
|
+
const [redirects, setRedirects] = useState({});
|
|
92
|
+
const [liveFindings, setLiveFindings] = useState([]);
|
|
86
93
|
const [urls, setUrls] = useState({});
|
|
87
94
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
88
95
|
const [parentLocales, setParentLocales] = useState([]);
|
|
@@ -249,6 +256,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
249
256
|
}
|
|
250
257
|
setRecord(result.data.record);
|
|
251
258
|
setPaths(result.data.paths);
|
|
259
|
+
setRedirects(result.data.redirects);
|
|
260
|
+
setLiveFindings(result.data.liveFindings);
|
|
252
261
|
setUrls(result.data.urls);
|
|
253
262
|
setParentLocales(result.data.parentLocales);
|
|
254
263
|
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
@@ -287,6 +296,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
287
296
|
return;
|
|
288
297
|
setRecord(result.data.record);
|
|
289
298
|
setPaths(result.data.paths);
|
|
299
|
+
setRedirects(result.data.redirects);
|
|
300
|
+
setLiveFindings(result.data.liveFindings);
|
|
290
301
|
setUrls(result.data.urls);
|
|
291
302
|
setParentLocales(result.data.parentLocales);
|
|
292
303
|
}, [ops, id, locale]);
|
|
@@ -510,6 +521,21 @@ export function EditorView({ ops, registry, id }) {
|
|
|
510
521
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
511
522
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
512
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
|
+
};
|
|
513
539
|
const doInsert = (zone, index, section) => {
|
|
514
540
|
const node = nodeFromSample(section, newNodeId(section.name));
|
|
515
541
|
mutate((t) => insertNode(t, zone, index, node));
|
|
@@ -791,10 +817,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
791
817
|
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
792
818
|
const editing = fieldRectOf(inline.session);
|
|
793
819
|
const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
|
|
794
|
-
const selection = selected && selRect
|
|
820
|
+
const selection = selected && selRect
|
|
795
821
|
? {
|
|
822
|
+
// An unknown node is selectable in place too (spec 2026-09-27 editor safety §5), titled as the Outline labels it.
|
|
796
823
|
node: selected, rect: selRect,
|
|
797
|
-
title: selectedSection?.title ?? selectedGlobal?.title ?? selected
|
|
824
|
+
title: selectedSection?.title ?? selectedGlobal?.title ?? labelOf(selected),
|
|
798
825
|
global: Boolean(selectedGlobal), locked: selLocked,
|
|
799
826
|
}
|
|
800
827
|
: layoutItem && selectedId && selRect
|
|
@@ -803,12 +830,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
803
830
|
rect: selRect, title: layoutItem.title, global: true, locked: false, layout: true,
|
|
804
831
|
}
|
|
805
832
|
: null;
|
|
806
|
-
|
|
833
|
+
function labelOf(node) {
|
|
807
834
|
if (node.type === CUSTOM_NODE_TYPE)
|
|
808
835
|
return customNodeLabel(node);
|
|
809
836
|
const global = globalOf(node);
|
|
810
|
-
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : node.type);
|
|
811
|
-
}
|
|
837
|
+
return sectionOf(node.type)?.title ?? (global ? `${global.title} · global` : `Unknown section \`${node.type}\``);
|
|
838
|
+
}
|
|
812
839
|
// Reveal is defined here, where the rect map exists; select() calls it
|
|
813
840
|
// through the ref so its own identity stays stable.
|
|
814
841
|
revealRef.current = (id) => {
|
|
@@ -829,7 +856,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
829
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); } })),
|
|
830
857
|
},
|
|
831
858
|
{ label },
|
|
832
|
-
] }) }), 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)
|
|
833
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 && (() => {
|
|
834
861
|
const { session } = inline.richtext;
|
|
835
862
|
const item = globalItemOf(session.node);
|
|
@@ -845,11 +872,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
845
872
|
else
|
|
846
873
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
847
874
|
}, onEscape: inline.end }));
|
|
848
|
-
})()] }) })] }), _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], 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 &&
|
|
849
876
|
!isFixedNode(record, registry) &&
|
|
850
877
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
851
878
|
? () => { void doRemoveLocale(); }
|
|
852
|
-
: 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)
|
|
853
880
|
reveal(p, selectedSection); }, onChange: (value) => {
|
|
854
881
|
if (ns === "css")
|
|
855
882
|
doPatchCss(selected.id, value);
|
|
@@ -857,7 +884,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
857
884
|
doPatch(selected.id, ns, key, value);
|
|
858
885
|
if (ns === "fields")
|
|
859
886
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
860
|
-
} }, `${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)
|
|
861
890
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
862
891
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
863
892
|
if (ns === "fields") {
|
|
@@ -865,10 +894,23 @@ export function EditorView({ ops, registry, id }) {
|
|
|
865
894
|
if (current)
|
|
866
895
|
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
867
896
|
}
|
|
868
|
-
}, 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:
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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) => {
|
|
872
914
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
873
915
|
if (first)
|
|
874
916
|
doInsert(zone, 0, first);
|
|
@@ -876,6 +918,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
876
918
|
}
|
|
877
919
|
/** The selected node's ref field (if any) resolves to its target
|
|
878
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
|
+
}
|
|
879
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. */
|
|
880
935
|
function globalPath(ns, key) {
|
|
881
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, 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;
|
|
@@ -11,11 +12,18 @@ export declare function PageInspector({ record, locale, registry, tree, path, fi
|
|
|
11
12
|
/** The page's path in this locale (the `paths` index). */
|
|
12
13
|
path: string | undefined;
|
|
13
14
|
fixed: boolean;
|
|
15
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
16
|
+
redirects?: string[];
|
|
14
17
|
onRename(patch: {
|
|
15
18
|
title?: string;
|
|
16
19
|
slug?: string;
|
|
20
|
+
redirect?: boolean;
|
|
17
21
|
}): Promise<string | null>;
|
|
18
22
|
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
19
23
|
onRemoveLocale?: () => void;
|
|
20
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;
|
|
21
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, 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, 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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
|
|
3
|
+
export declare function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }: {
|
|
4
4
|
record: PageRecord;
|
|
5
5
|
locale: string;
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -8,8 +8,11 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
|
|
|
8
8
|
path: string | undefined;
|
|
9
9
|
fixed: boolean;
|
|
10
10
|
/** Resolves to an error message, or null when the rename landed. */
|
|
11
|
+
/** The active locale's old addresses that redirect here (spec 2026-09-26 redirects §6). */
|
|
12
|
+
redirects?: string[];
|
|
11
13
|
onRename(patch: {
|
|
12
14
|
title?: string;
|
|
13
15
|
slug?: string;
|
|
16
|
+
redirect?: boolean;
|
|
14
17
|
}): Promise<string | null>;
|
|
15
18
|
}): import("react").JSX.Element | null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
|
|
4
4
|
// §6): the ACTIVE locale's title and slug, saved through pages.rename,
|
|
5
5
|
// and "Remove from this locale". The switcher in the canvas meta row
|
|
@@ -7,14 +7,19 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
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";
|
|
14
|
-
|
|
14
|
+
import { RouteLink } from "../shell/RouteLink.js";
|
|
15
|
+
import { useAdminHref } from "../shell/base.js";
|
|
16
|
+
export function PageSettings({ record, locale, registry, path, fixed, redirects, onRename }) {
|
|
15
17
|
const row = record.locales[locale];
|
|
16
18
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
17
19
|
const [error, setError] = useState(null);
|
|
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);
|
|
18
23
|
const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
19
24
|
// The record changes identity on every rename; re-seed the inputs from it.
|
|
20
25
|
useEffect(() => {
|
|
@@ -23,20 +28,33 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
23
28
|
if (!row)
|
|
24
29
|
return null;
|
|
25
30
|
const commit = async (key) => {
|
|
26
|
-
|
|
27
|
-
const next = draft[key];
|
|
28
|
-
if (next === row[key])
|
|
29
|
-
return;
|
|
30
|
-
if (next === "") {
|
|
31
|
-
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
31
|
+
if (committing.current)
|
|
32
32
|
return;
|
|
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.`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
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 }));
|
|
33
54
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
setDraft((d) => ({ ...d, slug: row.slug }));
|
|
37
|
-
return;
|
|
55
|
+
finally {
|
|
56
|
+
committing.current = false;
|
|
38
57
|
}
|
|
39
|
-
setError(await onRename({ [key]: next }));
|
|
40
58
|
};
|
|
41
59
|
const onKey = (key) => (e) => {
|
|
42
60
|
if (e.key === "Enter") {
|
|
@@ -44,5 +62,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
44
62
|
void commit(key);
|
|
45
63
|
}
|
|
46
64
|
};
|
|
47
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
65
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), redirects && redirects.length > 0 && (_jsx(Field, { label: "Redirects from", asDiv: true, hint: _jsxs(_Fragment, { children: ["Old addresses that send visitors here. Manage them in ", _jsx(RouteLink, { href: adminHref("/settings/redirects"), children: "Settings \u203A Redirects" }), "."] }), children: _jsx("div", { className: "sm-redirects-from", children: redirects.map((p) => _jsx("code", { children: p }, p)) }) })), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
48
66
|
}
|
|
@@ -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
|
}
|