smoodly 0.0.8 → 0.0.10

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 (79) hide show
  1. package/README.md +228 -18
  2. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  3. package/dist/admin/editor/CanvasOverlay.js +39 -0
  4. package/dist/admin/editor/EditorView.js +56 -237
  5. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  6. package/dist/admin/editor/InsertPicker.js +18 -0
  7. package/dist/admin/editor/NoticePane.d.ts +4 -0
  8. package/dist/admin/editor/NoticePane.js +4 -0
  9. package/dist/admin/editor/Outline.d.ts +18 -0
  10. package/dist/admin/editor/Outline.js +12 -0
  11. package/dist/admin/editor/locale-url.d.ts +12 -0
  12. package/dist/admin/editor/locale-url.js +32 -0
  13. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  14. package/dist/admin/editor/overlay-geometry.js +27 -0
  15. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  16. package/dist/admin/editor/useCanvasBridge.js +53 -0
  17. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  18. package/dist/admin/editor/usePageLocale.js +88 -0
  19. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  20. package/dist/admin/forms/FieldWidget.js +68 -8
  21. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  22. package/dist/admin/forms/RichtextToolbar.js +37 -0
  23. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  24. package/dist/admin/forms/RichtextWidget.js +123 -0
  25. package/dist/admin/forms/list.d.ts +18 -0
  26. package/dist/admin/forms/list.js +81 -0
  27. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  28. package/dist/admin/forms/richtext-extensions.js +74 -0
  29. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  30. package/dist/admin/forms/richtext-paste.js +59 -0
  31. package/dist/admin/index.d.ts +0 -1
  32. package/dist/admin/index.js +0 -1
  33. package/dist/admin/next/create-admin.d.ts +3 -7
  34. package/dist/admin/next/create-admin.js +14 -5
  35. package/dist/admin/richtext-doc.d.ts +7 -0
  36. package/dist/admin/richtext-doc.js +18 -0
  37. package/dist/admin/serialize.d.ts +4 -0
  38. package/dist/admin/serialize.js +3 -0
  39. package/dist/admin/shell/AdminApp.d.ts +7 -2
  40. package/dist/admin/shell/AdminApp.js +12 -6
  41. package/dist/admin/shell/EntriesList.js +3 -2
  42. package/dist/admin/shell/EntryForm.js +9 -25
  43. package/dist/admin/shell/ListView.js +3 -1
  44. package/dist/admin/shell/PagesList.js +3 -2
  45. package/dist/admin/shell/RouteLink.d.ts +4 -0
  46. package/dist/admin/shell/RouteLink.js +12 -0
  47. package/dist/admin/shell/SharedForm.js +4 -22
  48. package/dist/admin/shell/router.d.ts +37 -0
  49. package/dist/admin/shell/router.js +84 -0
  50. package/dist/admin/shell/session.js +3 -1
  51. package/dist/admin/ui/primitives.d.ts +2 -1
  52. package/dist/admin/ui/primitives.js +5 -3
  53. package/dist/admin/ui/theme.d.ts +1 -1
  54. package/dist/admin/ui/theme.js +40 -1
  55. package/dist/admin/validate.d.ts +7 -2
  56. package/dist/admin/validate.js +53 -13
  57. package/dist/collections.d.ts +0 -14
  58. package/dist/collections.js +0 -10
  59. package/dist/entry-store.js +7 -1
  60. package/dist/fields.d.ts +18 -2
  61. package/dist/fields.js +51 -5
  62. package/dist/index.d.ts +7 -2
  63. package/dist/index.js +6 -1
  64. package/dist/refs.js +5 -0
  65. package/dist/resolve.js +3 -0
  66. package/dist/richtext-render.d.ts +3 -0
  67. package/dist/richtext-render.js +62 -0
  68. package/dist/shared-id.d.ts +3 -0
  69. package/dist/shared-id.js +36 -0
  70. package/dist/sql-space.d.ts +4 -0
  71. package/dist/sql-space.js +5 -1
  72. package/dist/sql.js +1 -1
  73. package/dist/supabase-entry-store.d.ts +8 -0
  74. package/dist/supabase-entry-store.js +37 -2
  75. package/dist/toolset.d.ts +30 -0
  76. package/dist/toolset.js +49 -0
  77. package/package.json +11 -1
  78. package/dist/admin/richtext.d.ts +0 -14
  79. package/dist/admin/richtext.js +0 -17
