smoodly 0.0.14 → 0.0.16
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 +94 -15
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
- package/dist/admin/editor/CanvasOverlay.js +5 -5
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +251 -23
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/SharedInspector.d.ts +23 -0
- package/dist/admin/editor/SharedInspector.js +13 -0
- package/dist/admin/editor/shared-edit.d.ts +39 -0
- package/dist/admin/editor/shared-edit.js +124 -0
- package/dist/admin/editor/useSharedEdit.d.ts +6 -0
- package/dist/admin/editor/useSharedEdit.js +18 -0
- package/dist/admin/forms/tabs.d.ts +9 -3
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +198 -23
- package/dist/admin/ops.d.ts +32 -0
- package/dist/admin/serialize.d.ts +0 -1
- package/dist/admin/serialize.js +0 -1
- package/dist/admin/shared-items.js +14 -0
- package/dist/admin/shell/AdminApp.js +1 -1
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/IconRail.js +3 -3
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/SharedFields.d.ts +21 -0
- package/dist/admin/shell/SharedFields.js +15 -0
- package/dist/admin/shell/SharedForm.js +166 -52
- package/dist/admin/shell/SharedList.js +3 -2
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +13 -0
- package/dist/admin/ui/primitives.js +18 -2
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +46 -6
- package/dist/collections.d.ts +0 -6
- package/dist/collections.js +0 -1
- package/dist/entry-store.d.ts +38 -13
- package/dist/entry-store.js +90 -23
- package/dist/next/page-renderer.js +8 -4
- package/dist/shared.d.ts +8 -1
- package/dist/shared.js +10 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +17 -7
- package/dist/sql.js +18 -15
- package/dist/store.d.ts +38 -13
- package/dist/store.js +82 -12
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +101 -25
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +88 -19
- package/package.json +1 -1
|
@@ -28,7 +28,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
28
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
29
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
30
30
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
-
import { SHARED_NODE_TYPE } from "../../shared.js";
|
|
31
|
+
import { SHARED_NODE_TYPE, STYLES_KEY, sharedNameOf } from "../../shared.js";
|
|
32
32
|
import { descriptorAt, getAtPath } from "../field-paths.js";
|
|
33
33
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
34
34
|
import { revealField } from "../forms/reveal.js";
|
|
@@ -41,19 +41,24 @@ import { InsertPicker } from "./InsertPicker.js";
|
|
|
41
41
|
import { NoticePane } from "./NoticePane.js";
|
|
42
42
|
import { Outline } from "./Outline.js";
|
|
43
43
|
import { PageInspector } from "./PageInspector.js";
|
|
44
|
+
import { SharedInspector } from "./SharedInspector.js";
|
|
44
45
|
import { localeFromUrl } from "./locale-url.js";
|
|
45
46
|
import { plusButtonsFor } from "./overlay-geometry.js";
|
|
46
47
|
import { useCanvasBridge } from "./useCanvasBridge.js";
|
|
47
48
|
import { useInlineSession } from "./useInlineSession.js";
|
|
48
49
|
import { usePageLocale } from "./usePageLocale.js";
|
|
50
|
+
import { useSharedEdit } from "./useSharedEdit.js";
|
|
49
51
|
import { BASE } from "../shell/base.js";
|
|
50
|
-
import { RouteLink } from "../shell/RouteLink.js";
|
|
51
52
|
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
52
53
|
import { entryTitle } from "../shell/entries-list.js";
|
|
53
54
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
54
55
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
55
|
-
import {
|
|
56
|
+
import { PublishControl } from "../ui/PublishControl.js";
|
|
57
|
+
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
58
|
+
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
|
|
56
59
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
60
|
+
/** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
|
|
61
|
+
export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ? "&" : "?"}smoodly-version=${encodeURIComponent(versionId)}`;
|
|
57
62
|
export function EditorView({ ops, registry, id }) {
|
|
58
63
|
const [record, setRecord] = useState(null);
|
|
59
64
|
const [tree, setTree] = useState(null);
|
|
@@ -76,6 +81,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
76
81
|
const [tab, setTab] = useState("Content");
|
|
77
82
|
const [banner, setBanner] = useState(null);
|
|
78
83
|
const [published, setPublished] = useState(false);
|
|
84
|
+
/** An action of the page's control in flight. */
|
|
85
|
+
const [pageBusy, setPageBusy] = useState(null);
|
|
86
|
+
/** History's rows while the modal is open (spec 2026-09-13 history §5). */
|
|
87
|
+
const [history, setHistory] = useState(null);
|
|
88
|
+
/** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
|
|
89
|
+
const [dirty, setDirty] = useState(false);
|
|
90
|
+
/** Any edit on this canvas yet: the autosave indicator stays hidden until the first one (§6). */
|
|
91
|
+
const [touched, setTouched] = useState(false);
|
|
92
|
+
const [sharedBusy, setSharedBusy] = useState(null);
|
|
93
|
+
/** A shared edit since the item's last save ack; and any shared edit on this canvas yet. */
|
|
94
|
+
const [sharedDirty, setSharedDirty] = useState(false);
|
|
95
|
+
const [sharedTouched, setSharedTouched] = useState(false);
|
|
79
96
|
const [refOptions, setRefOptions] = useState({});
|
|
80
97
|
// Shared item name → row id, loaded once per editor mount: the picker's
|
|
81
98
|
// Shared group and a bound locked zone need the id to place a node.
|
|
@@ -93,11 +110,33 @@ export function EditorView({ ops, registry, id }) {
|
|
|
93
110
|
// now, or once an inline session ends (spec 2026-09-10 §7).
|
|
94
111
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
95
112
|
read: () => treeRef.current,
|
|
96
|
-
persist: (t) =>
|
|
113
|
+
persist: async (t) => {
|
|
114
|
+
const target = treeLocaleRef.current;
|
|
115
|
+
const r = await ops.pages.saveDraft(id, target, t);
|
|
116
|
+
if (r.ok) {
|
|
117
|
+
// The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
|
|
118
|
+
setRecord((rec) => rec?.locales[target]
|
|
119
|
+
? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
|
|
120
|
+
: rec);
|
|
121
|
+
setDirty(false);
|
|
122
|
+
}
|
|
123
|
+
return r;
|
|
124
|
+
},
|
|
97
125
|
onSaved: () => inlineRef.current?.onSaved(),
|
|
98
126
|
});
|
|
127
|
+
// Edits to a shared block go to the shared entry through their own
|
|
128
|
+
// autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
|
|
129
|
+
// through the same inline-session deferral as the page's.
|
|
130
|
+
const sharedEdit = useSharedEdit({ ops, onSaved: () => { setSharedDirty(false); inlineRef.current?.onSaved(); } });
|
|
131
|
+
const patchShared = (name, path, value) => {
|
|
132
|
+
sharedEdit.patch(name, path, value);
|
|
133
|
+
setSharedDirty(true);
|
|
134
|
+
setSharedTouched(true);
|
|
135
|
+
};
|
|
136
|
+
/** Both targets, page first: a locale switch must leave nothing pending on either. Page Publish keeps `flush()` — it never touches a shared item. */
|
|
137
|
+
const flushAll = useCallback(async () => (await flush()) && (await sharedEdit.flush()), [flush, sharedEdit.flush]);
|
|
99
138
|
const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
|
|
100
|
-
registry, ops, id, record, setRecord, flush,
|
|
139
|
+
registry, ops, id, record, setRecord, flush: flushAll,
|
|
101
140
|
fail: (text) => setBanner({ text }),
|
|
102
141
|
clearBanner: () => setBanner(null),
|
|
103
142
|
reload: () => setReloadKey((k) => k + 1),
|
|
@@ -134,6 +173,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
134
173
|
setSelectedId(null);
|
|
135
174
|
setHoverId(null);
|
|
136
175
|
setFolder(false);
|
|
176
|
+
sharedEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
|
|
177
|
+
setDirty(false);
|
|
178
|
+
setTouched(false);
|
|
179
|
+
setSharedDirty(false);
|
|
180
|
+
setSharedTouched(false);
|
|
137
181
|
// ops is a server action — it can reject outright (offline, 500,
|
|
138
182
|
// aborted), not just resolve to a typed error. Either failure mode
|
|
139
183
|
// must land on the same full-pane error, never a hung load.
|
|
@@ -232,6 +276,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
232
276
|
return null;
|
|
233
277
|
return registry.shared.find((s) => s.name === node.fields?.item) ?? null;
|
|
234
278
|
}, [registry]);
|
|
279
|
+
/** The shared item behind a node id: a placement's, by the tree, or a layout-rendered
|
|
280
|
+
* item's, by its `shared:` id (spec 2026-09-13 §1). Reads the refs — safe at event time. */
|
|
281
|
+
const sharedItemOf = useCallback((id) => {
|
|
282
|
+
const node = treeRef.current ? findNode(treeRef.current, id) : null;
|
|
283
|
+
if (node)
|
|
284
|
+
return sharedOf(node);
|
|
285
|
+
const name = sharedNameOf(id);
|
|
286
|
+
return name ? registryRef.current.shared.find((s) => s.name === name) ?? null : null;
|
|
287
|
+
}, [sharedOf]);
|
|
235
288
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
236
289
|
const refTargets = useMemo(() => {
|
|
237
290
|
const out = {};
|
|
@@ -292,6 +345,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
292
345
|
setSharedIds({}); });
|
|
293
346
|
return () => { cancelled = true; };
|
|
294
347
|
}, [ops]);
|
|
348
|
+
// A selected shared block loads its item for the canvas locale, once (spec 2026-09-13 §3).
|
|
349
|
+
const selectedItemName = selectedId ? sharedItemOf(selectedId)?.name ?? null : null;
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (selectedItemName)
|
|
352
|
+
void sharedEdit.load(selectedItemName, locale);
|
|
353
|
+
}, [selectedItemName, locale]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (sharedEdit.loadError)
|
|
356
|
+
setBanner({ text: sharedEdit.loadError });
|
|
357
|
+
}, [sharedEdit.loadError]);
|
|
295
358
|
// Selecting from the outline must reveal the block. The canvas column is
|
|
296
359
|
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
297
360
|
// the full document), so scroll it to the rect the bridge reported.
|
|
@@ -311,12 +374,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
311
374
|
return next;
|
|
312
375
|
});
|
|
313
376
|
mark();
|
|
377
|
+
setDirty(true);
|
|
378
|
+
setTouched(true);
|
|
314
379
|
}, [mark]);
|
|
315
380
|
// ── inline editing (spec 2026-09-10) ──────────────────────────────────
|
|
316
381
|
const inline = useInlineSession({
|
|
317
382
|
iframeRef, postToCanvas,
|
|
318
383
|
patch: (node, path, value) => {
|
|
319
|
-
|
|
384
|
+
const item = sharedItemOf(node);
|
|
385
|
+
if (item)
|
|
386
|
+
patchShared(item.name, path, value);
|
|
387
|
+
else
|
|
388
|
+
mutate((t) => patchNodePath(t, node, path, value));
|
|
320
389
|
// A second element marked with the same field follows the one being typed in.
|
|
321
390
|
if (typeof value === "string")
|
|
322
391
|
inlineRef.current?.writeText(node, path, value);
|
|
@@ -359,13 +428,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
359
428
|
};
|
|
360
429
|
const onFieldClick = (id, path, x, y) => {
|
|
361
430
|
const node = tree ? findNode(tree, id) : null;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
431
|
+
// A shared block's fields are the item's — the section's own (spec 2026-09-13 §5); any other block's are its section's.
|
|
432
|
+
const item = sharedItemOf(id);
|
|
433
|
+
const schema = item ?? (node ? sectionOf(node.type) : null);
|
|
434
|
+
if (!schema)
|
|
435
|
+
return;
|
|
436
|
+
const descriptor = descriptorAt(schema, path);
|
|
366
437
|
if (!descriptor) {
|
|
367
438
|
if (process.env.NODE_ENV !== "production") {
|
|
368
|
-
console.warn(`smoodly: "${
|
|
439
|
+
console.warn(`smoodly: "${item?.name ?? node?.type}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
|
|
369
440
|
}
|
|
370
441
|
return;
|
|
371
442
|
}
|
|
@@ -375,7 +446,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
375
446
|
if (kind && inline.start({ node: id, path, kind }, { x, y }))
|
|
376
447
|
return;
|
|
377
448
|
// Any other field, or a text mark on a container: the inspector reveals its widget.
|
|
378
|
-
reveal(path,
|
|
449
|
+
reveal(path, schema);
|
|
379
450
|
};
|
|
380
451
|
// A zone bound to a shared item needs the item's row id (ops.shared.list),
|
|
381
452
|
// which can land after the draft did. Fill then; nothing else changes.
|
|
@@ -443,11 +514,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
443
514
|
// ── publish ──────────────────────────────────────────────────────────
|
|
444
515
|
const doPublish = async () => {
|
|
445
516
|
setBanner(null);
|
|
517
|
+
setPageBusy("publish");
|
|
446
518
|
// Never publish behind a pending debounce.
|
|
447
519
|
const flushed = await flush();
|
|
448
520
|
if (!flushed) {
|
|
449
521
|
// The pre-publish flush failed (or is still behind, mid-retry) —
|
|
450
522
|
// publishing now would ship a stale draft. Bail instead.
|
|
523
|
+
setPageBusy(null);
|
|
451
524
|
setBanner({ text: "Couldn't save your latest changes — publish cancelled." });
|
|
452
525
|
return;
|
|
453
526
|
}
|
|
@@ -461,6 +534,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
461
534
|
// outright instead of resolving to a typed error.
|
|
462
535
|
result = { ok: false, code: "internal", message: "Publish failed — check your connection and try again." };
|
|
463
536
|
}
|
|
537
|
+
setPageBusy(null);
|
|
464
538
|
if (result.ok) {
|
|
465
539
|
setRecord(result.data);
|
|
466
540
|
setPublished(true);
|
|
@@ -470,6 +544,135 @@ export function EditorView({ ops, registry, id }) {
|
|
|
470
544
|
setBanner({ text: result.message, fields: result.fields });
|
|
471
545
|
}
|
|
472
546
|
};
|
|
547
|
+
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload the canvas. */
|
|
548
|
+
const doDiscard = async () => {
|
|
549
|
+
if (!window.confirm("Discard your changes and restore the published version?"))
|
|
550
|
+
return;
|
|
551
|
+
setBanner(null);
|
|
552
|
+
setPageBusy("discard");
|
|
553
|
+
if (!(await flushAll())) {
|
|
554
|
+
setPageBusy(null);
|
|
555
|
+
setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
let result;
|
|
559
|
+
try {
|
|
560
|
+
result = await ops.pages.discardDraft(id, locale);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
564
|
+
}
|
|
565
|
+
setPageBusy(null);
|
|
566
|
+
if (!result.ok) {
|
|
567
|
+
setBanner({ text: result.message });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
setRecord(result.data);
|
|
571
|
+
setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
|
|
572
|
+
};
|
|
573
|
+
/** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
|
|
574
|
+
const doUnpublish = async () => {
|
|
575
|
+
setBanner(null);
|
|
576
|
+
setPageBusy("unpublish");
|
|
577
|
+
let result;
|
|
578
|
+
try {
|
|
579
|
+
result = await ops.pages.unpublish(id, locale);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
583
|
+
}
|
|
584
|
+
setPageBusy(null);
|
|
585
|
+
if (!result.ok) {
|
|
586
|
+
setBanner({ text: result.message });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
setRecord(result.data);
|
|
590
|
+
};
|
|
591
|
+
/** History: flush first so the list's draft row is current, then list the locale's versions. */
|
|
592
|
+
const doHistory = async () => {
|
|
593
|
+
setBanner(null);
|
|
594
|
+
const row = record?.locales[locale];
|
|
595
|
+
if (!row)
|
|
596
|
+
return;
|
|
597
|
+
if (!(await flushAll())) {
|
|
598
|
+
setBanner({ text: "Couldn't save your latest changes — history not opened." });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let r;
|
|
602
|
+
try {
|
|
603
|
+
r = await ops.pages.listVersions(id, locale);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
607
|
+
}
|
|
608
|
+
if (!r.ok) {
|
|
609
|
+
setBanner({ text: r.message });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
setHistory(historyRows(r.data, row));
|
|
613
|
+
};
|
|
614
|
+
/** Restore: the version's tree becomes the draft; the canvas reloads from the store, as after Discard. */
|
|
615
|
+
const doRestore = async (versionId) => {
|
|
616
|
+
let result;
|
|
617
|
+
try {
|
|
618
|
+
result = await ops.pages.restore(id, locale, versionId);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
622
|
+
}
|
|
623
|
+
if (!result.ok) {
|
|
624
|
+
setBanner({ text: result.message });
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
setRecord(result.data);
|
|
628
|
+
setReloadKey((k) => k + 1);
|
|
629
|
+
return true;
|
|
630
|
+
};
|
|
631
|
+
/** The shared card's own actions (spec 2026-09-13 §5, publish flow §2): the item in the canvas locale, never the page. */
|
|
632
|
+
const doPublishShared = async (name) => {
|
|
633
|
+
setBanner(null);
|
|
634
|
+
setSharedBusy("publish");
|
|
635
|
+
const r = await sharedEdit.setStatus(name, "published");
|
|
636
|
+
setSharedBusy(null);
|
|
637
|
+
if (!r.ok)
|
|
638
|
+
setBanner({ text: r.message, fields: r.fields });
|
|
639
|
+
};
|
|
640
|
+
const doUnpublishShared = async (name) => {
|
|
641
|
+
setBanner(null);
|
|
642
|
+
setSharedBusy("unpublish");
|
|
643
|
+
const r = await sharedEdit.setStatus(name, "draft");
|
|
644
|
+
setSharedBusy(null);
|
|
645
|
+
if (!r.ok)
|
|
646
|
+
setBanner({ text: r.message, fields: r.fields });
|
|
647
|
+
};
|
|
648
|
+
const doDiscardShared = async (name) => {
|
|
649
|
+
if (!window.confirm("Discard your changes and restore the published version?"))
|
|
650
|
+
return;
|
|
651
|
+
setBanner(null);
|
|
652
|
+
setSharedBusy("discard");
|
|
653
|
+
if (!(await sharedEdit.flush())) {
|
|
654
|
+
setSharedBusy(null);
|
|
655
|
+
setBanner({ text: "Couldn't save your latest changes — discard cancelled." });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
let r;
|
|
659
|
+
try {
|
|
660
|
+
r = await ops.shared.discardDraft(name, locale);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
664
|
+
}
|
|
665
|
+
setSharedBusy(null);
|
|
666
|
+
if (!r.ok) {
|
|
667
|
+
setBanner({ text: r.message });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// The item reloads from the store; the canvas refreshes through the same path as a save ack.
|
|
671
|
+
sharedEdit.reset();
|
|
672
|
+
setSharedDirty(false);
|
|
673
|
+
void sharedEdit.load(name, locale);
|
|
674
|
+
inlineRef.current?.onSaved();
|
|
675
|
+
};
|
|
473
676
|
// ── keyboard ─────────────────────────────────────────────────────────
|
|
474
677
|
useEffect(() => {
|
|
475
678
|
const onKey = (e) => {
|
|
@@ -494,11 +697,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
494
697
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
495
698
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
496
699
|
const selectedShared = selected ? sharedOf(selected) : null;
|
|
700
|
+
// A layout-rendered item: selected by its `shared:` id, in no zone (spec 2026-09-13 §1).
|
|
701
|
+
const layoutItem = selectedId && !selected ? sharedItemOf(selectedId) : null;
|
|
702
|
+
const selectedItem = selectedShared ?? layoutItem;
|
|
497
703
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
498
704
|
const selLocked = policyOf(selZone)?.kind === "locked";
|
|
499
705
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
500
|
-
const selRect =
|
|
706
|
+
const selRect = selectedId ? rectById.get(selectedId) : undefined;
|
|
501
707
|
const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
|
|
708
|
+
const hoverShared = hoverId ? sharedItemOf(hoverId) !== null : false;
|
|
502
709
|
const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
|
|
503
710
|
const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
|
|
504
711
|
const editing = fieldRectOf(inline.session);
|
|
@@ -509,7 +716,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
509
716
|
title: selectedSection?.title ?? selectedShared?.title ?? selected.type,
|
|
510
717
|
shared: Boolean(selectedShared), locked: selLocked,
|
|
511
718
|
}
|
|
512
|
-
:
|
|
719
|
+
: layoutItem && selectedId && selRect
|
|
720
|
+
? {
|
|
721
|
+
node: { id: selectedId, type: SHARED_NODE_TYPE, fields: { item: layoutItem.name } },
|
|
722
|
+
rect: selRect, title: layoutItem.title, shared: true, locked: false, layout: true,
|
|
723
|
+
}
|
|
724
|
+
: null;
|
|
513
725
|
const labelOf = (node) => {
|
|
514
726
|
const shared = sharedOf(node);
|
|
515
727
|
return sectionOf(node.type)?.title ?? (shared ? `${shared.title} · shared` : node.type);
|
|
@@ -525,7 +737,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
525
737
|
scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
|
526
738
|
};
|
|
527
739
|
const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
|
|
528
|
-
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
740
|
+
const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
|
|
529
741
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
530
742
|
const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
|
|
531
743
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
|
|
@@ -534,23 +746,39 @@ export function EditorView({ ops, registry, id }) {
|
|
|
534
746
|
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
747
|
},
|
|
536
748
|
{ 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" })),
|
|
749
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverShared: hoverShared, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
538
750
|
const { session } = inline.richtext;
|
|
539
|
-
const
|
|
540
|
-
const
|
|
541
|
-
|
|
751
|
+
const item = sharedItemOf(session.node);
|
|
752
|
+
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
753
|
+
const schema = item ?? (node ? sectionOf(node.type) : null);
|
|
754
|
+
const fields = item ? sharedEdit.items[item.name]?.fields : node?.fields;
|
|
755
|
+
if (!schema || !fields)
|
|
542
756
|
return null;
|
|
543
|
-
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(
|
|
544
|
-
|
|
757
|
+
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
|
|
758
|
+
inline.markChanged();
|
|
759
|
+
if (item)
|
|
760
|
+
patchShared(item.name, session.path, doc);
|
|
761
|
+
else
|
|
762
|
+
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
763
|
+
}, onEscape: inline.end }));
|
|
764
|
+
})()] }) })] }), _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?.title }), selectedItem && (sharedTouched || sharedEdit.saveState === "error") && _jsx(SaveIndicator, { state: sharedEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
545
765
|
!isFixedNode(record, registry) &&
|
|
546
766
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
547
767
|
? () => { void doRemoveLocale(); }
|
|
548
|
-
: undefined })) : null,
|
|
768
|
+
: undefined })) : null, (selectedSection || selectedItem) && (_jsxs("div", { className: "sm-inspector__body", children: [selected && 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
769
|
reveal(p, selectedSection); }, onChange: (value) => {
|
|
550
770
|
doPatch(selected.id, ns, key, value);
|
|
551
771
|
if (ns === "fields")
|
|
552
772
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
553
|
-
} }, `${selected.id}.${ns}.${key}`))),
|
|
773
|
+
} }, `${selected.id}.${ns}.${key}`))), selectedItem && selectedId && (_jsx(SharedInspector, { item: selectedItem, edit: sharedEdit.items[selectedItem.name] ?? null, locale: locale, entries: entries, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: sharedDirty || sharedEdit.saveState !== "saved", busy: sharedBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
774
|
+
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
775
|
+
patchShared(selectedItem.name, ns === "fields" ? key : `${STYLES_KEY}.${key}`, value);
|
|
776
|
+
if (ns === "fields") {
|
|
777
|
+
const current = sharedEdit.items[selectedItem.name];
|
|
778
|
+
if (current)
|
|
779
|
+
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
780
|
+
}
|
|
781
|
+
}, onPublish: () => { void doPublishShared(selectedItem.name); }, onDiscard: () => { void doDiscardShared(selectedItem.name); }, onUnpublish: () => { void doUnpublishShared(selectedItem.name); } }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
|
|
554
782
|
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
555
783
|
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
|
|
556
784
|
})), onAddFirst: (zone) => {
|
|
@@ -8,7 +8,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
8
8
|
// tab and the remove-from-locale button live there (spec 2026-09-10 page
|
|
9
9
|
// fields §5).
|
|
10
10
|
import { useEffect, useState } from "react";
|
|
11
|
-
import {
|
|
11
|
+
import { updatedLine } from "../ui/format.js";
|
|
12
|
+
import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
|
|
12
13
|
export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
|
|
13
14
|
const row = record.locales[locale];
|
|
14
15
|
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
@@ -37,5 +38,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
|
|
|
37
38
|
void commit(key);
|
|
38
39
|
}
|
|
39
40
|
};
|
|
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") }) })] }));
|
|
41
|
+
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 })] }));
|
|
41
42
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AdminShared } from "../serialize.ts";
|
|
2
|
+
import type { TabEntry } from "../forms/tabs.ts";
|
|
3
|
+
import type { SharedEditItem } from "./shared-edit.ts";
|
|
4
|
+
import { type RefOption } from "../shell/SharedFields.tsx";
|
|
5
|
+
import { type PublishBusy } from "../ui/PublishControl.tsx";
|
|
6
|
+
export declare function SharedInspector({ item, edit, locale, entries, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
|
|
7
|
+
item: AdminShared;
|
|
8
|
+
/** null while the item loads. */
|
|
9
|
+
edit: SharedEditItem | null;
|
|
10
|
+
locale: string;
|
|
11
|
+
entries: TabEntry[];
|
|
12
|
+
multilingual: boolean;
|
|
13
|
+
refOptions: Record<string, RefOption[]>;
|
|
14
|
+
inlinePath?: string;
|
|
15
|
+
onEditHere?(): void;
|
|
16
|
+
onChange(ns: "fields" | "styles", key: string, value: unknown): void;
|
|
17
|
+
/** An edit the store has not confirmed (dirty, or the item's autosave not in `saved`). */
|
|
18
|
+
pending: boolean;
|
|
19
|
+
busy: PublishBusy;
|
|
20
|
+
onPublish(): void;
|
|
21
|
+
onDiscard(): void;
|
|
22
|
+
onUnpublish(): void;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { BASE } from "../shell/base.js";
|
|
4
|
+
import { localeStatusOf } from "../shell/pages-tree.js";
|
|
5
|
+
import { SharedFields } from "../shell/SharedFields.js";
|
|
6
|
+
import { PublishControl } from "../ui/PublishControl.js";
|
|
7
|
+
export function SharedInspector({ item, edit, locale, entries, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
|
|
8
|
+
const row = edit?.record.locales[locale];
|
|
9
|
+
// The same three states the shared list shows; the control decides what applies.
|
|
10
|
+
const status = row ? localeStatusOf(row) : "draft";
|
|
11
|
+
const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
|
|
12
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Shared content \u2014 changes every page it is used on." }), edit && (_jsxs("p", { className: "sm-hint", children: [pages.length === 0 ? "Not placed on any page." : `Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), edit ? (_jsx(SharedFields, { item: item, entries: entries, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
|
|
2
|
+
import type { AdminOps, OpResult } from "../ops.ts";
|
|
3
|
+
import { type SaveState } from "./autosave.ts";
|
|
4
|
+
export type SharedEditItem = {
|
|
5
|
+
name: string;
|
|
6
|
+
locale: string;
|
|
7
|
+
record: EntryRecord;
|
|
8
|
+
/** The merged draft fields; a visual item's styles under `$styles`. */
|
|
9
|
+
fields: Record<string, unknown>;
|
|
10
|
+
usage: EntryUsage[];
|
|
11
|
+
/** The last save's field errors by dotted path; cleared by a successful save. */
|
|
12
|
+
errors: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
export type SharedEditState = {
|
|
15
|
+
items: Record<string, SharedEditItem>;
|
|
16
|
+
saveState: SaveState;
|
|
17
|
+
loadError: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type SharedEdit = {
|
|
20
|
+
getState(): SharedEditState;
|
|
21
|
+
subscribe(listener: () => void): () => void;
|
|
22
|
+
/** Load an item for a locale once; a repeat while loaded or loading is a no-op. */
|
|
23
|
+
load(name: string, locale: string): Promise<void>;
|
|
24
|
+
/** Set the value at a dotted path (`tagline`, `$styles.tone`, `columns.1.title`); a debounced save follows. */
|
|
25
|
+
patch(name: string, path: string, value: unknown): void;
|
|
26
|
+
flush(): Promise<boolean>;
|
|
27
|
+
/** Flush, then set the item's status in its locale; the record updates on success. */
|
|
28
|
+
setStatus(name: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
29
|
+
/** Drop every item (a new locale, a new canvas). Flush BEFORE calling this. */
|
|
30
|
+
reset(): void;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
};
|
|
33
|
+
export declare function createSharedEdit(opts: {
|
|
34
|
+
ops: Pick<AdminOps, "shared">;
|
|
35
|
+
onSaved?: () => void;
|
|
36
|
+
debounceMs?: number;
|
|
37
|
+
retryBaseMs?: number;
|
|
38
|
+
retryCapMs?: number;
|
|
39
|
+
}): SharedEdit;
|