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.
- package/README.md +228 -18
- package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
- package/dist/admin/editor/CanvasOverlay.js +39 -0
- package/dist/admin/editor/EditorView.js +56 -237
- package/dist/admin/editor/InsertPicker.d.ts +18 -0
- package/dist/admin/editor/InsertPicker.js +18 -0
- package/dist/admin/editor/NoticePane.d.ts +4 -0
- package/dist/admin/editor/NoticePane.js +4 -0
- package/dist/admin/editor/Outline.d.ts +18 -0
- package/dist/admin/editor/Outline.js +12 -0
- package/dist/admin/editor/locale-url.d.ts +12 -0
- package/dist/admin/editor/locale-url.js +32 -0
- package/dist/admin/editor/overlay-geometry.d.ts +14 -0
- package/dist/admin/editor/overlay-geometry.js +27 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
- package/dist/admin/editor/useCanvasBridge.js +53 -0
- package/dist/admin/editor/usePageLocale.d.ts +23 -0
- package/dist/admin/editor/usePageLocale.js +88 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +68 -8
- package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
- package/dist/admin/forms/RichtextToolbar.js +37 -0
- package/dist/admin/forms/RichtextWidget.d.ts +5 -0
- package/dist/admin/forms/RichtextWidget.js +123 -0
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/forms/richtext-extensions.d.ts +5 -0
- package/dist/admin/forms/richtext-extensions.js +74 -0
- package/dist/admin/forms/richtext-paste.d.ts +22 -0
- package/dist/admin/forms/richtext-paste.js +59 -0
- package/dist/admin/index.d.ts +0 -1
- package/dist/admin/index.js +0 -1
- package/dist/admin/next/create-admin.d.ts +3 -7
- package/dist/admin/next/create-admin.js +14 -5
- package/dist/admin/richtext-doc.d.ts +7 -0
- package/dist/admin/richtext-doc.js +18 -0
- package/dist/admin/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -0
- package/dist/admin/shell/AdminApp.d.ts +7 -2
- package/dist/admin/shell/AdminApp.js +12 -6
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +9 -25
- package/dist/admin/shell/ListView.js +3 -1
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/RouteLink.d.ts +4 -0
- package/dist/admin/shell/RouteLink.js +12 -0
- package/dist/admin/shell/SharedForm.js +4 -22
- package/dist/admin/shell/router.d.ts +37 -0
- package/dist/admin/shell/router.js +84 -0
- package/dist/admin/shell/session.js +3 -1
- package/dist/admin/ui/primitives.d.ts +2 -1
- package/dist/admin/ui/primitives.js +5 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +40 -1
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +53 -13
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +18 -2
- package/dist/fields.js +51 -5
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/refs.js +5 -0
- package/dist/resolve.js +3 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +5 -1
- package/dist/sql.js +1 -1
- package/dist/supabase-entry-store.d.ts +8 -0
- package/dist/supabase-entry-store.js +37 -2
- package/dist/toolset.d.ts +30 -0
- package/dist/toolset.js +49 -0
- package/package.json +11 -1
- package/dist/admin/richtext.d.ts +0 -14
- 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,
|
|
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,
|
|
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
|
|
96
|
-
|
|
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
|
|
396
|
+
return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
|
|
524
397
|
}
|
|
525
398
|
if (folder) {
|
|
526
|
-
return (_jsx(
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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 { 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
|
+
}
|