@@ -13,6 +13,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  // that locale (spec 2026-09-06 §6); switching flushes the pending save
14
14
  // first.
15
15
  //
16
+ // This file orchestrates: load, tree gestures, publish, and the layout.
17
+ // The pieces live beside it — `useCanvasBridge` (geometry and messages),
18
+ // `usePageLocale` (switch/add/remove), `CanvasOverlay` and
19
+ // `overlay-geometry` (the chrome above the iframe), `InsertPicker`,
20
+ // `Outline`, `PageSettings`, `NoticePane`.
21
+ //
16
22
  // Chrome follows the design system's visual-editor screen: top bar, toned
17
23
  // canvas around a white page, 300px inspector holding fields then outline.
18
24
  //
@@ -22,60 +28,35 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
28
  import { SHARED_NODE_TYPE } from "../../shared.js";
23
29
  import { FieldWidget } from "../forms/FieldWidget.js";
24
30
  import { tabsFor } from "../forms/tabs.js";
25
- import { allowedSections, allowedShared, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
31
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
26
32
  import { useDraftAutosave } from "./autosave.js";
33
+ import { CanvasOverlay } from "./CanvasOverlay.js";
34
+ import { InsertPicker } from "./InsertPicker.js";
35
+ import { NoticePane } from "./NoticePane.js";
36
+ import { Outline } from "./Outline.js";
27
37
  import { PageSettings } from "./PageSettings.js";
38
+ import { localeFromUrl } from "./locale-url.js";
39
+ import { plusButtonsFor } from "./overlay-geometry.js";
40
+ import { useCanvasBridge } from "./useCanvasBridge.js";
41
+ import { usePageLocale } from "./usePageLocale.js";
28
42
  import { BASE } from "../shell/base.js";
43
+ import { RouteLink } from "../shell/RouteLink.js";
29
44
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
30
45
  import { entryTitle } from "../shell/entries-list.js";
31
46
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
32
- import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, StatusBadge, Tabs, TopBar, cx, } from "../ui/primitives.js";
47
+ import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
33
48
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
34
- const GEOMETRY_TIMEOUT_MS = 4000;
35
- /** The locale to edit: from the URL (the Pages list links here with
36
- * ?locale=), else the default. */
37
- function localeFromUrl(registry) {
38
- try {
39
- const l = new URLSearchParams(window.location.search).get("locale");
40
- if (l && registry.locales.supported.includes(l))
41
- return l;
42
- }
43
- catch {
44
- // no window — the default is fine
45
- }
46
- return registry.locales.default;
47
- }
48
- /** Point the address bar at a locale, keeping the rest of the query
49
- * string — a bare `?locale=…` would drop every other param. */
50
- function writeLocaleToUrl(next) {
51
- try {
52
- const u = new URL(window.location.href);
53
- u.searchParams.set("locale", next);
54
- window.history.replaceState(null, "", u);
55
- }
56
- catch {
57
- // no window (or no history) — the URL is cosmetic here
58
- }
59
- }
60
49
  export function EditorView({ ops, registry, id }) {
61
50
  const [record, setRecord] = useState(null);
62
51
  const [tree, setTree] = useState(null);
63
52
  const [loadError, setLoadError] = useState(null);
64
53
  const [ready, setReady] = useState(false);
65
54
  const [folder, setFolder] = useState(false);
66
- // The locale being edited. Every load, edit, autosave and publish
67
- // targets this ONE locale (spec 2026-09-06 §6).
68
- const [locale, setLocale] = useState(() => localeFromUrl(registry));
69
55
  const [reloadKey, setReloadKey] = useState(0);
70
- const [adding, setAdding] = useState(false);
71
56
  const [paths, setPaths] = useState({});
72
57
  const [urls, setUrls] = useState({});
73
58
  // The locales the parent exists in — "Add locale" offers only these.
74
59
  const [parentLocales, setParentLocales] = useState([]);
75
- const [rects, setRects] = useState([]);
76
- const [emptyZones, setEmptyZones] = useState([]);
77
- const [docH, setDocH] = useState(900);
78
- const [geometry, setGeometry] = useState("waiting");
79
60
  const [selectedId, setSelectedId] = useState(null);
80
61
  const [hoverId, setHoverId] = useState(null);
81
62
  const [picker, setPicker] = useState(null);
@@ -86,14 +67,14 @@ export function EditorView({ ops, registry, id }) {
86
67
  // Shared item name → row id, loaded once per editor mount: the picker's
87
68
  // Shared group and a bound locked zone need the id to place a node.
88
69
  const [sharedIds, setSharedIds] = useState(null);
89
- const [hoverPlus, setHoverPlus] = useState(null);
90
70
  const iframeRef = useRef(null);
91
71
  const scrollerRef = useRef(null);
92
72
  const treeRef = useRef(null);
93
73
  const registryRef = useRef(registry);
94
74
  registryRef.current = registry;
95
- // The tree in memory belongs to the locale it was loaded for; a switch flushes first (switchLocale), so the ref and the tree always agree.
96
- const treeLocaleRef = useRef(locale);
75
+ // The tree in memory belongs to the locale it was loaded for; a switch
76
+ // flushes first (switchLocale), so the ref and the tree always agree.
77
+ const treeLocaleRef = useRef(localeFromUrl(registry));
97
78
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
98
79
  // retry on failure, and the bridge refreshes the iframe on the save ack.
99
80
  const { saveState, mark, flush } = useDraftAutosave({
@@ -101,6 +82,12 @@ export function EditorView({ ops, registry, id }) {
101
82
  persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
102
83
  onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
103
84
  });
85
+ const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
86
+ registry, ops, id, record, setRecord, flush,
87
+ fail: (text) => setBanner({ text }),
88
+ clearBanner: () => setBanner(null),
89
+ reload: () => setReloadKey((k) => k + 1),
90
+ });
104
91
  // The URL that serves this page, per locale, from the store's paths
105
92
  // index — the label is the CMS path, the canvas and "View live" take
106
93
  // the site URL (href applied server-side by pages.get).
@@ -108,6 +95,11 @@ export function EditorView({ ops, registry, id }) {
108
95
  const label = path;
109
96
  const siteUrl = urls[locale] ?? path;
110
97
  const iframeSrc = ready ? withEditorParam(siteUrl) : null;
98
+ // Canvas-originated selection: the node is under the cursor already, so
99
+ // this path deliberately skips the scroll every other selection does.
100
+ const { rects, emptyZones, docH, geometry, postToCanvas } = useCanvasBridge({
101
+ iframeRef, iframeSrc, onSelect: setSelectedId, onHover: setHoverId,
102
+ });
111
103
  // ── load: draft mode FIRST, then the record; only then point the iframe
112
104
  // at the site route (an iframe loaded before the cookie exists would
113
105
  // render the published tree).
@@ -184,10 +176,6 @@ export function EditorView({ ops, registry, id }) {
184
176
  })();
185
177
  return () => { cancelled = true; };
186
178
  }, [ops, id, locale, reloadKey]);
