smoodly 0.0.12 → 0.0.14
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 +294 -40
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +135 -28
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +35 -43
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +85 -19
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +18 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +11 -1
- package/dist/admin/tree-ops.js +32 -1
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +38 -4
- package/dist/admin/ui/primitives.js +35 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +527 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +52 -12
- package/dist/collections.d.ts +6 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +23 -3
- package/dist/entry-store.js +31 -5
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +30 -1
- package/dist/fields.js +33 -3
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +16 -7
- package/dist/index.js +10 -4
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.d.ts +14 -7
- package/dist/render.js +4 -3
- package/dist/resolve.d.ts +12 -2
- package/dist/resolve.js +16 -7
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +25 -1
- package/dist/site.js +89 -22
- package/dist/store.d.ts +7 -1
- package/dist/store.js +13 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +34 -11
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +14 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -1
|
@@ -8,7 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
8
8
|
// the geometry the bridge reports map 1:1 onto the overlay layer above
|
|
9
9
|
// it. Every gesture mutates client tree state through Task 4's pure ops;
|
|
10
10
|
// a debounced saveDraft persists, and the bridge refreshes the iframe on
|
|
11
|
-
// the save ack (refresh-on-change preview, DESIGN.md §3).
|
|
11
|
+
// the save ack (refresh-on-change preview, DESIGN.md §3). Inline editing
|
|
12
|
+
// (spec 2026-09-10) runs beside it: `useInlineSession` owns the sessions
|
|
13
|
+
// in the iframe's document, `InlineRichtext` the canvas editor, and the
|
|
14
|
+
// save-ack refresh waits for a session to end. Every load,
|
|
12
15
|
// edit, autosave and publish targets ONE locale — the page's own tree in
|
|
13
16
|
// that locale (spec 2026-09-06 §6); switching flushes the pending save
|
|
14
17
|
// first.
|
|
@@ -17,34 +20,39 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
17
20
|
// The pieces live beside it — `useCanvasBridge` (geometry and messages),
|
|
18
21
|
// `usePageLocale` (switch/add/remove), `CanvasOverlay` and
|
|
19
22
|
// `overlay-geometry` (the chrome above the iframe), `InsertPicker`,
|
|
20
|
-
// `Outline`, `
|
|
23
|
+
// `Outline`, `PageInspector`, `NoticePane`.
|
|
21
24
|
//
|
|
22
25
|
// Chrome follows the design system's visual-editor screen: top bar, toned
|
|
23
|
-
// canvas around a white page,
|
|
26
|
+
// canvas around a white page, 360px inspector holding fields then outline.
|
|
24
27
|
//
|
|
25
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
26
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
27
30
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
28
31
|
import { SHARED_NODE_TYPE } from "../../shared.js";
|
|
32
|
+
import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
29
33
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
34
|
+
import { revealField } from "../forms/reveal.js";
|
|
35
|
+
import { tabOf, tabsFor } from "../forms/tabs.js";
|
|
36
|
+
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
32
37
|
import { useDraftAutosave } from "./autosave.js";
|
|
33
38
|
import { CanvasOverlay } from "./CanvasOverlay.js";
|
|
39
|
+
import { InlineRichtext } from "./InlineRichtext.js";
|
|
34
40
|
import { InsertPicker } from "./InsertPicker.js";
|
|
35
41
|
import { NoticePane } from "./NoticePane.js";
|
|
36
42
|
import { Outline } from "./Outline.js";
|
|
37
|
-
import {
|
|
43
|
+
import { PageInspector } from "./PageInspector.js";
|
|
38
44
|
import { localeFromUrl } from "./locale-url.js";
|
|
39
45
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
40
46
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
47
|
+
import { useInlineSession } from "./useInlineSession.js";
|
|
41
48
|
import { usePageLocale } from "./usePageLocale.js";
|
|
42
49
|
import { BASE } from "../shell/base.js";
|
|
43
50
|
import { RouteLink } from "../shell/RouteLink.js";
|
|
44
51
|
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
45
52
|
import { entryTitle } from "../shell/entries-list.js";
|
|
46
53
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
47
|
-
import {
|
|
54
|
+
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
55
|
+
import { Banner as BannerBox, Breadcrumb, Button, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
|
|
48
56
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
49
57
|
export function EditorView({ ops, registry, id }) {
|
|
50
58
|
const [record, setRecord] = useState(null);
|
|
@@ -59,6 +67,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
59
67
|
const [parentLocales, setParentLocales] = useState([]);
|
|
60
68
|
const [selectedId, setSelectedId] = useState(null);
|
|
61
69
|
const [hoverId, setHoverId] = useState(null);
|
|
70
|
+
const [hoverField, setHoverField] = useState(null);
|
|
71
|
+
const [revealReq, setRevealReq] = useState(null);
|
|
72
|
+
const inspectorRef = useRef(null);
|
|
73
|
+
// The session hook is created after `mutate`; the autosave and the bridge reach it through this ref.
|
|
74
|
+
const inlineRef = useRef(null);
|
|
62
75
|
const [picker, setPicker] = useState(null);
|
|
63
76
|
const [tab, setTab] = useState("Content");
|
|
64
77
|
const [banner, setBanner] = useState(null);
|
|
@@ -76,11 +89,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
76
89
|
// flushes first (switchLocale), so the ref and the tree always agree.
|
|
77
90
|
const treeLocaleRef = useRef(localeFromUrl(registry));
|
|
78
91
|
// Every edit marks the draft dirty; a debounced saveDraft persists, with
|
|
79
|
-
// retry on failure, and the bridge refreshes the iframe on the save ack
|
|
92
|
+
// retry on failure, and the bridge refreshes the iframe on the save ack —
|
|
93
|
+
// now, or once an inline session ends (spec 2026-09-10 §7).
|
|
80
94
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
81
95
|
read: () => treeRef.current,
|
|
82
96
|
persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
|
|
83
|
-
onSaved: () =>
|
|
97
|
+
onSaved: () => inlineRef.current?.onSaved(),
|
|
84
98
|
});
|
|
85
99
|
const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
|
|
86
100
|
registry, ops, id, record, setRecord, flush,
|
|
@@ -97,8 +111,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
97
111
|
const iframeSrc = ready ? withEditorParam(siteUrl) : null;
|
|
98
112
|
// Canvas-originated selection: the node is under the cursor already, so
|
|
99
113
|
// this path deliberately skips the scroll every other selection does.
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
// (`onCanvasSelect` is declared further down; the handler runs at event time.)
|
|
115
|
+
const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
|
|
116
|
+
iframeRef, iframeSrc,
|
|
117
|
+
onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
|
|
118
|
+
onHover: (id, path) => {
|
|
119
|
+
setHoverId(id);
|
|
120
|
+
setHoverField((prev) => (prev && prev.node === id && prev.path === path ? prev : id && path ? { node: id, path } : null));
|
|
121
|
+
},
|
|
122
|
+
onRendered: () => inlineRef.current?.onRendered(),
|
|
102
123
|
});
|
|
103
124
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
104
125
|
// at the site route (an iframe loaded before the cookie exists would
|
|
@@ -291,6 +312,71 @@ export function EditorView({ ops, registry, id }) {
|
|
|
291
312
|
});
|
|
292
313
|
mark();
|
|
293
314
|
}, [mark]);
|
|
315
|
+
// ── inline editing (spec 2026-09-10) ──────────────────────────────────
|
|
316
|
+
const inline = useInlineSession({
|
|
317
|
+
iframeRef, postToCanvas,
|
|
318
|
+
patch: (node, path, value) => {
|
|
319
|
+
mutate((t) => patchNodePath(t, node, path, value));
|
|
320
|
+
// A second element marked with the same field follows the one being typed in.
|
|
321
|
+
if (typeof value === "string")
|
|
322
|
+
inlineRef.current?.writeText(node, path, value);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
inlineRef.current = inline;
|
|
326
|
+
// A different selection ends the session; a new canvas drops everything.
|
|
327
|
+
useEffect(() => { inline.onSelection(selectedId); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
328
|
+
useEffect(() => { inline.onCanvasChange(); }, [iframeSrc]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
329
|
+
/** Inspector edits of text fields land in the canvas the same tick (spec §7): every
|
|
330
|
+
* marked text element of the node, by its reported path, from the fields after the patch. */
|
|
331
|
+
const syncTexts = (id, section, fields) => {
|
|
332
|
+
for (const f of fieldRects) {
|
|
333
|
+
if (f.node !== id)
|
|
334
|
+
continue;
|
|
335
|
+
const d = descriptorAt(section, f.path);
|
|
336
|
+
const v = getAtPath(fields, f.path);
|
|
337
|
+
if (d?.type === "text" && typeof v === "string")
|
|
338
|
+
inline.writeText(id, f.path, v);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
/** Scroll the inspector to a field's widget, pulse it, focus it (spec §8). The field's tab first —
|
|
342
|
+
* marks address content fields, and the projection puts a content image on Media. */
|
|
343
|
+
const reveal = (path, section) => setRevealReq((r) => ({ path, tab: tabOf(section, path) ?? "Content", n: (r?.n ?? 0) + 1 }));
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!revealReq)
|
|
346
|
+
return;
|
|
347
|
+
setTab(revealReq.tab);
|
|
348
|
+
const frame = requestAnimationFrame(() => { if (inspectorRef.current)
|
|
349
|
+
revealField(inspectorRef.current, revealReq.path); });
|
|
350
|
+
return () => cancelAnimationFrame(frame);
|
|
351
|
+
}, [revealReq]);
|
|
352
|
+
/** A click in the canvas: on a marked field of the selected section it acts on the field (spec §2), else it selects. */
|
|
353
|
+
const onCanvasSelect = (id, path, x, y) => {
|
|
354
|
+
if (id && id === selectedId && path) {
|
|
355
|
+
onFieldClick(id, path, x, y);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
setSelectedId(id);
|
|
359
|
+
};
|
|
360
|
+
const onFieldClick = (id, path, x, y) => {
|
|
361
|
+
const node = tree ? findNode(tree, id) : null;
|
|
362
|
+
const section = node ? sectionOf(node.type) : null;
|
|
363
|
+
if (!node || !section)
|
|
364
|
+
return; // a shared placement is edited under Globals — nothing inline here
|
|
365
|
+
const descriptor = descriptorAt(section, path);
|
|
366
|
+
if (!descriptor) {
|
|
367
|
+
if (process.env.NODE_ENV !== "production") {
|
|
368
|
+
console.warn(`smoodly: "${section.name}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (inline.session && inline.session.node === id && inline.session.path === path)
|
|
373
|
+
return;
|
|
374
|
+
const kind = descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
|
|
375
|
+
if (kind && inline.start({ node: id, path, kind }, { x, y }))
|
|
376
|
+
return;
|
|
377
|
+
// Any other field, or a text mark on a container: the inspector reveals its widget.
|
|
378
|
+
reveal(path, section);
|
|
379
|
+
};
|
|
294
380
|
// A zone bound to a shared item needs the item's row id (ops.shared.list),
|
|
295
381
|
// which can land after the draft did. Fill then; nothing else changes.
|
|
296
382
|
useEffect(() => {
|
|
@@ -350,7 +436,10 @@ export function EditorView({ ops, registry, id }) {
|
|
|
350
436
|
setSelectedId((current) => (current === id ? null : current));
|
|
351
437
|
};
|
|
352
438
|
const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
|
|
439
|
+
const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
|
|
353
440
|
const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
|
|
441
|
+
/** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
|
|
442
|
+
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
354
443
|
// ── publish ──────────────────────────────────────────────────────────
|
|
355
444
|
const doPublish = async () => {
|
|
356
445
|
setBanner(null);
|
|
@@ -396,7 +485,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
396
485
|
return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
|
|
397
486
|
}
|
|
398
487
|
if (folder) {
|
|
399
|
-
return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a
|
|
488
|
+
return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a path \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }) }));
|
|
400
489
|
}
|
|
401
490
|
if (record && !record.locales[locale]) {
|
|
402
491
|
const from = sourceLocaleOf(record, registry);
|
|
@@ -410,7 +499,10 @@ export function EditorView({ ops, registry, id }) {
|
|
|
410
499
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
411
500
|
const selRect = selected ? rectById.get(selected.id) : undefined;
|
|
412
501
|
const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
|
|
413
|
-
const
|
|
502
|
+
const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
|
|
503
|
+
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
504
|
+
const editing = fieldRectOf(inline.session);
|
|
505
|
+
const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
|
|
414
506
|
const selection = selected && selRect && (selectedSection || selectedShared)
|
|
415
507
|
? {
|
|
416
508
|
node: selected, rect: selRect,
|
|
@@ -436,21 +528,36 @@ export function EditorView({ ops, registry, id }) {
|
|
|
436
528
|
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
437
529
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
438
530
|
const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
|
|
439
|
-
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
531
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
|
|
532
|
+
{
|
|
533
|
+
label: "Pages", href: `${BASE}/pages`,
|
|
534
|
+
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
535
|
+
},
|
|
536
|
+
{ label },
|
|
537
|
+
] }) }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, 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 && tree && (() => {
|
|
538
|
+
const { session } = inline.richtext;
|
|
539
|
+
const node = findNode(tree, session.node);
|
|
540
|
+
const section = node ? sectionOf(node.type) : null;
|
|
541
|
+
if (!node || !section)
|
|
542
|
+
return null;
|
|
543
|
+
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
|
|
544
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_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 ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
545
|
+
!isFixedNode(record, registry) &&
|
|
546
|
+
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
547
|
+
? () => { void doRemoveLocale(); }
|
|
548
|
+
: undefined })) : null, selected && (selectedSection || selectedShared) && (_jsxs("div", { className: "sm-inspector__body", children: [selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
549
|
+
reveal(p, selectedSection); }, onChange: (value) => {
|
|
550
|
+
doPatch(selected.id, ns, key, value);
|
|
551
|
+
if (ns === "fields")
|
|
552
|
+
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
553
|
+
} }, `${selected.id}.${ns}.${key}`))), selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
|
|
554
|
+
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
555
|
+
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
|
|
556
|
+
})), onAddFirst: (zone) => {
|
|
557
|
+
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
558
|
+
if (first)
|
|
559
|
+
doInsert(zone, 0, first);
|
|
560
|
+
} }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
|
|
454
561
|
}
|
|
455
562
|
/** The selected node's ref field (if any) resolves to its target
|
|
456
563
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Allowed } from "../../toolset.ts";
|
|
3
|
+
import { type ActiveState, type ToolbarCommand } from "../forms/RichtextToolbar.tsx";
|
|
4
|
+
export declare const FLOATBAR_W = 340;
|
|
5
|
+
export declare function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }: {
|
|
6
|
+
/** The caret's left, top and bottom in overlay coordinates. */
|
|
7
|
+
x: number;
|
|
8
|
+
top: number;
|
|
9
|
+
bottom: number;
|
|
10
|
+
pageWidth: number;
|
|
11
|
+
allowed: Allowed;
|
|
12
|
+
active: ActiveState;
|
|
13
|
+
canInsert: boolean;
|
|
14
|
+
onCommand(command: ToolbarCommand): void;
|
|
15
|
+
popover?: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { RichtextToolbar } from "../forms/RichtextToolbar.js";
|
|
4
|
+
export const FLOATBAR_W = 340;
|
|
5
|
+
const GAP = 10;
|
|
6
|
+
const FLIP_AT = 56;
|
|
7
|
+
export function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }) {
|
|
8
|
+
const left = Math.max(8, Math.min(x, pageWidth - FLOATBAR_W - 8));
|
|
9
|
+
const below = top < FLIP_AT;
|
|
10
|
+
const style = below
|
|
11
|
+
? { left, top: bottom + 8 }
|
|
12
|
+
: { left, top: top - GAP, transform: "translateY(-100%)" };
|
|
13
|
+
return (_jsxs("div", { className: "sm-floatbar", style: style, children: [_jsx(RichtextToolbar, { allowed: allowed, active: active, canInsert: canInsert, onCommand: onCommand }), popover] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RichtextMount } from "./useInlineSession.ts";
|
|
2
|
+
export declare function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }: {
|
|
3
|
+
mount: RichtextMount;
|
|
4
|
+
value: unknown;
|
|
5
|
+
toolset: unknown;
|
|
6
|
+
pageWidth: number;
|
|
7
|
+
onChange(doc: unknown): void;
|
|
8
|
+
onEscape(): void;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A richtext session in the canvas (spec 2026-09-10 §5): the TipTap
|
|
4
|
+
// editor portalled into the root useInlineSession appended inside the
|
|
5
|
+
// marked container — the same extensions, gating and node views as the
|
|
6
|
+
// sidebar widget, through useRichtextEditor — and the chrome in the
|
|
7
|
+
// admin: the floating toolbar above the selection, the slash menu at the
|
|
8
|
+
// caret, the media picker. The form context's `canvas` flag gives the
|
|
9
|
+
// cards inside the admin's classes and the light theme. Rendered inside
|
|
10
|
+
// the page box, so the toolbar's coordinates are the caret's.
|
|
11
|
+
import { useEffect, useMemo } from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
import { EditorContent, useEditorState } from "@tiptap/react";
|
|
14
|
+
import { MediaPicker } from "../forms/MediaPicker.js";
|
|
15
|
+
import { RichtextInsertMenu } from "../forms/RichtextInsertMenu.js";
|
|
16
|
+
import { RichtextLinkRow, RichtextMenu } from "../forms/RichtextPopovers.js";
|
|
17
|
+
import { RichtextFormProvider } from "../forms/richtext-form-context.js";
|
|
18
|
+
import { useRichtextEditor } from "../forms/useRichtextEditor.js";
|
|
19
|
+
import { FloatingToolbar } from "./FloatingToolbar.js";
|
|
20
|
+
export function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }) {
|
|
21
|
+
const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-inline-richtext", onEscape });
|
|
22
|
+
const { editor } = rt;
|
|
23
|
+
const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true }), [mount.session.path]);
|
|
24
|
+
// The caret where the click landed, once the editor exists.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!editor)
|
|
27
|
+
return;
|
|
28
|
+
const pos = mount.at ? editor.view.posAtCoords({ left: mount.at.x, top: mount.at.y })?.pos : undefined;
|
|
29
|
+
editor.commands.focus(pos ?? "end");
|
|
30
|
+
}, [editor, mount.at]);
|
|
31
|
+
// The container's flex/grid layout lands on the element that holds the blocks — the
|
|
32
|
+
// ProseMirror root — and every wrapper between it and the mount root is made
|
|
33
|
+
// transparent, so a flex column's gap still separates the paragraphs (spec §5).
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!editor)
|
|
36
|
+
return;
|
|
37
|
+
const dom = editor.view.dom;
|
|
38
|
+
for (let el = dom.parentElement; el && el !== mount.root; el = el.parentElement)
|
|
39
|
+
el.style.display = "contents";
|
|
40
|
+
for (const [prop, value] of Object.entries(mount.layout))
|
|
41
|
+
dom.style.setProperty(prop, value);
|
|
42
|
+
}, [editor, mount.root, mount.layout]);
|
|
43
|
+
// Read-only while the refresh that carries its edits renders (spec §5).
|
|
44
|
+
useEffect(() => { editor?.setEditable(!mount.closing); }, [editor, mount.closing]);
|
|
45
|
+
// The selection's start, in iframe document coordinates = overlay coordinates.
|
|
46
|
+
const from = useEditorState({ editor, selector: ({ editor }) => editor?.state.selection.from ?? null });
|
|
47
|
+
const caret = editor && from !== null && editor.view.dom.isConnected ? editor.view.coordsAtPos(from) : null;
|
|
48
|
+
const slashRect = rt.slash?.state.rect ?? null;
|
|
49
|
+
return (_jsxs(RichtextFormProvider, { value: formInfo, children: [createPortal(_jsx(EditorContent, { editor: editor }), mount.root), !mount.closing && caret && (_jsx(FloatingToolbar, { x: caret.left, top: caret.top, bottom: caret.bottom, pageWidth: pageWidth, allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run, popover: rt.menu.open ? _jsx(RichtextMenu, { menu: rt.menu }) : rt.link.open ? _jsx(RichtextLinkRow, { link: rt.link }) : null })), !mount.closing && rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { left: slashRect?.left ?? 0, top: (slashRect?.bottom ?? 0) + 4, pointerEvents: "auto" }, 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 })] }));
|
|
50
|
+
}
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import type { TreeNode } from "../../render.tsx";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
label: string;
|
|
10
|
-
}[];
|
|
2
|
+
type ZoneRows = {
|
|
3
|
+
zone: string;
|
|
4
|
+
locked: boolean;
|
|
5
|
+
canTake: boolean;
|
|
6
|
+
nodes: {
|
|
7
|
+
node: TreeNode;
|
|
8
|
+
label: string;
|
|
11
9
|
}[];
|
|
10
|
+
};
|
|
11
|
+
type Handlers = {
|
|
12
12
|
selectedId: string | null;
|
|
13
13
|
onSelect(id: string): void;
|
|
14
14
|
/** The empty row of an insertable zone was clicked. */
|
|
15
15
|
onAddFirst(zone: string): void;
|
|
16
16
|
onMove(id: string, dir: -1 | 1): void;
|
|
17
|
+
/** The drop and the grip's arrow keys: `index` in the resulting order. */
|
|
18
|
+
onMoveTo(id: string, index: number): void;
|
|
17
19
|
onRemove(id: string): void;
|
|
18
|
-
}
|
|
20
|
+
};
|
|
21
|
+
export declare function Outline({ zones, ...handlers }: {
|
|
22
|
+
zones: ZoneRows[];
|
|
23
|
+
} & Handlers): import("react").JSX.Element;
|
|
24
|
+
export {};
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
3
|
-
import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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";
|
|
4
|
+
import { useSortable } from "../ui/useSortable.js";
|
|
5
|
+
export function Outline({ zones, ...handlers }) {
|
|
6
|
+
return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
|
|
7
|
+
}
|
|
8
|
+
function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMove, onMoveTo, onRemove }) {
|
|
9
|
+
const sortable = useSortable({
|
|
10
|
+
count: locked ? 0 : nodes.length,
|
|
11
|
+
onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
|
|
12
|
+
});
|
|
13
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
|
|
14
|
+
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 }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", 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 }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id))) }))] }));
|
|
12
15
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PageTree } from "../../render.tsx";
|
|
2
|
+
import type { PageRecord } from "../../store.ts";
|
|
3
|
+
import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export type PageTab = "SEO & Social" | "Settings";
|
|
5
|
+
export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
|
|
6
|
+
export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
|
|
7
|
+
record: PageRecord;
|
|
8
|
+
locale: string;
|
|
9
|
+
registry: AdminRegistry;
|
|
10
|
+
tree: PageTree | null;
|
|
11
|
+
/** The page's path in this locale (the `paths` index). */
|
|
12
|
+
path: string | undefined;
|
|
13
|
+
fixed: boolean;
|
|
14
|
+
onRename(patch: {
|
|
15
|
+
title?: string;
|
|
16
|
+
slug?: string;
|
|
17
|
+
}): Promise<string | null>;
|
|
18
|
+
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
19
|
+
onRemoveLocale?: () => void;
|
|
20
|
+
onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The inspector with nothing selected (spec 2026-09-10 page fields §5):
|
|
4
|
+
// the page's title as the head, then SEO & Social (first and default when
|
|
5
|
+
// the template enables it) and Settings — title, slug, the template's
|
|
6
|
+
// page fields, remove from locale. Page tabs keep their own state and
|
|
7
|
+
// reset when the page changes.
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
10
|
+
import { Button, Tabs } from "../ui/primitives.js";
|
|
11
|
+
import { PageSettings } from "./PageSettings.js";
|
|
12
|
+
import { SeoSettings } from "./SeoSettings.js";
|
|
13
|
+
export function pageTabsFor(template) {
|
|
14
|
+
return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
|
|
15
|
+
}
|
|
16
|
+
export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
|
|
17
|
+
const template = registry.pages.find((p) => p.name === record.template);
|
|
18
|
+
const tabs = pageTabsFor(template);
|
|
19
|
+
const [tab, setTab] = useState(tabs[0]);
|
|
20
|
+
useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
21
|
+
const active = tabs.includes(tab) ? tab : tabs[0];
|
|
22
|
+
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] })] })) })] }));
|
|
24
|
+
}
|
|
@@ -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, onRename }: {
|
|
4
4
|
record: PageRecord;
|
|
5
5
|
locale: string;
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -12,6 +12,4 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
|
|
|
12
12
|
title?: string;
|
|
13
13
|
slug?: string;
|
|
14
14
|
}): Promise<string | null>;
|
|
15
|
-
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
16
|
-
onRemoveLocale?: () => void;
|
|
17
15
|
}): import("react").JSX.Element | null;
|
|
@@ -3,12 +3,13 @@ import { jsxs as _jsxs, jsx as _jsx } 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
|
|
6
|
-
// selects the locale, so there are no per-locale tabs here.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// selects the locale, so there are no per-locale tabs here. Rendered on
|
|
7
|
+
// the Settings tab of PageInspector while no block is selected; the SEO
|
|
8
|
+
// tab and the remove-from-locale button live there (spec 2026-09-10 page
|
|
9
|
+
// fields §5).
|
|
9
10
|
import { useEffect, useState } from "react";
|
|
10
|
-
import {
|
|
11
|
-
export function PageSettings({ record, locale, registry, path, fixed, onRename
|
|
11
|
+
import { Field, SectionLabel } from "../ui/primitives.js";
|
|
12
|
+
export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
|
|
12
13
|
const row = record.locales[locale];
|
|
13
14
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
14
15
|
const [error, setError] = useState(null);
|
|
@@ -36,5 +37,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename,
|
|
|
36
37
|
void commit(key);
|
|
37
38
|
}
|
|
38
39
|
};
|
|
39
|
-
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") }) })
|
|
40
|
+
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") }) })] }));
|
|
40
41
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const SEO_TITLE_MAX = 60;
|
|
2
|
+
export declare const SEO_DESCRIPTION_MAX = 160;
|
|
3
|
+
export type SeoKey = "title" | "description" | "ogImage" | "noindex";
|
|
4
|
+
export declare function SeoSettings({ meta, onPatch }: {
|
|
5
|
+
meta: Record<string, unknown>;
|
|
6
|
+
onPatch(key: SeoKey, value: unknown): void;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { MediaWidget } from "../forms/MediaWidget.js";
|
|
4
|
+
import { Field, Switch } from "../ui/primitives.js";
|
|
5
|
+
export const SEO_TITLE_MAX = 60;
|
|
6
|
+
export const SEO_DESCRIPTION_MAX = 160;
|
|
7
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
8
|
+
function Counter({ n, max }) {
|
|
9
|
+
return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
|
|
10
|
+
}
|
|
11
|
+
export function SeoSettings({ meta, onPatch }) {
|
|
12
|
+
const title = str(meta.title);
|
|
13
|
+
const description = str(meta.description);
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(Field, { label: "Social image", hint: "1200\u00D7630 recommended", asDiv: true, children: _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v) }) }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What the text helpers need from an element — a DOM Element satisfies it. */
|
|
2
|
+
export type TextHost = {
|
|
3
|
+
childElementCount: number;
|
|
4
|
+
firstChild: {
|
|
5
|
+
nodeType: number;
|
|
6
|
+
nodeValue: string | null;
|
|
7
|
+
nextSibling: unknown;
|
|
8
|
+
} | null;
|
|
9
|
+
textContent: string | null;
|
|
10
|
+
};
|
|
11
|
+
/** A marked element the text session may edit: no child elements. A mark
|
|
12
|
+
* on a container (an <a> inside a <p>) falls through to the inspector. */
|
|
13
|
+
export declare const isPlainTextHost: (el: {
|
|
14
|
+
childElementCount: number;
|
|
15
|
+
}) => boolean;
|
|
16
|
+
/** Write `value` in place: into the one existing text node when there is
|
|
17
|
+
* one, so the site's React keeps pointing at the node it rendered and a
|
|
18
|
+
* refresh reconciles into it; else as new text content. False when the
|
|
19
|
+
* element is not a plain text host. */
|
|
20
|
+
export declare function writeTextInto(el: TextHost, value: string): boolean;
|
|
21
|
+
/** The container's layout the richtext editor root copies (spec §5), so a
|
|
22
|
+
* flex-column or grid container keeps its block spacing while its
|
|
23
|
+
* children are hidden behind the editor. Kebab-cased, for setProperty. */
|
|
24
|
+
export declare const LAYOUT_PROPS: readonly ["display", "flex-direction", "gap", "row-gap", "column-gap", "grid-template-columns", "grid-auto-flow", "align-items", "justify-content"];
|
|
25
|
+
export declare function layoutToCopy(computed: {
|
|
26
|
+
getPropertyValue(name: string): string;
|
|
27
|
+
}): Record<string, string>;
|