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.
Files changed (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. 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 { 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";
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
- const [locale, setLocale] = useState(registry.locales.default);
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] ?? paths[registry.locales.default] ?? "/";
107
+ const path = paths[locale] ?? "/";
65
108
  const label = path;
66
- const siteUrl = urls[locale] ?? urls[registry.locales.default] ?? path;
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
- setRecord(result.data.record);
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
- }, [ops, id]);
147
- const onRename = async (renameLocale, patch) => {
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: renameLocale, ...patch });
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
- if (!r.ok)
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.map((e) => ({
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: String((targetMeta?.titleField && e.fields[targetMeta.titleField]) ?? e.id),
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
- }, [ops, refTargets, registry]);
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(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: {
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.title, selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
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.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) => {
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))), allowedSections(policyOf(picker.zone), registry.sections).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
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, paths, fixed, onRename }: {
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: Record<string, string>;
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(locale: string, patch: {
11
+ onRename(patch: {
10
12
  title?: string;
11
13
  slug?: string;
12
14
  }): Promise<string | null>;
13
- }): import("react").JSX.Element;
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 { 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.
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, 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({});
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(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) => {
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[locale]?.[key] ?? "";
21
- if (next === stored(locale, key))
24
+ const next = draft[key];
25
+ if (next === row[key])
22
26
  return;
23
27
  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
28
+ setError(`A ${key} is required — nothing falls back to another locale.`);
29
+ return;
29
30
  }
30
- setError(await onRename(locale, { [key]: next }));
31
+ setError(await onRename({ [key]: next }));
31
32
  };
32
- const set = (locale, key, value) => setDraft((d) => ({ ...d, [locale]: { ...(d[locale] ?? { title: "", slug: "" }), [key]: value } }));
33
- const onKey = (locale, key) => (e) => {
33
+ const onKey = (key) => (e) => {
34
34
  if (e.key === "Enter") {
35
35
  e.preventDefault();
36
- void commit(locale, key);
36
+ void commit(key);
37
37
  }
38
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)))] }));
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 { PageI18n, PageRecord, PageStore } from "../store.ts";
2
+ import type { PageRecord, PageStore } from "../store.ts";
3
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;
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
- slug: string;
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, then list. */
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[]>;