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.
Files changed (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. 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 { Banner as BannerBox, Breadcrumb, Button, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
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) => ops.pages.saveDraft(id, treeLocaleRef.current, 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
- mutate((t) => patchNodePath(t, node, path, value));
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
- 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);
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: "${section.name}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
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, section);
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 = selected ? rectById.get(selected.id) : undefined;
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
- : null;
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" })), 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 && (() => {
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 node = findNode(tree, session.node);
540
- const section = node ? sectionOf(node.type) : null;
541
- if (!node || !section)
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(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 &&
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, 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)
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}`))), 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) => ({
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 { Field, SectionLabel } from "../ui/primitives.js";
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;