smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -8,7 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
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
|
-
// the save ack (refresh-on-change preview, DESIGN.md §3).
|
|
11
|
+
// the save ack (refresh-on-change preview, DESIGN.md §3). Every load,
|
|
12
|
+
// edit, autosave and publish targets ONE locale — the page's own tree in
|
|
13
|
+
// that locale (spec 2026-09-06 §6); switching flushes the pending save
|
|
14
|
+
// first.
|
|
12
15
|
//
|
|
13
16
|
// Chrome follows the design system's visual-editor screen: top bar, toned
|
|
14
17
|
// canvas around a white page, 300px inspector holding fields then outline.
|
|
@@ -16,24 +19,59 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
19
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
17
20
|
// that knows about Next, and it ships to the site, not the admin.
|
|
18
21
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { SHARED_NODE_TYPE } from "../../shared.js";
|
|
19
23
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
20
24
|
import { tabsFor } from "../forms/tabs.js";
|
|
21
|
-
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
25
|
+
import { allowedSections, allowedShared, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
22
26
|
import { useDraftAutosave } from "./autosave.js";
|
|
23
27
|
import { PageSettings } from "./PageSettings.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
28
|
+
import { BASE } from "../shell/base.js";
|
|
29
|
+
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
30
|
+
import { entryTitle } from "../shell/entries-list.js";
|
|
31
|
+
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";
|
|
26
33
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
27
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
|
+
}
|
|
28
60
|
export function EditorView({ ops, registry, id }) {
|
|
29
61
|
const [record, setRecord] = useState(null);
|
|
30
62
|
const [tree, setTree] = useState(null);
|
|
31
63
|
const [loadError, setLoadError] = useState(null);
|
|
32
64
|
const [ready, setReady] = useState(false);
|
|
33
65
|
const [folder, setFolder] = useState(false);
|
|
34
|
-
|
|
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
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
70
|
+
const [adding, setAdding] = useState(false);
|
|
35
71
|
const [paths, setPaths] = useState({});
|
|
36
72
|
const [urls, setUrls] = useState({});
|
|
73
|
+
// The locales the parent exists in — "Add locale" offers only these.
|
|
74
|
+
const [parentLocales, setParentLocales] = useState([]);
|
|
37
75
|
const [rects, setRects] = useState([]);
|
|
38
76
|
const [emptyZones, setEmptyZones] = useState([]);
|
|
39
77
|
const [docH, setDocH] = useState(900);
|
|
@@ -45,25 +83,30 @@ export function EditorView({ ops, registry, id }) {
|
|
|
45
83
|
const [banner, setBanner] = useState(null);
|
|
46
84
|
const [published, setPublished] = useState(false);
|
|
47
85
|
const [refOptions, setRefOptions] = useState({});
|
|
86
|
+
// Shared item name → row id, loaded once per editor mount: the picker's
|
|
87
|
+
// Shared group and a bound locked zone need the id to place a node.
|
|
88
|
+
const [sharedIds, setSharedIds] = useState(null);
|
|
48
89
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
49
90
|
const iframeRef = useRef(null);
|
|
50
91
|
const scrollerRef = useRef(null);
|
|
51
92
|
const treeRef = useRef(null);
|
|
52
93
|
const registryRef = useRef(registry);
|
|
53
94
|
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);
|
|
54
97
|
// Every edit marks the draft dirty; a debounced saveDraft persists, with
|
|
55
98
|
// retry on failure, and the bridge refreshes the iframe on the save ack.
|
|
56
99
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
57
100
|
read: () => treeRef.current,
|
|
58
|
-
persist: (t) => ops.pages.saveDraft(id, t),
|
|
101
|
+
persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
|
|
59
102
|
onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
|
|
60
103
|
});
|
|
61
104
|
// The URL that serves this page, per locale, from the store's paths
|
|
62
105
|
// index — the label is the CMS path, the canvas and "View live" take
|
|
63
106
|
// the site URL (href applied server-side by pages.get).
|
|
64
|
-
const path = paths[locale] ??
|
|
107
|
+
const path = paths[locale] ?? "/";
|
|
65
108
|
const label = path;
|
|
66
|
-
const siteUrl = urls[locale] ??
|
|
109
|
+
const siteUrl = urls[locale] ?? path;
|
|
67
110
|
const iframeSrc = ready ? withEditorParam(siteUrl) : null;
|
|
68
111
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
69
112
|
// at the site route (an iframe loaded before the cookie exists would
|
|
@@ -71,6 +114,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
71
114
|
useEffect(() => {
|
|
72
115
|
let cancelled = false;
|
|
73
116
|
(async () => {
|
|
117
|
+
// A new locale (or a reload) is a new canvas: nothing from the previous one may survive.
|
|
118
|
+
setReady(false);
|
|
119
|
+
setTree(null);
|
|
120
|
+
treeRef.current = null;
|
|
121
|
+
setSelectedId(null);
|
|
122
|
+
setHoverId(null);
|
|
123
|
+
setFolder(false);
|
|
74
124
|
// ops is a server action — it can reject outright (offline, 500,
|
|
75
125
|
// aborted), not just resolve to a typed error. Either failure mode
|
|
76
126
|
// must land on the same full-pane error, never a hung load.
|
|
@@ -92,7 +142,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
92
142
|
}
|
|
93
143
|
let result;
|
|
94
144
|
try {
|
|
95
|
-
result = await ops.pages.get(id);
|
|
145
|
+
result = await ops.pages.get(id, locale);
|
|
96
146
|
}
|
|
97
147
|
catch {
|
|
98
148
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -103,6 +153,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
103
153
|
setLoadError(result.message);
|
|
104
154
|
return;
|
|
105
155
|
}
|
|
156
|
+
setRecord(result.data.record);
|
|
157
|
+
setPaths(result.data.paths);
|
|
158
|
+
setUrls(result.data.urls);
|
|
159
|
+
setParentLocales(result.data.parentLocales);
|
|
160
|
+
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
161
|
+
if (!result.data.record.locales[locale])
|
|
162
|
+
return;
|
|
106
163
|
// A folder has no draft tree — it gets the notice pane, not a canvas.
|
|
107
164
|
if (result.data.draft === null) {
|
|
108
165
|
setFolder(true);
|
|
@@ -118,9 +175,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
118
175
|
const reg = registryRef.current;
|
|
119
176
|
const template = reg.pages.find((p) => p.name === result.data.record.template);
|
|
120
177
|
const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
|
|
121
|
-
|
|
122
|
-
setPaths(result.data.paths);
|
|
123
|
-
setUrls(result.data.urls);
|
|
178
|
+
treeLocaleRef.current = locale;
|
|
124
179
|
setTree(draft);
|
|
125
180
|
treeRef.current = draft;
|
|
126
181
|
if (draft !== result.data.draft)
|
|
@@ -128,7 +183,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
128
183
|
setReady(true);
|
|
129
184
|
})();
|
|
130
185
|
return () => { cancelled = true; };
|
|
131
|
-
}, [ops, id]);
|
|
186
|
+
}, [ops, id, locale, reloadKey]);
|
|
132
187
|
// A new canvas URL (a locale switch, a rename) means new geometry — the
|
|
133
188
|
// previous page's rects are stale the instant the iframe starts loading
|
|
134
189
|
// a different route, so clear them alongside resetting geometry.
|
|
@@ -137,17 +192,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
137
192
|
// After a rename only the record and the URL indexes are stale — never
|
|
138
193
|
// re-read the tree here: the draft in memory may be ahead of the store.
|
|
139
194
|
const reload = useCallback(async () => {
|
|
140
|
-
const result = await ops.pages.get(id).catch(() => null);
|
|
195
|
+
const result = await ops.pages.get(id, locale).catch(() => null);
|
|
141
196
|
if (!result || !result.ok)
|
|
142
197
|
return;
|
|
143
198
|
setRecord(result.data.record);
|
|
144
199
|
setPaths(result.data.paths);
|
|
145
200
|
setUrls(result.data.urls);
|
|
146
|
-
|
|
147
|
-
|
|
201
|
+
setParentLocales(result.data.parentLocales);
|
|
202
|
+
}, [ops, id, locale]);
|
|
203
|
+
const onRename = async (patch) => {
|
|
148
204
|
let result;
|
|
149
205
|
try {
|
|
150
|
-
result = await ops.pages.rename(id, { locale
|
|
206
|
+
result = await ops.pages.rename(id, { locale, ...patch });
|
|
151
207
|
}
|
|
152
208
|
catch {
|
|
153
209
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -157,10 +213,94 @@ export function EditorView({ ops, registry, id }) {
|
|
|
157
213
|
await reload();
|
|
158
214
|
return null;
|
|
159
215
|
};
|
|
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
|
+
};
|
|
160
294
|
const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
|
|
161
295
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
162
296
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
163
297
|
const sectionOf = useCallback((type) => registry.sections.find((s) => s.name === type) ?? null, [registry]);
|
|
298
|
+
/** The shared item a placement node places, or null for any other node. */
|
|
299
|
+
const sharedOf = useCallback((node) => {
|
|
300
|
+
if (node.type !== SHARED_NODE_TYPE)
|
|
301
|
+
return null;
|
|
302
|
+
return registry.shared.find((s) => s.name === node.fields?.item) ?? null;
|
|
303
|
+
}, [registry]);
|
|
164
304
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
165
305
|
const refTargets = useMemo(() => {
|
|
166
306
|
const out = {};
|
|
@@ -176,15 +316,20 @@ export function EditorView({ ops, registry, id }) {
|
|
|
176
316
|
return out;
|
|
177
317
|
}, [registry]);
|
|
178
318
|
useEffect(() => {
|
|
319
|
+
let cancelled = false;
|
|
179
320
|
for (const target of new Set(Object.values(refTargets))) {
|
|
180
321
|
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
181
322
|
ops.entries.list(target).then((r) => {
|
|
182
|
-
|
|
323
|
+
// A list issued for the previous locale must not overwrite this one's.
|
|
324
|
+
if (cancelled || !r.ok)
|
|
183
325
|
return;
|
|
184
|
-
const options = r.data
|
|
326
|
+
const options = r.data
|
|
327
|
+
// only entries present in the locale being edited can be referenced there
|
|
328
|
+
.filter((e) => e.locales[locale])
|
|
329
|
+
.map((e) => ({
|
|
185
330
|
id: e.id,
|
|
186
|
-
title:
|
|
187
|
-
status: e.status,
|
|
331
|
+
title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
|
|
332
|
+
status: e.locales[locale].status,
|
|
188
333
|
}));
|
|
189
334
|
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
190
335
|
}).catch((e) => {
|
|
@@ -193,7 +338,29 @@ export function EditorView({ ops, registry, id }) {
|
|
|
193
338
|
console.error(e);
|
|
194
339
|
});
|
|
195
340
|
}
|
|
196
|
-
|
|
341
|
+
return () => { cancelled = true; };
|
|
342
|
+
}, [ops, refTargets, registry, locale]);
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (registryRef.current.shared.length === 0) {
|
|
345
|
+
setSharedIds({});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let cancelled = false;
|
|
349
|
+
// A failed load leaves the map empty: the picker's Shared group and
|
|
350
|
+
// any zone bound to an item degrade to nothing to place, not a stuck
|
|
351
|
+
// editor — so say why in the console, like the ref options above.
|
|
352
|
+
ops.shared.list()
|
|
353
|
+
.then((r) => {
|
|
354
|
+
if (cancelled)
|
|
355
|
+
return;
|
|
356
|
+
if (!r.ok)
|
|
357
|
+
console.error(r.message);
|
|
358
|
+
setSharedIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
|
|
359
|
+
})
|
|
360
|
+
.catch((e) => { console.error(e); if (!cancelled)
|
|
361
|
+
setSharedIds({}); });
|
|
362
|
+
return () => { cancelled = true; };
|
|
363
|
+
}, [ops]);
|
|
197
364
|
// ── canvas messaging ─────────────────────────────────────────────────
|
|
198
365
|
const postToCanvas = useCallback((m) => {
|
|
199
366
|
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
@@ -251,6 +418,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
251
418
|
});
|
|
252
419
|
mark();
|
|
253
420
|
}, [mark]);
|
|
421
|
+
// A zone bound to a shared item needs the item's row id (ops.shared.list),
|
|
422
|
+
// which can land after the draft did. Fill then; nothing else changes.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (!tree || !sharedIds || !record)
|
|
425
|
+
return;
|
|
426
|
+
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
427
|
+
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, sharedIds);
|
|
428
|
+
if (filled !== tree)
|
|
429
|
+
mutate(() => filled);
|
|
430
|
+
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
431
|
+
// or a new draft loads, not on every keystroke.
|
|
432
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
433
|
+
}, [sharedIds, record, mutate, tree === null]);
|
|
254
434
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
255
435
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
256
436
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
@@ -260,6 +440,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
260
440
|
setPicker(null);
|
|
261
441
|
select(node.id);
|
|
262
442
|
};
|
|
443
|
+
const doInsertShared = (zone, index, item) => {
|
|
444
|
+
const ref = sharedIds?.[item.name];
|
|
445
|
+
if (!ref)
|
|
446
|
+
return;
|
|
447
|
+
const node = placementNode(item, ref, newNodeId(SHARED_NODE_TYPE));
|
|
448
|
+
mutate((t) => insertNode(t, zone, index, node));
|
|
449
|
+
setPicker(null);
|
|
450
|
+
select(node.id);
|
|
451
|
+
};
|
|
263
452
|
const doDuplicate = (node) => {
|
|
264
453
|
if (!tree)
|
|
265
454
|
return;
|
|
@@ -302,7 +491,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
302
491
|
}
|
|
303
492
|
let result;
|
|
304
493
|
try {
|
|
305
|
-
result = await ops.pages.publish(id);
|
|
494
|
+
result = await ops.pages.publish(id, locale);
|
|
306
495
|
}
|
|
307
496
|
catch (e) {
|
|
308
497
|
console.error(e);
|
|
@@ -336,8 +525,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
336
525
|
if (folder) {
|
|
337
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" })] }) }));
|
|
338
527
|
}
|
|
528
|
+
if (record && !record.locales[locale]) {
|
|
529
|
+
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" })] }) }));
|
|
531
|
+
}
|
|
339
532
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
340
533
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
534
|
+
const selectedShared = selected ? sharedOf(selected) : null;
|
|
341
535
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
342
536
|
const selLocked = policyOf(selZone)?.kind === "locked";
|
|
343
537
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
@@ -382,7 +576,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
382
576
|
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
383
577
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
384
578
|
const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
|
|
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(
|
|
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: {
|
|
386
580
|
height: 240, display: "grid", placeItems: "center",
|
|
387
581
|
color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
|
|
388
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: {
|
|
@@ -395,12 +589,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
395
589
|
width: hoverRect.w, height: hoverRect.h,
|
|
396
590
|
outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
|
|
397
591
|
outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
|
|
398
|
-
} })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
592
|
+
} })), selRect && selected && (selectedSection || selectedShared) && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
399
593
|
position: "absolute", left: selRect.x, top: selRect.y,
|
|
400
594
|
width: selRect.w, height: selRect.h,
|
|
401
595
|
outline: "var(--outline-selection)", outlineOffset: -1.5,
|
|
402
596
|
borderRadius: "var(--radius-sm)",
|
|
403
|
-
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection
|
|
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: {
|
|
404
598
|
position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
|
|
405
599
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
406
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) => {
|
|
@@ -411,7 +605,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
411
605
|
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
|
|
412
606
|
pointerEvents: "auto",
|
|
413
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));
|
|
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
|
|
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 &&
|
|
609
|
+
!isFixedNode(record, registry) &&
|
|
610
|
+
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
611
|
+
? () => { 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) => {
|
|
415
613
|
const locked = policyOf(zone)?.kind === "locked";
|
|
416
614
|
const nodes = nodesIn(zone);
|
|
417
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: () => {
|
|
@@ -424,15 +622,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
424
622
|
const section = sectionOf(node.type);
|
|
425
623
|
const isSelected = node.id === selectedId;
|
|
426
624
|
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));
|
|
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));
|
|
428
626
|
}
|
|
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));
|
|
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));
|
|
430
628
|
})] }, zone));
|
|
431
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: {
|
|
432
630
|
position: "absolute",
|
|
433
631
|
left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
|
|
434
632
|
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))),
|
|
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." }))] }) }))] }));
|
|
436
635
|
}
|
|
437
636
|
/** The selected node's ref field (if any) resolves to its target
|
|
438
637
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { PageRecord } from "../../store.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function PageSettings({ record, registry,
|
|
3
|
+
export declare function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }: {
|
|
4
4
|
record: PageRecord;
|
|
5
|
+
locale: string;
|
|
5
6
|
registry: AdminRegistry;
|
|
6
|
-
paths
|
|
7
|
+
/** The page's path in this locale (the `paths` index). */
|
|
8
|
+
path: string | undefined;
|
|
7
9
|
fixed: boolean;
|
|
8
10
|
/** Resolves to an error message, or null when the rename landed. */
|
|
9
|
-
onRename(
|
|
11
|
+
onRename(patch: {
|
|
10
12
|
title?: string;
|
|
11
13
|
slug?: string;
|
|
12
14
|
}): Promise<string | null>;
|
|
13
|
-
|
|
15
|
+
/** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
|
|
16
|
+
onRemoveLocale?: () => void;
|
|
17
|
+
}): import("react").JSX.Element | null;
|
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
// Page settings (spec 2026-09-05 §7
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
|
|
4
|
+
// §6): the ACTIVE locale's title and slug, saved through pages.rename,
|
|
5
|
+
// and "Remove from this locale". The switcher in the canvas meta row
|
|
6
|
+
// selects the locale, so there are no per-locale tabs here. SEO/meta fields are OQ
|
|
7
|
+
// #17's own design pass. Rendered in the inspector while no block is
|
|
8
|
+
// selected.
|
|
6
9
|
import { useEffect, useState } from "react";
|
|
7
|
-
import { Field, SectionLabel } from "../ui/primitives.js";
|
|
8
|
-
export function PageSettings({ record, registry,
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const [draft, setDraft] = useState({});
|
|
10
|
+
import { Button, Field, SectionLabel } from "../ui/primitives.js";
|
|
11
|
+
export function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }) {
|
|
12
|
+
const row = record.locales[locale];
|
|
13
|
+
const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
12
14
|
const [error, setError] = useState(null);
|
|
13
|
-
const isHome = record.parentId === null && record.slug === registry.homePageSlug;
|
|
15
|
+
const isHome = record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
14
16
|
// The record changes identity on every rename; re-seed the inputs from it.
|
|
15
17
|
useEffect(() => {
|
|
16
|
-
setDraft(
|
|
17
|
-
}, [record]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
18
|
-
|
|
18
|
+
setDraft({ title: row?.title ?? "", slug: row?.slug ?? "" });
|
|
19
|
+
}, [record, locale]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
20
|
+
if (!row)
|
|
21
|
+
return null;
|
|
22
|
+
const commit = async (key) => {
|
|
19
23
|
setError(null);
|
|
20
|
-
const next = draft[
|
|
21
|
-
if (next ===
|
|
24
|
+
const next = draft[key];
|
|
25
|
+
if (next === row[key])
|
|
22
26
|
return;
|
|
23
27
|
if (next === "") {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
return; // a blank translation falls back to the default locale
|
|
28
|
+
setError(`A ${key} is required — nothing falls back to another locale.`);
|
|
29
|
+
return;
|
|
29
30
|
}
|
|
30
|
-
setError(await onRename(
|
|
31
|
+
setError(await onRename({ [key]: next }));
|
|
31
32
|
};
|
|
32
|
-
const
|
|
33
|
-
const onKey = (locale, key) => (e) => {
|
|
33
|
+
const onKey = (key) => (e) => {
|
|
34
34
|
if (e.key === "Enter") {
|
|
35
35
|
e.preventDefault();
|
|
36
|
-
void commit(
|
|
36
|
+
void commit(key);
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [
|
|
39
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), onRemoveLocale && (_jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] }))] }));
|
|
40
40
|
}
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PageRecord, PageStore } from "../store.ts";
|
|
3
3
|
export type FixedNode = {
|
|
4
|
-
/** The
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/** The node's segment per locale it exists in. A fixed page always
|
|
5
|
+
* carries every supported locale — a slug map that omits one falls
|
|
6
|
+
* back to the default locale's segment, same as a string slug. A
|
|
7
|
+
* collection mount's segments are the collection's path exactly:
|
|
8
|
+
* every supported locale for a string path, exactly its keys for a
|
|
9
|
+
* per-locale map. */
|
|
10
|
+
segments: Record<string, string>;
|
|
8
11
|
title: string;
|
|
9
|
-
/** A per-locale collection path names its locales; everything else is present everywhere. */
|
|
10
|
-
locales: string[] | null;
|
|
11
12
|
/** The fixed page registration's name; null for a plain mount (a folder). */
|
|
12
13
|
template: string | null;
|
|
13
14
|
/** The collection whose `path` this node is; null for a plain fixed page. */
|
|
14
15
|
collection: string | null;
|
|
15
16
|
};
|
|
16
17
|
export declare function fixedNodeSpecs(config: ResolvedConfig): FixedNode[];
|
|
17
|
-
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
18
|
+
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
19
|
+
* Judged by the default-locale slug only — a root that exists in Finnish
|
|
20
|
+
* alone is never a fixed node, whatever its Finnish slug. */
|
|
18
21
|
export declare function fixedNodeOf(record: {
|
|
19
22
|
parentId: string | null;
|
|
20
|
-
|
|
23
|
+
locales: Record<string, {
|
|
24
|
+
slug: string;
|
|
25
|
+
}>;
|
|
21
26
|
}, config: ResolvedConfig): FixedNode | null;
|
|
22
|
-
/** Create every fixed node that has no root record yet,
|
|
27
|
+
/** Create every fixed node that has no root record yet, give every fixed
|
|
28
|
+
* node the locales it lacks, then list. */
|
|
23
29
|
export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
|