187
- // A new canvas URL (a locale switch, a rename) means new geometry — the
188
- // previous page's rects are stale the instant the iframe starts loading
189
- // a different route, so clear them alongside resetting geometry.
190
- useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
191
179
  useEffect(() => { treeRef.current = tree; }, [tree]);
192
180
  // After a rename only the record and the URL indexes are stale — never
193
181
  // re-read the tree here: the draft in memory may be ahead of the store.
@@ -213,84 +201,6 @@ export function EditorView({ ops, registry, id }) {
213
201
  await reload();
214
202
  return null;
215
203
  };
216
- /** Switch the edited locale. The pending autosave must land first, or
217
- * it would persist THIS tree under the new locale. */
218
- const switchLocale = async (next) => {
219
- if (next === locale)
220
- return;
221
- const flushed = await flush();
222
- if (!flushed) {
223
- setBanner({ text: "Couldn't save your latest changes — switch cancelled." });
224
- return;
225
- }
226
- writeLocaleToUrl(next);
227
- setBanner(null);
228
- setLocale(next);
229
- };
230
- /** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
231
- const doAddLocale = async (target) => {
232
- if (!record || adding)
233
- return;
234
- const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
235
- if (from === locale) {
236
- const flushed = await flush();
237
- if (!flushed) {
238
- setBanner({ text: "Couldn't save your latest changes — add cancelled." });
239
- return;
240
- }
241
- }
242
- setAdding(true);
243
- let result;
244
- try {
245
- result = await ops.pages.addLocale(id, target, { from });
246
- }
247
- catch {
248
- result = { ok: false, code: "internal", message: "Couldn't reach the server." };
249
- }
250
- setAdding(false);
251
- if (!result.ok) {
252
- setBanner({ text: result.message });
253
- return;
254
- }
255
- // The record now HAS the locale; keep it, or the pane below would go
256
- // on offering "doesn't exist yet" until the reload lands.
257
- setRecord(result.data);
258
- if (target === locale)
259
- setReloadKey((k) => k + 1);
260
- else
261
- void switchLocale(target);
262
- };
263
- const doRemoveLocale = async () => {
264
- if (!record)
265
- return;
266
- const remaining = Object.keys(record.locales).filter((l) => l !== locale);
267
- if (remaining.length === 0)
268
- return;
269
- if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
270
- return;
271
- // Let a pending save land before the row goes, so no save races the
272
- // delete — and if it cannot land, stop: a retry mid-flight would
273
- // recreate the very draft the removal is deleting.
274
- const flushed = await flush();
275
- if (!flushed) {
276
- setBanner({ text: "Couldn't save your latest changes — removal cancelled." });
277
- return;
278
- }
279
- let result;
280
- try {
281
- result = await ops.pages.removeLocale(id, locale);
282
- }
283
- catch {
284
- result = { ok: false, code: "internal", message: "Couldn't reach the server." };
285
- }
286
- if (!result.ok) {
287
- setBanner({ text: result.message });
288
- return;
289
- }
290
- const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
291
- writeLocaleToUrl(next);
292
- setLocale(next);
293
- };
294
204
  const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
295
205
  const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
296
206
  const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
@@ -361,43 +271,6 @@ export function EditorView({ ops, registry, id }) {
361
271
  setSharedIds({}); });
362
272
  return () => { cancelled = true; };
363
273
  }, [ops]);
364
- // ── canvas messaging ─────────────────────────────────────────────────
365
- const postToCanvas = useCallback((m) => {
366
- iframeRef.current?.contentWindow?.postMessage(m, "*");
367
- }, []);
368
- useEffect(() => {
369
- const onMessage = (e) => {
370
- const m = e.data;
371
- if (m?.source !== "smoodly")
372
- return;
373
- if (e.source !== iframeRef.current?.contentWindow)
374
- return;
375
- if (m.kind === "rects") {
376
- setRects(m.nodes);
377
- setEmptyZones(m.emptyZones);
378
- setDocH(m.docH);
379
- setGeometry("ok");
380
- }
381
- else if (m.kind === "select") {
382
- // Canvas-originated: the node is under the cursor already, so this
383
- // path deliberately skips the scroll every other selection does.
384
- setSelectedId(m.id);
385
- }
386
- else if (m.kind === "hover") {
387
- setHoverId(m.id);
388
- }
389
- };
390
- window.addEventListener("message", onMessage);
391
- return () => window.removeEventListener("message", onMessage);
392
- }, []);
393
- // Bridge degradation: geometry silence is survivable — the iframe and
394
- // the outline keep working, only the overlay goes dark.
395
- useEffect(() => {
396
- if (!iframeSrc || geometry !== "waiting")
397
- return;
398
- const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
399
- return () => clearTimeout(t);
400
- }, [iframeSrc, geometry]);
401
274
  // Selecting from the outline must reveal the block. The canvas column is
402
275
  // the scroller now (the iframe has no scrollbar of its own — it is sized to
403
276
  // the full document), so scroll it to the rect the bridge reported.
@@ -520,14 +393,14 @@ export function EditorView({ ops, registry, id }) {
520
393
  return () => window.removeEventListener("keydown", onKey);
521
394
  }, []);
522
395
  if (loadError) {
523
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(BannerBox, { tone: "error", children: loadError }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
396
+ return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
524
397
  }
525
398
  if (folder) {
526
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [_jsx(BannerBox, { tone: "warn", children: "This is a folder \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." }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
399
+ return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a folder \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." }) }));
527
400
  }
528
401
  if (record && !record.locales[locale]) {
529
402
  const from = sourceLocaleOf(record, registry);
530
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [banner && _jsx(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: banner.text }), _jsxs(BannerBox, { tone: "warn", children: ["This page doesn't exist in ", locale, " yet. Adding it copies the ", from, " draft as the first ", locale, " version."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => { void doAddLocale(locale); }, disabled: adding, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(from); }, children: ["Back to ", from] })] }), _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
403
+ return (_jsxs(NoticePane, { children: [banner && _jsx(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: banner.text }), _jsxs(BannerBox, { tone: "warn", children: ["This page doesn't exist in ", locale, " yet. Adding it copies the ", from, " draft as the first ", locale, " version."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => { void doAddLocale(locale); }, disabled: adding, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(from); }, children: ["Back to ", from] })] })] }));
531
404
  }
532
405
  const selected = tree && selectedId ? findNode(tree, selectedId) : null;
533
406
  const selectedSection = selected ? sectionOf(selected.type) : null;
@@ -538,6 +411,17 @@ export function EditorView({ ops, registry, id }) {
538
411
  const selRect = selected ? rectById.get(selected.id) : undefined;
539
412
  const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
540
413
  const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
414
+ const selection = selected && selRect && (selectedSection || selectedShared)
415
+ ? {
416
+ node: selected, rect: selRect,
417
+ title: selectedSection?.title ?? selectedShared?.title ?? selected.type,
418
+ shared: Boolean(selectedShared), locked: selLocked,
419
+ }
420
+ : null;
421
+ const labelOf = (node) => {
422
+ const shared = sharedOf(node);
423
+ return sectionOf(node.type)?.title ?? (shared ? `${shared.title} · shared` : node.type);
424
+ };
541
425
  // Reveal is defined here, where the rect map exists; select() calls it
542
426
  // through the ref so its own identity stays stable.
543
427
  revealRef.current = (id) => {
@@ -548,90 +432,25 @@ export function EditorView({ ops, registry, id }) {
548
432
  const top = rect.y - Math.max(0, (scroller.clientHeight - rect.h) / 2);
549
433
  scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
550
434
  };
551
- // "+" affordances: one per legal boundary in every insertable zone.
552
- const plusButtons = [];
553
- for (const zone of zoneOrder) {
554
- if (!zoneCanTake(zone))
555
- continue;
556
- // Carry each member's real position in the tree's zone array through
557
- // the rect filter — under refresh-on-change the rects lag the tree by
558
- // up to a save cycle, so a filtered-array position isn't a valid
559
- // insertNode index once a node is missing its rect.
560
- const members = nodesIn(zone)
561
- .map((n, treeIndex) => ({ rect: rectById.get(n.id), treeIndex }))
562
- .filter((m) => Boolean(m.rect));
563
- if (members.length === 0) {
564
- const zr = emptyZones.find((r) => r.zone === zone);
565
- if (zr)
566
- plusButtons.push({ zone, index: 0, y: zr.y + zr.h / 2 });
567
- }
568
- else {
569
- members.forEach(({ rect, treeIndex }, i) => {
570
- plusButtons.push({ zone, index: treeIndex, y: rect.y - 1 });
571
- if (i === members.length - 1)
572
- plusButtons.push({ zone, index: treeIndex + 1, y: rect.y + rect.h + 1 });
573
- });
574
- }
575
- }
435
+ const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
576
436
  const tabs = selectedSection ? tabsFor(selectedSection) : [];
577
437
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
578
438
  const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
579
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: "/admin/pages", items: [{ label: "Pages", href: "/admin/pages" }, { label }] }), 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: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), 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); } })) : (_jsxs("span", { children: ["\u00B7 ", locale] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (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-mono", style: {
439
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [{ label: "Pages", href: `${BASE}/pages` }, { label }] }), 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: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), 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); } })) : (_jsxs("span", { children: ["\u00B7 ", locale] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (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-mono", style: {
580
440
  height: 240, display: "grid", placeItems: "center",
581
441
  color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
582
- }, children: "LOADING CANVAS\u2026" })), _jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed sm-mono", style: {
583
- position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
584
- height: Math.max(56, z.h), fontSize: "var(--text-xs)",
585
- letterSpacing: ".04em", textTransform: "uppercase", color: "var(--gray-500)",
586
- borderColor: "var(--gray-300)",
587
- }, children: [z.zone, " \u00B7 empty"] }, `empty-${z.zone}`))), hoverRect && (_jsx("div", { style: {
588
- position: "absolute", left: hoverRect.x, top: hoverRect.y,
589
- width: hoverRect.w, height: hoverRect.h,
590
- outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
591
- outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
592
- } })), selRect && selected && (selectedSection || selectedShared) && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
593
- position: "absolute", left: selRect.x, top: selRect.y,
594
- width: selRect.w, height: selRect.h,
595
- outline: "var(--outline-selection)", outlineOffset: -1.5,
596
- borderRadius: "var(--radius-sm)",
597
- } }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection?.title ?? selectedShared?.title, selectedShared && " · shared", selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
598
- position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
599
- transform: "translateX(-100%)", pointerEvents: "auto",
600
- }, children: selLocked ? (_jsx("span", { className: "sm-mono", style: { fontSize: "var(--text-xs)", color: "#8C94A8", padding: "4px 8px" }, children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => doMove(selected.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => doMove(selected.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => doDuplicate(selected), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => doRemove(selected.id), children: "\u00D7" })] })) })] })), plusButtons.map((p) => {
601
- const key = `${p.zone}-${p.index}`;
602
- const hot = hoverPlus === key;
603
- return (_jsxs("div", { style: { position: "absolute", left: 0, right: 0, top: p.y }, children: [hot && _jsx("div", { className: "sm-guide", style: { margin: "0 12px" } }), _jsxs("div", { style: {
604
- position: "absolute", left: "50%", top: 0, transform: "translate(-50%,-50%)",
605
- display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
606
- pointerEvents: "auto",
607
- }, children: [_jsx("button", { className: "sm-plus", "aria-label": `Add to ${p.zone}`, onMouseEnter: () => setHoverPlus(key), onMouseLeave: () => setHoverPlus((k) => (k === key ? null : k)), onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), children: "+" }), hot && _jsxs("span", { className: "sm-tooltip", children: ["Add to ", p.zone] })] })] }, key));
608
- })] })] }) })] }), _jsxs("aside", { className: "sm-inspector", 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) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
442
+ }, children: "LOADING CANVAS\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, 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 })] }) })] }), _jsxs("aside", { className: "sm-inspector", 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) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
609
443
  !isFixedNode(record, registry) &&
610
444
  !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
611
445
  ? () => { void doRemoveLocale(); }
612
- : undefined }), _jsx(Divider, { bleed: 14 })] })), 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), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && 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("a", { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] })), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
613
- const locked = policyOf(zone)?.kind === "locked";
614
- const nodes = nodesIn(zone);
615
- 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", zoneCanTake(zone) && "sm-outline-row--clickable"), onClick: () => {
616
- if (!zoneCanTake(zone))
617
- return;
618
- const first = allowedSections(policyOf(zone), registry.sections)[0];
619
- if (first)
620
- doInsert(zone, 0, first);
621
- }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), zoneCanTake(zone) && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600 }, children: "+ Add block" })] })), nodes.map((node, i) => {
622
- const section = sectionOf(node.type);
623
- const isSelected = node.id === selectedId;
624
- if (locked) {
625
- return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
626
- }
627
- return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
628
- })] }, zone));
629
- })] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
630
- position: "absolute",
631
- left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
632
- top: Math.min(picker.y, Math.max(12, window.innerHeight - 240)),
633
- }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Shared" }), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsertShared(picker.zone, picker.index, item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Shared \u00B7 edited in one place" })] })] }, `shared-${item.name}`)))] })), allowedSections(policyOf(picker.zone), registry.sections).length === 0 &&
634
- allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
446
+ : undefined }), _jsx(Divider, { bleed: 14 })] })), 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), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && 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(Divider, { bleed: 14 }), _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
447
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
448
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
449
+ })), onAddFirst: (zone) => {
450
+ const first = allowedSections(policyOf(zone), registry.sections)[0];
451
+ if (first)
452
+ doInsert(zone, 0, first);
453
+ } })] })] })] }), 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) }))] }));
635
454
  }
636
455
  /** The selected node's ref field (if any) resolves to its target
637
456
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -0,0 +1,18 @@
1
+ import type { ZonePolicy } from "../../zones.ts";
2
+ import type { AdminRegistry, AdminSection, AdminShared } from "../serialize.ts";
3
+ export type PickerAt = {
4
+ zone: string;
5
+ index: number;
6
+ x: number;
7
+ y: number;
8
+ };
9
+ export declare function InsertPicker({ at, policy, registry, sharedIds, onInsert, onInsertShared, onClose }: {
10
+ at: PickerAt;
11
+ policy: ZonePolicy | undefined;
12
+ registry: AdminRegistry;
13
+ /** Shared item name → row id; an item without one cannot be placed. */
14
+ sharedIds: Record<string, string>;
15
+ onInsert(section: AdminSection): void;
16
+ onInsertShared(item: AdminShared): void;
17
+ onClose(): void;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { allowedSections, allowedShared } from "../tree-ops.js";
4
+ import { SectionLabel } from "../ui/primitives.js";
5
+ /** Keep the panel on screen; a static render (no window) leaves it where it was asked. */
6
+ function clamp(at) {
7
+ if (typeof window === "undefined")
8
+ return { left: at.x, top: at.y };
9
+ return {
10
+ left: Math.max(12, Math.min(at.x, window.innerWidth - 320)),
11
+ top: Math.min(at.y, Math.max(12, window.innerHeight - 240)),
12
+ };
13
+ }
14
+ export function InsertPicker({ at, policy, registry, sharedIds, onInsert, onInsertShared, onClose }) {
15
+ const sections = allowedSections(policy, registry.sections);
16
+ const shared = allowedShared(policy, registry.shared, sharedIds);
17
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", ...clamp(at) }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), shared.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Shared" }), shared.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertShared(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Shared \u00B7 edited in one place" })] })] }, `shared-${item.name}`)))] })), sections.length === 0 && shared.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
18
+ }
@@ -0,0 +1,4 @@
1
+ import type { ReactNode } from "react";
2
+ export declare function NoticePane({ children }: {
3
+ children: ReactNode;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function NoticePane({ children }) {
3
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [children, _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
4
+ }
@@ -0,0 +1,18 @@
1
+ import type { TreeNode } from "../../render.tsx";
2
+ export declare function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }: {
3
+ zones: {
4
+ zone: string;
5
+ locked: boolean;
6
+ canTake: boolean;
7
+ nodes: {
8
+ node: TreeNode;
9
+ label: string;
10
+ }[];
11
+ }[];
12
+ selectedId: string | null;
13
+ onSelect(id: string): void;
14
+ /** The empty row of an insertable zone was clicked. */
15
+ onAddFirst(zone: string): void;
16
+ onMove(id: string, dir: -1 | 1): void;
17
+ onRemove(id: string): void;
18
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
+ export function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }) {
5
+ return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map(({ zone, locked, canTake, nodes }) => (_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)
6
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600 }, children: "+ Add block" })] })), nodes.map(({ node, label }, i) => {
7
+ if (locked) {
8
+ return (_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", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
9
+ }
10
+ return (_jsxs("div", { onClick: () => onSelect(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", node.id === selectedId && "sm-outline-row--selected"), children: [_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));
11
+ })] }, zone)))] }));
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { AdminRegistry } from "../serialize.ts";
2
+ type Locales = AdminRegistry["locales"];
3
+ /** A supported ?locale from the query string, else the default. */
4
+ export declare function localeFromSearch(search: string, locales: Locales): string;
5
+ /** The same URL pointed at a locale, keeping the rest of the query
6
+ * string — a bare `?locale=…` would drop every other param. */
7
+ export declare function withLocale(href: string, next: string): string;
8
+ export declare function localeFromUrl(registry: AdminRegistry): string;
9
+ /** Point the current URL at a locale in place. A replace, so the router
10
+ * never asks the leave guard — the form's own confirm covers a switch. */
11
+ export declare function writeLocaleToUrl(next: string): void;
12
+ export {};
@@ -0,0 +1,32 @@
1
+ import { navigate } from "../shell/router.js";
2
+ /** A supported ?locale from the query string, else the default. */
3
+ export function localeFromSearch(search, locales) {
4
+ const l = new URLSearchParams(search).get("locale");
5
+ return l && locales.supported.includes(l) ? l : locales.default;
6
+ }
7
+ /** The same URL pointed at a locale, keeping the rest of the query
8
+ * string — a bare `?locale=…` would drop every other param. */
9
+ export function withLocale(href, next) {
10
+ const u = new URL(href);
11
+ u.searchParams.set("locale", next);
12
+ return u.toString();
13
+ }
14
+ export function localeFromUrl(registry) {
15
+ try {
16
+ return localeFromSearch(window.location.search, registry.locales);
17
+ }
18
+ catch {
19
+ // no window — the default is fine
20
+ return registry.locales.default;
21
+ }
22
+ }
23
+ /** Point the current URL at a locale in place. A replace, so the router
24
+ * never asks the leave guard — the form's own confirm covers a switch. */
25
+ export function writeLocaleToUrl(next) {
26
+ try {
27
+ navigate(withLocale(window.location.href, next), { replace: true });
28
+ }
29
+ catch {
30
+ // no window (or no history) — the URL is cosmetic here
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ import type { TreeNode } from "../../render.tsx";
2
+ import type { BridgeRect, ZoneRect } from "./protocol.ts";
3
+ export type PlusButton = {
4
+ zone: string;
5
+ index: number;
6
+ y: number;
7
+ };
8
+ export declare function plusButtonsFor({ zoneOrder, canTake, nodesIn, rects, emptyZones }: {
9
+ zoneOrder: string[];
10
+ canTake(zone: string): boolean;
11
+ nodesIn(zone: string): TreeNode[];
12
+ rects: Map<string, BridgeRect>;
13
+ emptyZones: ZoneRect[];
14
+ }): PlusButton[];
@@ -0,0 +1,27 @@
1
+ export function plusButtonsFor({ zoneOrder, canTake, nodesIn, rects, emptyZones }) {
2
+ const out = [];
3
+ for (const zone of zoneOrder) {
4
+ if (!canTake(zone))
5
+ continue;
6
+ // Carry each member's real position in the tree's zone array through
7
+ // the rect filter — under refresh-on-change the rects lag the tree by
8
+ // up to a save cycle, so a filtered-array position isn't a valid
9
+ // insertNode index once a node is missing its rect.
10
+ const members = nodesIn(zone)
11
+ .map((n, treeIndex) => ({ rect: rects.get(n.id), treeIndex }))
12
+ .filter((m) => Boolean(m.rect));
13
+ if (members.length === 0) {
14
+ const zr = emptyZones.find((r) => r.zone === zone);
15
+ if (zr)
16
+ out.push({ zone, index: 0, y: zr.y + zr.h / 2 });
17
+ }
18
+ else {
19
+ members.forEach(({ rect, treeIndex }, i) => {
20
+ out.push({ zone, index: treeIndex, y: rect.y - 1 });
21
+ if (i === members.length - 1)
22
+ out.push({ zone, index: treeIndex + 1, y: rect.y + rect.h + 1 });
23
+ });
24
+ }
25
+ }
26
+ return out;
27
+ }