smoodly 0.0.4 → 0.0.5
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
|
@@ -1,33 +1,39 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
// The Smoodly editor:
|
|
3
|
+
// The Smoodly editor: a calm instrument around a bright canvas.
|
|
4
4
|
//
|
|
5
5
|
// The canvas is an iframe of the SITE's own route in draft mode (pixel-
|
|
6
6
|
// identical by construction, DESIGN.md §2), sized to its full document
|
|
7
|
-
// height so the
|
|
7
|
+
// height so the canvas COLUMN scrolls, not the iframe — which is what lets
|
|
8
8
|
// the geometry the bridge reports map 1:1 onto the overlay layer above
|
|
9
9
|
// it. Every gesture mutates client tree state through Task 4's pure ops;
|
|
10
10
|
// a debounced saveDraft persists, and the bridge refreshes the iframe on
|
|
11
11
|
// the save ack (refresh-on-change preview, DESIGN.md §3).
|
|
12
12
|
//
|
|
13
|
+
// Chrome follows the design system's visual-editor screen: top bar, toned
|
|
14
|
+
// canvas around a white page, 300px inspector holding fields then outline.
|
|
15
|
+
//
|
|
13
16
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
14
17
|
// that knows about Next, and it ships to the site, not the admin.
|
|
15
18
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
-
import { pagePath } from "../page-path.js";
|
|
17
19
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
18
20
|
import { tabsFor } from "../forms/tabs.js";
|
|
19
21
|
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
20
22
|
import { useDraftAutosave } from "./autosave.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
23
|
+
import { PageSettings } from "./PageSettings.js";
|
|
24
|
+
import { isFixedNode } from "../shell/pages-tree.js";
|
|
25
|
+
import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, Segmented, Tabs, TopBar, cx, } from "../ui/primitives.js";
|
|
26
|
+
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
25
27
|
const GEOMETRY_TIMEOUT_MS = 4000;
|
|
26
|
-
export function EditorView({ ops, registry,
|
|
28
|
+
export function EditorView({ ops, registry, id }) {
|
|
27
29
|
const [record, setRecord] = useState(null);
|
|
28
30
|
const [tree, setTree] = useState(null);
|
|
29
31
|
const [loadError, setLoadError] = useState(null);
|
|
30
|
-
const [
|
|
32
|
+
const [ready, setReady] = useState(false);
|
|
33
|
+
const [folder, setFolder] = useState(false);
|
|
34
|
+
const [locale, setLocale] = useState(registry.locales.default);
|
|
35
|
+
const [paths, setPaths] = useState({});
|
|
36
|
+
const [urls, setUrls] = useState({});
|
|
31
37
|
const [rects, setRects] = useState([]);
|
|
32
38
|
const [emptyZones, setEmptyZones] = useState([]);
|
|
33
39
|
const [docH, setDocH] = useState(900);
|
|
@@ -39,7 +45,9 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
39
45
|
const [banner, setBanner] = useState(null);
|
|
40
46
|
const [published, setPublished] = useState(false);
|
|
41
47
|
const [refOptions, setRefOptions] = useState({});
|
|
48
|
+
const [hoverPlus, setHoverPlus] = useState(null);
|
|
42
49
|
const iframeRef = useRef(null);
|
|
50
|
+
const scrollerRef = useRef(null);
|
|
43
51
|
const treeRef = useRef(null);
|
|
44
52
|
const registryRef = useRef(registry);
|
|
45
53
|
registryRef.current = registry;
|
|
@@ -47,13 +55,16 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
47
55
|
// retry on failure, and the bridge refreshes the iframe on the save ack.
|
|
48
56
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
49
57
|
read: () => treeRef.current,
|
|
50
|
-
persist: (t) => ops.pages.saveDraft(
|
|
58
|
+
persist: (t) => ops.pages.saveDraft(id, t),
|
|
51
59
|
onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
|
|
52
60
|
});
|
|
53
|
-
// The URL that serves this page
|
|
54
|
-
//
|
|
55
|
-
|
|
61
|
+
// The URL that serves this page, per locale, from the store's paths
|
|
62
|
+
// index — the label is the CMS path, the canvas and "View live" take
|
|
63
|
+
// the site URL (href applied server-side by pages.get).
|
|
64
|
+
const path = paths[locale] ?? paths[registry.locales.default] ?? "/";
|
|
56
65
|
const label = path;
|
|
66
|
+
const siteUrl = urls[locale] ?? urls[registry.locales.default] ?? path;
|
|
67
|
+
const iframeSrc = ready ? withEditorParam(siteUrl) : null;
|
|
57
68
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
58
69
|
// at the site route (an iframe loaded before the cookie exists would
|
|
59
70
|
// render the published tree).
|
|
@@ -81,7 +92,7 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
81
92
|
}
|
|
82
93
|
let result;
|
|
83
94
|
try {
|
|
84
|
-
result = await ops.pages.get(
|
|
95
|
+
result = await ops.pages.get(id);
|
|
85
96
|
}
|
|
86
97
|
catch {
|
|
87
98
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -92,6 +103,11 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
92
103
|
setLoadError(result.message);
|
|
93
104
|
return;
|
|
94
105
|
}
|
|
106
|
+
// A folder has no draft tree — it gets the notice pane, not a canvas.
|
|
107
|
+
if (result.data.draft === null) {
|
|
108
|
+
setFolder(true);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
95
111
|
// Locked zones can't be filled by any gesture, so fill the empty
|
|
96
112
|
// ones here; a change marks the draft dirty so the debounced save
|
|
97
113
|
// persists it and the canvas shows the same tree as the outline.
|
|
@@ -103,15 +119,44 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
103
119
|
const template = reg.pages.find((p) => p.name === result.data.record.template);
|
|
104
120
|
const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
|
|
105
121
|
setRecord(result.data.record);
|
|
122
|
+
setPaths(result.data.paths);
|
|
123
|
+
setUrls(result.data.urls);
|
|
106
124
|
setTree(draft);
|
|
107
125
|
treeRef.current = draft;
|
|
108
126
|
if (draft !== result.data.draft)
|
|
109
127
|
mark();
|
|
110
|
-
|
|
128
|
+
setReady(true);
|
|
111
129
|
})();
|
|
112
130
|
return () => { cancelled = true; };
|
|
113
|
-
}, [ops,
|
|
131
|
+
}, [ops, id]);
|
|
132
|
+
// A new canvas URL (a locale switch, a rename) means new geometry — the
|
|
133
|
+
// previous page's rects are stale the instant the iframe starts loading
|
|
134
|
+
// a different route, so clear them alongside resetting geometry.
|
|
135
|
+
useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
|
|
114
136
|
useEffect(() => { treeRef.current = tree; }, [tree]);
|
|
137
|
+
// After a rename only the record and the URL indexes are stale — never
|
|
138
|
+
// re-read the tree here: the draft in memory may be ahead of the store.
|
|
139
|
+
const reload = useCallback(async () => {
|
|
140
|
+
const result = await ops.pages.get(id).catch(() => null);
|
|
141
|
+
if (!result || !result.ok)
|
|
142
|
+
return;
|
|
143
|
+
setRecord(result.data.record);
|
|
144
|
+
setPaths(result.data.paths);
|
|
145
|
+
setUrls(result.data.urls);
|
|
146
|
+
}, [ops, id]);
|
|
147
|
+
const onRename = async (renameLocale, patch) => {
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await ops.pages.rename(id, { locale: renameLocale, ...patch });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
154
|
+
}
|
|
155
|
+
if (!result.ok)
|
|
156
|
+
return result.message;
|
|
157
|
+
await reload();
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
115
160
|
const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
|
|
116
161
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
117
162
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
@@ -186,11 +231,15 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
186
231
|
const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
|
|
187
232
|
return () => clearTimeout(t);
|
|
188
233
|
}, [iframeSrc, geometry]);
|
|
234
|
+
// Selecting from the outline must reveal the block. The canvas column is
|
|
235
|
+
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
236
|
+
// the full document), so scroll it to the rect the bridge reported.
|
|
237
|
+
const revealRef = useRef(() => { });
|
|
189
238
|
const select = useCallback((id) => {
|
|
190
239
|
setSelectedId(id);
|
|
191
240
|
if (id)
|
|
192
|
-
|
|
193
|
-
}, [
|
|
241
|
+
revealRef.current(id);
|
|
242
|
+
}, []);
|
|
194
243
|
// ── persistence ──────────────────────────────────────────────────────
|
|
195
244
|
const mutate = useCallback((fn) => {
|
|
196
245
|
setTree((t) => {
|
|
@@ -253,7 +302,7 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
253
302
|
}
|
|
254
303
|
let result;
|
|
255
304
|
try {
|
|
256
|
-
result = await ops.pages.publish(
|
|
305
|
+
result = await ops.pages.publish(id);
|
|
257
306
|
}
|
|
258
307
|
catch (e) {
|
|
259
308
|
console.error(e);
|
|
@@ -282,7 +331,10 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
282
331
|
return () => window.removeEventListener("keydown", onKey);
|
|
283
332
|
}, []);
|
|
284
333
|
if (loadError) {
|
|
285
|
-
return (_jsx("div", { style: {
|
|
334
|
+
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" })] }) }));
|
|
335
|
+
}
|
|
336
|
+
if (folder) {
|
|
337
|
+
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" })] }) }));
|
|
286
338
|
}
|
|
287
339
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
288
340
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
@@ -292,6 +344,16 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
292
344
|
const selRect = selected ? rectById.get(selected.id) : undefined;
|
|
293
345
|
const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
|
|
294
346
|
const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
|
|
347
|
+
// Reveal is defined here, where the rect map exists; select() calls it
|
|
348
|
+
// through the ref so its own identity stays stable.
|
|
349
|
+
revealRef.current = (id) => {
|
|
350
|
+
const rect = rectById.get(id);
|
|
351
|
+
const scroller = scrollerRef.current;
|
|
352
|
+
if (!rect || !scroller)
|
|
353
|
+
return;
|
|
354
|
+
const top = rect.y - Math.max(0, (scroller.clientHeight - rect.h) / 2);
|
|
355
|
+
scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
|
356
|
+
};
|
|
295
357
|
// "+" affordances: one per legal boundary in every insertable zone.
|
|
296
358
|
const plusButtons = [];
|
|
297
359
|
for (const zone of zoneOrder) {
|
|
@@ -320,50 +382,57 @@ export function EditorView({ ops, registry, slug }) {
|
|
|
320
382
|
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
321
383
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
322
384
|
const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
385
|
+
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" })), _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(Segmented, { options: registry.locales.supported, value: locale, onChange: setLocale })) : (_jsxs("span", { children: ["\u00B7 ", locale] })), registry.locales.supported.length > 1 && paths[locale] === undefined && ready && (_jsxs("span", { children: ["not in this locale \u2014 showing ", registry.locales.default] }))] }), _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: {
|
|
386
|
+
height: 240, display: "grid", placeItems: "center",
|
|
387
|
+
color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
|
|
388
|
+
}, 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: {
|
|
389
|
+
position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
|
|
390
|
+
height: Math.max(56, z.h), fontSize: "var(--text-xs)",
|
|
391
|
+
letterSpacing: ".04em", textTransform: "uppercase", color: "var(--gray-500)",
|
|
392
|
+
borderColor: "var(--gray-300)",
|
|
393
|
+
}, children: [z.zone, " \u00B7 empty"] }, `empty-${z.zone}`))), hoverRect && (_jsx("div", { style: {
|
|
394
|
+
position: "absolute", left: hoverRect.x, top: hoverRect.y,
|
|
395
|
+
width: hoverRect.w, height: hoverRect.h,
|
|
396
|
+
outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
|
|
397
|
+
outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
|
|
398
|
+
} })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
399
|
+
position: "absolute", left: selRect.x, top: selRect.y,
|
|
400
|
+
width: selRect.w, height: selRect.h,
|
|
401
|
+
outline: "var(--outline-selection)", outlineOffset: -1.5,
|
|
402
|
+
borderRadius: "var(--radius-sm)",
|
|
403
|
+
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection.title, selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
|
|
404
|
+
position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
|
|
405
|
+
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
406
|
+
}, 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) => {
|
|
407
|
+
const key = `${p.zone}-${p.index}`;
|
|
408
|
+
const hot = hoverPlus === key;
|
|
409
|
+
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: {
|
|
410
|
+
position: "absolute", left: "50%", top: 0, transform: "translate(-50%,-50%)",
|
|
411
|
+
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
|
|
412
|
+
pointerEvents: "auto",
|
|
413
|
+
}, 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));
|
|
414
|
+
})] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_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 })] }), _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, registry: registry, paths: paths, fixed: isFixedNode(record, registry), onRename: onRename }), _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}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
415
|
+
const locked = policyOf(zone)?.kind === "locked";
|
|
416
|
+
const nodes = nodesIn(zone);
|
|
417
|
+
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: () => {
|
|
418
|
+
if (!zoneCanTake(zone))
|
|
419
|
+
return;
|
|
420
|
+
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
421
|
+
if (first)
|
|
422
|
+
doInsert(zone, 0, first);
|
|
423
|
+
}, 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) => {
|
|
424
|
+
const section = sectionOf(node.type);
|
|
425
|
+
const isSelected = node.id === selectedId;
|
|
426
|
+
if (locked) {
|
|
427
|
+
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 ?? node.type }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
|
|
428
|
+
}
|
|
429
|
+
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 ?? 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));
|
|
430
|
+
})] }, zone));
|
|
431
|
+
})] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
|
|
432
|
+
position: "absolute",
|
|
433
|
+
left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
|
|
434
|
+
top: Math.min(picker.y, Math.max(12, window.innerHeight - 240)),
|
|
435
|
+
}, 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))), allowedSections(policyOf(picker.zone), registry.sections).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
|
|
367
436
|
}
|
|
368
437
|
/** The selected node's ref field (if any) resolves to its target
|
|
369
438
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -373,19 +442,3 @@ function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
|
|
|
373
442
|
const target = targets[`${section.name}.${ns}.${key}`];
|
|
374
443
|
return target ? loaded[target] ?? [] : [];
|
|
375
444
|
}
|
|
376
|
-
const shell = {
|
|
377
|
-
fontFamily: ui,
|
|
378
|
-
background: "radial-gradient(1200px 500px at 70% -10%, #221c33 0%, #0e0c14 55%)",
|
|
379
|
-
minHeight: "100vh",
|
|
380
|
-
color: "#e8e4f4",
|
|
381
|
-
};
|
|
382
|
-
function btn(extra) {
|
|
383
|
-
return { fontFamily: "inherit", fontSize: 13, border: 0, borderRadius: 9, padding: "8px 14px", cursor: "pointer", ...extra };
|
|
384
|
-
}
|
|
385
|
-
function IconBtn({ label, title, onClick, disabled }) {
|
|
386
|
-
return (_jsx("button", { onClick: onClick, title: title, disabled: disabled, onMouseEnter: (e) => { if (!disabled)
|
|
387
|
-
e.currentTarget.style.background = "rgba(255,255,255,0.12)"; }, onMouseLeave: (e) => (e.currentTarget.style.background = "transparent"), style: {
|
|
388
|
-
width: 28, height: 26, background: "transparent", border: 0, color: "#e8e4f4",
|
|
389
|
-
borderRadius: 6, cursor: disabled ? "default" : "pointer", fontSize: 13, opacity: disabled ? 0.3 : 1,
|
|
390
|
-
}, children: label }));
|
|
391
|
-
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PageRecord } from "../../store.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
export declare function PageSettings({ record, registry, paths, fixed, onRename }: {
|
|
4
|
+
record: PageRecord;
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
paths: Record<string, string>;
|
|
7
|
+
fixed: boolean;
|
|
8
|
+
/** Resolves to an error message, or null when the rename landed. */
|
|
9
|
+
onRename(locale: string, patch: {
|
|
10
|
+
title?: string;
|
|
11
|
+
slug?: string;
|
|
12
|
+
}): Promise<string | null>;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Page settings (spec 2026-09-05 §7): title and slug per locale, saved
|
|
4
|
+
// through pages.rename. SEO/meta fields are OQ #17's own design pass.
|
|
5
|
+
// Rendered in the inspector while no block is selected.
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { Field, SectionLabel } from "../ui/primitives.js";
|
|
8
|
+
export function PageSettings({ record, registry, paths, fixed, onRename }) {
|
|
9
|
+
const { default: def, supported } = registry.locales;
|
|
10
|
+
const stored = (locale, key) => locale === def ? record[key] : (record.i18n?.[locale]?.[key] ?? "");
|
|
11
|
+
const [draft, setDraft] = useState({});
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const isHome = record.parentId === null && record.slug === registry.homePageSlug;
|
|
14
|
+
// The record changes identity on every rename; re-seed the inputs from it.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setDraft(Object.fromEntries(supported.map((l) => [l, { title: stored(l, "title"), slug: stored(l, "slug") }])));
|
|
17
|
+
}, [record]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
18
|
+
const commit = async (locale, key) => {
|
|
19
|
+
setError(null);
|
|
20
|
+
const next = draft[locale]?.[key] ?? "";
|
|
21
|
+
if (next === stored(locale, key))
|
|
22
|
+
return;
|
|
23
|
+
if (next === "") {
|
|
24
|
+
if (locale === def) {
|
|
25
|
+
setError(`The default locale needs a ${key}.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
return; // a blank translation falls back to the default locale
|
|
29
|
+
}
|
|
30
|
+
setError(await onRename(locale, { [key]: next }));
|
|
31
|
+
};
|
|
32
|
+
const set = (locale, key, value) => setDraft((d) => ({ ...d, [locale]: { ...(d[locale] ?? { title: "", slug: "" }), [key]: value } }));
|
|
33
|
+
const onKey = (locale, key) => (e) => {
|
|
34
|
+
if (e.key === "Enter") {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
void commit(locale, key);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(SectionLabel, { children: "Page" }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), supported.map((locale) => (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [supported.length > 1 && _jsx("span", { className: "sm-label", children: locale }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft[locale]?.title ?? "", placeholder: locale === def ? "" : stored(def, "title"), onChange: (e) => set(locale, "title", e.target.value), onBlur: () => void commit(locale, "title"), onKeyDown: onKey(locale, "title") }) }), _jsx(Field, { label: "Slug", hint: paths[locale] ?? "not published in this locale", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft[locale]?.slug ?? "", placeholder: locale === def ? "" : stored(def, "slug"), onChange: (e) => set(locale, "slug", e.target.value), onBlur: () => void commit(locale, "slug"), onKeyDown: onKey(locale, "slug") }) })] }, locale)))] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { PageI18n, PageRecord, PageStore } from "../store.ts";
|
|
3
|
+
export type FixedNode = {
|
|
4
|
+
/** The default-locale segment (the record's `slug`). */
|
|
5
|
+
slug: string;
|
|
6
|
+
/** Other locales' segments, only where they differ from the default. */
|
|
7
|
+
i18n: PageI18n | null;
|
|
8
|
+
title: string;
|
|
9
|
+
/** A per-locale collection path names its locales; everything else is present everywhere. */
|
|
10
|
+
locales: string[] | null;
|
|
11
|
+
/** The fixed page registration's name; null for a plain mount (a folder). */
|
|
12
|
+
template: string | null;
|
|
13
|
+
/** The collection whose `path` this node is; null for a plain fixed page. */
|
|
14
|
+
collection: string | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
|
|
17
|
+
/** The spec a ROOT record materializes, or null for an editor-owned node. */
|
|
18
|
+
export declare function fixedNodeOf(record: {
|
|
19
|
+
parentId: string | null;
|
|
20
|
+
slug: string;
|
|
21
|
+
}, config: ResolvedConfig): FixedNode | null;
|
|
22
|
+
/** Create every fixed node that has no root record yet, then list. */
|
|
23
|
+
export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { perLocaleSegments } from "../paths.js";
|
|
2
|
+
export function fixedNodeSpecs(config) {
|
|
3
|
+
const { default: def, supported } = config.locales;
|
|
4
|
+
const perLocale = (v) => perLocaleSegments(v, supported);
|
|
5
|
+
const i18nOf = (segments) => {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const [locale, segment] of Object.entries(segments)) {
|
|
8
|
+
if (locale !== def && segment !== segments[def])
|
|
9
|
+
out[locale] = { slug: segment };
|
|
10
|
+
}
|
|
11
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
12
|
+
};
|
|
13
|
+
const specs = new Map();
|
|
14
|
+
for (const p of config.registry.pages ?? []) {
|
|
15
|
+
if (p.schema.kind !== "page" || p.schema.slug === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
const segments = perLocale(p.schema.slug);
|
|
18
|
+
specs.set(segments[def], {
|
|
19
|
+
slug: segments[def],
|
|
20
|
+
i18n: i18nOf(segments),
|
|
21
|
+
title: p.schema.title ?? p.schema.name,
|
|
22
|
+
locales: null,
|
|
23
|
+
template: p.schema.name,
|
|
24
|
+
collection: null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
for (const c of config.registry.collections ?? []) {
|
|
28
|
+
if (c.path === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
const segments = perLocale(c.path);
|
|
31
|
+
const indexPage = specs.get(segments[def]);
|
|
32
|
+
if (indexPage) {
|
|
33
|
+
// defineConfig proved the page sits at the collection's segment in
|
|
34
|
+
// every locale: that page IS the mount — the collection's index page.
|
|
35
|
+
indexPage.collection = c.name;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
specs.set(segments[def], {
|
|
39
|
+
slug: segments[def],
|
|
40
|
+
i18n: i18nOf(segments),
|
|
41
|
+
title: c.title,
|
|
42
|
+
locales: typeof c.path === "string" ? null : Object.keys(segments),
|
|
43
|
+
template: null,
|
|
44
|
+
collection: c.name,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return [...specs.values()];
|
|
48
|
+
}
|
|
49
|
+
/** The spec a ROOT record materializes, or null for an editor-owned node. */
|
|
50
|
+
export function fixedNodeOf(record, config) {
|
|
51
|
+
if (record.parentId !== null)
|
|
52
|
+
return null;
|
|
53
|
+
return fixedNodeSpecs(config).find((s) => s.slug === record.slug) ?? null;
|
|
54
|
+
}
|
|
55
|
+
/** Create every fixed node that has no root record yet, then list. */
|
|
56
|
+
export async function ensureFixedNodes(pages, config) {
|
|
57
|
+
const all = await pages.listPages();
|
|
58
|
+
const taken = new Set(all.filter((p) => p.parentId === null).map((p) => p.slug));
|
|
59
|
+
let attempted = false;
|
|
60
|
+
for (const spec of fixedNodeSpecs(config)) {
|
|
61
|
+
if (taken.has(spec.slug))
|
|
62
|
+
continue;
|
|
63
|
+
attempted = true;
|
|
64
|
+
try {
|
|
65
|
+
await pages.createPage({
|
|
66
|
+
slug: spec.slug, template: spec.template, title: spec.title, i18n: spec.i18n, locales: spec.locales,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
// One spec that cannot land — a path an editor already claimed in some
|
|
71
|
+
// locale — must not take the whole list down: warn, skip it, keep
|
|
72
|
+
// materializing the rest. Two admins loading at once can also race on
|
|
73
|
+
// this same create; the loser's slugTaken is the other one winning,
|
|
74
|
+
// not a failure, so it skips silently — no warn.
|
|
75
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
76
|
+
if (!message.includes("already exists")) {
|
|
77
|
+
console.warn(`smoodly: could not materialize "${spec.slug}": ${message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// A create was tried, so the pre-race list is stale either way.
|
|
82
|
+
return attempted ? pages.listPages() : all;
|
|
83
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
2
|
import type { AdminField } from "../serialize.ts";
|
|
3
|
-
export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, }: {
|
|
3
|
+
export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, prefix, }: {
|
|
4
4
|
descriptor: AdminField;
|
|
5
5
|
value: unknown;
|
|
6
6
|
onChange(value: unknown): void;
|
|
@@ -11,4 +11,5 @@ export declare function FieldWidget({ descriptor: d, value, onChange, error, ref
|
|
|
11
11
|
status: string;
|
|
12
12
|
}[];
|
|
13
13
|
fieldKey: string;
|
|
14
|
+
prefix?: string;
|
|
14
15
|
}): ReactElement;
|