smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- 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/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- 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/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/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- 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.
|
|
@@ -21,19 +24,52 @@ import { tabsFor } from "../forms/tabs.js";
|
|
|
21
24
|
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
22
25
|
import { useDraftAutosave } from "./autosave.js";
|
|
23
26
|
import { PageSettings } from "./PageSettings.js";
|
|
24
|
-
import { isFixedNode } from "../shell/pages-tree.js";
|
|
25
|
-
import {
|
|
27
|
+
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
28
|
+
import { entryTitle } from "../shell/entries-list.js";
|
|
29
|
+
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
30
|
+
import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, SectionLabel, StatusBadge, Tabs, TopBar, cx, } from "../ui/primitives.js";
|
|
26
31
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
27
32
|
const GEOMETRY_TIMEOUT_MS = 4000;
|
|
33
|
+
/** The locale to edit: from the URL (the Pages list links here with
|
|
34
|
+
* ?locale=), else the default. */
|
|
35
|
+
function localeFromUrl(registry) {
|
|
36
|
+
try {
|
|
37
|
+
const l = new URLSearchParams(window.location.search).get("locale");
|
|
38
|
+
if (l && registry.locales.supported.includes(l))
|
|
39
|
+
return l;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// no window — the default is fine
|
|
43
|
+
}
|
|
44
|
+
return registry.locales.default;
|
|
45
|
+
}
|
|
46
|
+
/** Point the address bar at a locale, keeping the rest of the query
|
|
47
|
+
* string — a bare `?locale=…` would drop every other param. */
|
|
48
|
+
function writeLocaleToUrl(next) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(window.location.href);
|
|
51
|
+
u.searchParams.set("locale", next);
|
|
52
|
+
window.history.replaceState(null, "", u);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// no window (or no history) — the URL is cosmetic here
|
|
56
|
+
}
|
|
57
|
+
}
|
|
28
58
|
export function EditorView({ ops, registry, id }) {
|
|
29
59
|
const [record, setRecord] = useState(null);
|
|
30
60
|
const [tree, setTree] = useState(null);
|
|
31
61
|
const [loadError, setLoadError] = useState(null);
|
|
32
62
|
const [ready, setReady] = useState(false);
|
|
33
63
|
const [folder, setFolder] = useState(false);
|
|
34
|
-
|
|
64
|
+
// The locale being edited. Every load, edit, autosave and publish
|
|
65
|
+
// targets this ONE locale (spec 2026-09-06 §6).
|
|
66
|
+
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
67
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
68
|
+
const [adding, setAdding] = useState(false);
|
|
35
69
|
const [paths, setPaths] = useState({});
|
|
36
70
|
const [urls, setUrls] = useState({});
|
|
71
|
+
// The locales the parent exists in — "Add locale" offers only these.
|
|
72
|
+
const [parentLocales, setParentLocales] = useState([]);
|
|
37
73
|
const [rects, setRects] = useState([]);
|
|
38
74
|
const [emptyZones, setEmptyZones] = useState([]);
|
|
39
75
|
const [docH, setDocH] = useState(900);
|
|
@@ -51,19 +87,21 @@ export function EditorView({ ops, registry, id }) {
|
|
|
51
87
|
const treeRef = useRef(null);
|
|
52
88
|
const registryRef = useRef(registry);
|
|
53
89
|
registryRef.current = registry;
|
|
90
|
+
// 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.
|
|
91
|
+
const treeLocaleRef = useRef(locale);
|
|
54
92
|
// Every edit marks the draft dirty; a debounced saveDraft persists, with
|
|
55
93
|
// retry on failure, and the bridge refreshes the iframe on the save ack.
|
|
56
94
|
const { saveState, mark, flush } = useDraftAutosave({
|
|
57
95
|
read: () => treeRef.current,
|
|
58
|
-
persist: (t) => ops.pages.saveDraft(id, t),
|
|
96
|
+
persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
|
|
59
97
|
onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
|
|
60
98
|
});
|
|
61
99
|
// The URL that serves this page, per locale, from the store's paths
|
|
62
100
|
// index — the label is the CMS path, the canvas and "View live" take
|
|
63
101
|
// the site URL (href applied server-side by pages.get).
|
|
64
|
-
const path = paths[locale] ??
|
|
102
|
+
const path = paths[locale] ?? "/";
|
|
65
103
|
const label = path;
|
|
66
|
-
const siteUrl = urls[locale] ??
|
|
104
|
+
const siteUrl = urls[locale] ?? path;
|
|
67
105
|
const iframeSrc = ready ? withEditorParam(siteUrl) : null;
|
|
68
106
|
// ── load: draft mode FIRST, then the record; only then point the iframe
|
|
69
107
|
// at the site route (an iframe loaded before the cookie exists would
|
|
@@ -71,6 +109,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
71
109
|
useEffect(() => {
|
|
72
110
|
let cancelled = false;
|
|
73
111
|
(async () => {
|
|
112
|
+
// A new locale (or a reload) is a new canvas: nothing from the previous one may survive.
|
|
113
|
+
setReady(false);
|
|
114
|
+
setTree(null);
|
|
115
|
+
treeRef.current = null;
|
|
116
|
+
setSelectedId(null);
|
|
117
|
+
setHoverId(null);
|
|
118
|
+
setFolder(false);
|
|
74
119
|
// ops is a server action — it can reject outright (offline, 500,
|
|
75
120
|
// aborted), not just resolve to a typed error. Either failure mode
|
|
76
121
|
// must land on the same full-pane error, never a hung load.
|
|
@@ -92,7 +137,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
92
137
|
}
|
|
93
138
|
let result;
|
|
94
139
|
try {
|
|
95
|
-
result = await ops.pages.get(id);
|
|
140
|
+
result = await ops.pages.get(id, locale);
|
|
96
141
|
}
|
|
97
142
|
catch {
|
|
98
143
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -103,6 +148,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
103
148
|
setLoadError(result.message);
|
|
104
149
|
return;
|
|
105
150
|
}
|
|
151
|
+
setRecord(result.data.record);
|
|
152
|
+
setPaths(result.data.paths);
|
|
153
|
+
setUrls(result.data.urls);
|
|
154
|
+
setParentLocales(result.data.parentLocales);
|
|
155
|
+
// Absent in this locale: the "Add <locale>" pane, derived from the record.
|
|
156
|
+
if (!result.data.record.locales[locale])
|
|
157
|
+
return;
|
|
106
158
|
// A folder has no draft tree — it gets the notice pane, not a canvas.
|
|
107
159
|
if (result.data.draft === null) {
|
|
108
160
|
setFolder(true);
|
|
@@ -118,9 +170,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
118
170
|
const reg = registryRef.current;
|
|
119
171
|
const template = reg.pages.find((p) => p.name === result.data.record.template);
|
|
120
172
|
const draft = fillLockedZones(result.data.draft, template?.zones ?? {}, reg.sections);
|
|
121
|
-
|
|
122
|
-
setPaths(result.data.paths);
|
|
123
|
-
setUrls(result.data.urls);
|
|
173
|
+
treeLocaleRef.current = locale;
|
|
124
174
|
setTree(draft);
|
|
125
175
|
treeRef.current = draft;
|
|
126
176
|
if (draft !== result.data.draft)
|
|
@@ -128,7 +178,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
128
178
|
setReady(true);
|
|
129
179
|
})();
|
|
130
180
|
return () => { cancelled = true; };
|
|
131
|
-
}, [ops, id]);
|
|
181
|
+
}, [ops, id, locale, reloadKey]);
|
|
132
182
|
// A new canvas URL (a locale switch, a rename) means new geometry — the
|
|
133
183
|
// previous page's rects are stale the instant the iframe starts loading
|
|
134
184
|
// a different route, so clear them alongside resetting geometry.
|
|
@@ -137,17 +187,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
137
187
|
// After a rename only the record and the URL indexes are stale — never
|
|
138
188
|
// re-read the tree here: the draft in memory may be ahead of the store.
|
|
139
189
|
const reload = useCallback(async () => {
|
|
140
|
-
const result = await ops.pages.get(id).catch(() => null);
|
|
190
|
+
const result = await ops.pages.get(id, locale).catch(() => null);
|
|
141
191
|
if (!result || !result.ok)
|
|
142
192
|
return;
|
|
143
193
|
setRecord(result.data.record);
|
|
144
194
|
setPaths(result.data.paths);
|
|
145
195
|
setUrls(result.data.urls);
|
|
146
|
-
|
|
147
|
-
|
|
196
|
+
setParentLocales(result.data.parentLocales);
|
|
197
|
+
}, [ops, id, locale]);
|
|
198
|
+
const onRename = async (patch) => {
|
|
148
199
|
let result;
|
|
149
200
|
try {
|
|
150
|
-
result = await ops.pages.rename(id, { locale
|
|
201
|
+
result = await ops.pages.rename(id, { locale, ...patch });
|
|
151
202
|
}
|
|
152
203
|
catch {
|
|
153
204
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -157,6 +208,84 @@ export function EditorView({ ops, registry, id }) {
|
|
|
157
208
|
await reload();
|
|
158
209
|
return null;
|
|
159
210
|
};
|
|
211
|
+
/** Switch the edited locale. The pending autosave must land first, or
|
|
212
|
+
* it would persist THIS tree under the new locale. */
|
|
213
|
+
const switchLocale = async (next) => {
|
|
214
|
+
if (next === locale)
|
|
215
|
+
return;
|
|
216
|
+
const flushed = await flush();
|
|
217
|
+
if (!flushed) {
|
|
218
|
+
setBanner({ text: "Couldn't save your latest changes — switch cancelled." });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
writeLocaleToUrl(next);
|
|
222
|
+
setBanner(null);
|
|
223
|
+
setLocale(next);
|
|
224
|
+
};
|
|
225
|
+
/** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
|
|
226
|
+
const doAddLocale = async (target) => {
|
|
227
|
+
if (!record || adding)
|
|
228
|
+
return;
|
|
229
|
+
const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
|
|
230
|
+
if (from === locale) {
|
|
231
|
+
const flushed = await flush();
|
|
232
|
+
if (!flushed) {
|
|
233
|
+
setBanner({ text: "Couldn't save your latest changes — add cancelled." });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
setAdding(true);
|
|
238
|
+
let result;
|
|
239
|
+
try {
|
|
240
|
+
result = await ops.pages.addLocale(id, target, { from });
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
244
|
+
}
|
|
245
|
+
setAdding(false);
|
|
246
|
+
if (!result.ok) {
|
|
247
|
+
setBanner({ text: result.message });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// The record now HAS the locale; keep it, or the pane below would go
|
|
251
|
+
// on offering "doesn't exist yet" until the reload lands.
|
|
252
|
+
setRecord(result.data);
|
|
253
|
+
if (target === locale)
|
|
254
|
+
setReloadKey((k) => k + 1);
|
|
255
|
+
else
|
|
256
|
+
void switchLocale(target);
|
|
257
|
+
};
|
|
258
|
+
const doRemoveLocale = async () => {
|
|
259
|
+
if (!record)
|
|
260
|
+
return;
|
|
261
|
+
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
262
|
+
if (remaining.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
|
|
265
|
+
return;
|
|
266
|
+
// Let a pending save land before the row goes, so no save races the
|
|
267
|
+
// delete — and if it cannot land, stop: a retry mid-flight would
|
|
268
|
+
// recreate the very draft the removal is deleting.
|
|
269
|
+
const flushed = await flush();
|
|
270
|
+
if (!flushed) {
|
|
271
|
+
setBanner({ text: "Couldn't save your latest changes — removal cancelled." });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await ops.pages.removeLocale(id, locale);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
280
|
+
}
|
|
281
|
+
if (!result.ok) {
|
|
282
|
+
setBanner({ text: result.message });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
|
|
286
|
+
writeLocaleToUrl(next);
|
|
287
|
+
setLocale(next);
|
|
288
|
+
};
|
|
160
289
|
const template = useMemo(() => (record ? registry.pages.find((p) => p.name === record.template) ?? null : null), [record, registry]);
|
|
161
290
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
162
291
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
@@ -176,15 +305,20 @@ export function EditorView({ ops, registry, id }) {
|
|
|
176
305
|
return out;
|
|
177
306
|
}, [registry]);
|
|
178
307
|
useEffect(() => {
|
|
308
|
+
let cancelled = false;
|
|
179
309
|
for (const target of new Set(Object.values(refTargets))) {
|
|
180
310
|
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
181
311
|
ops.entries.list(target).then((r) => {
|
|
182
|
-
|
|
312
|
+
// A list issued for the previous locale must not overwrite this one's.
|
|
313
|
+
if (cancelled || !r.ok)
|
|
183
314
|
return;
|
|
184
|
-
const options = r.data
|
|
315
|
+
const options = r.data
|
|
316
|
+
// only entries present in the locale being edited can be referenced there
|
|
317
|
+
.filter((e) => e.locales[locale])
|
|
318
|
+
.map((e) => ({
|
|
185
319
|
id: e.id,
|
|
186
|
-
title:
|
|
187
|
-
status: e.status,
|
|
320
|
+
title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
|
|
321
|
+
status: e.locales[locale].status,
|
|
188
322
|
}));
|
|
189
323
|
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
190
324
|
}).catch((e) => {
|
|
@@ -193,7 +327,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
193
327
|
console.error(e);
|
|
194
328
|
});
|
|
195
329
|
}
|
|
196
|
-
|
|
330
|
+
return () => { cancelled = true; };
|
|
331
|
+
}, [ops, refTargets, registry, locale]);
|
|
197
332
|
// ── canvas messaging ─────────────────────────────────────────────────
|
|
198
333
|
const postToCanvas = useCallback((m) => {
|
|
199
334
|
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
@@ -302,7 +437,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
302
437
|
}
|
|
303
438
|
let result;
|
|
304
439
|
try {
|
|
305
|
-
result = await ops.pages.publish(id);
|
|
440
|
+
result = await ops.pages.publish(id, locale);
|
|
306
441
|
}
|
|
307
442
|
catch (e) {
|
|
308
443
|
console.error(e);
|
|
@@ -336,6 +471,10 @@ export function EditorView({ ops, registry, id }) {
|
|
|
336
471
|
if (folder) {
|
|
337
472
|
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
473
|
}
|
|
474
|
+
if (record && !record.locales[locale]) {
|
|
475
|
+
const from = sourceLocaleOf(record, registry);
|
|
476
|
+
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" })] }) }));
|
|
477
|
+
}
|
|
339
478
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
340
479
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
341
480
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
@@ -382,7 +521,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
382
521
|
const tabs = selectedSection ? tabsFor(selectedSection) : [];
|
|
383
522
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
384
523
|
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(
|
|
524
|
+
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
525
|
height: 240, display: "grid", placeItems: "center",
|
|
387
526
|
color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
|
|
388
527
|
}, 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: {
|
|
@@ -411,7 +550,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
411
550
|
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
|
|
412
551
|
pointerEvents: "auto",
|
|
413
552
|
}, 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,
|
|
553
|
+
})] })] }) })] }), _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, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
554
|
+
!isFixedNode(record, registry) &&
|
|
555
|
+
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
556
|
+
? () => { void doRemoveLocale(); }
|
|
557
|
+
: 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}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
415
558
|
const locked = policyOf(zone)?.kind === "locked";
|
|
416
559
|
const nodes = nodesIn(zone);
|
|
417
560
|
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: () => {
|
|
@@ -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[]>;
|
|
@@ -1,25 +1,19 @@
|
|
|
1
|
-
import { perLocaleSegments } from "../paths.js";
|
|
1
|
+
import { fixedPageSegments, perLocaleSegments } from "../paths.js";
|
|
2
2
|
export function fixedNodeSpecs(config) {
|
|
3
3
|
const { default: def, supported } = config.locales;
|
|
4
4
|
const perLocale = (v) => perLocaleSegments(v, supported);
|
|
5
|
-
const i18nOf = (segments) => {
|
|
6
|
-
const out = {};
|
|
7
|
-
for (const [locale, segment] of Object.entries(segments)) {
|
|
8
|
-
if (locale !== def && segment !== segments[def])
|
|
9
|
-
out[locale] = { slug: segment };
|
|
10
|
-
}
|
|
11
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
12
|
-
};
|
|
13
5
|
const specs = new Map();
|
|
14
6
|
for (const p of config.registry.pages ?? []) {
|
|
15
7
|
if (p.schema.kind !== "page" || p.schema.slug === undefined)
|
|
16
8
|
continue;
|
|
17
|
-
|
|
9
|
+
// Fixed pages exist in every supported locale (spec 2026-09-06 §3): a
|
|
10
|
+
// slug map that omits a locale falls back to the default locale's
|
|
11
|
+
// segment. One helper fills it for the boot check (config.ts), here
|
|
12
|
+
// and fixedPathFor (localize.ts), so the three cannot drift.
|
|
13
|
+
const segments = fixedPageSegments(p.schema.slug, config.locales);
|
|
18
14
|
specs.set(segments[def], {
|
|
19
|
-
|
|
20
|
-
i18n: i18nOf(segments),
|
|
15
|
+
segments,
|
|
21
16
|
title: p.schema.title ?? p.schema.name,
|
|
22
|
-
locales: null,
|
|
23
17
|
template: p.schema.name,
|
|
24
18
|
collection: null,
|
|
25
19
|
});
|
|
@@ -35,46 +29,66 @@ export function fixedNodeSpecs(config) {
|
|
|
35
29
|
indexPage.collection = c.name;
|
|
36
30
|
continue;
|
|
37
31
|
}
|
|
38
|
-
specs.set(segments[def], {
|
|
39
|
-
slug: segments[def],
|
|
40
|
-
i18n: i18nOf(segments),
|
|
41
|
-
title: c.title,
|
|
42
|
-
locales: typeof c.path === "string" ? null : Object.keys(segments),
|
|
43
|
-
template: null,
|
|
44
|
-
collection: c.name,
|
|
45
|
-
});
|
|
32
|
+
specs.set(segments[def], { segments, title: c.title, template: null, collection: c.name });
|
|
46
33
|
}
|
|
47
34
|
return [...specs.values()];
|
|
48
35
|
}
|
|
49
|
-
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
36
|
+
/** The spec a ROOT record materializes, or null for an editor-owned node.
|
|
37
|
+
* Judged by the default-locale slug only — a root that exists in Finnish
|
|
38
|
+
* alone is never a fixed node, whatever its Finnish slug. */
|
|
50
39
|
export function fixedNodeOf(record, config) {
|
|
51
40
|
if (record.parentId !== null)
|
|
52
41
|
return null;
|
|
53
|
-
|
|
42
|
+
const def = config.locales.default;
|
|
43
|
+
const slug = record.locales[def]?.slug;
|
|
44
|
+
if (slug === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
return fixedNodeSpecs(config).find((s) => s.segments[def] === slug) ?? null;
|
|
54
47
|
}
|
|
55
|
-
/** Create every fixed node that has no root record yet,
|
|
48
|
+
/** Create every fixed node that has no root record yet, give every fixed
|
|
49
|
+
* node the locales it lacks, then list. */
|
|
56
50
|
export async function ensureFixedNodes(pages, config) {
|
|
51
|
+
const def = config.locales.default;
|
|
57
52
|
const all = await pages.listPages();
|
|
58
|
-
const
|
|
53
|
+
const roots = all.filter((p) => p.parentId === null);
|
|
59
54
|
let attempted = false;
|
|
60
55
|
for (const spec of fixedNodeSpecs(config)) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
56
|
+
const slug = spec.segments[def];
|
|
57
|
+
let record = roots.find((p) => p.locales[def]?.slug === slug);
|
|
58
|
+
if (!record) {
|
|
59
|
+
attempted = true;
|
|
60
|
+
try {
|
|
61
|
+
record = await pages.createPage({ locale: def, slug, template: spec.template, title: spec.title });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
// Two admins loading at once race on this very create; the loser's
|
|
65
|
+
// slugTaken is the other one winning, not a failure, so it skips
|
|
66
|
+
// silently — no warn. Anything else is worth saying out loud.
|
|
67
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
68
|
+
if (!message.includes("already exists")) {
|
|
69
|
+
console.warn(`smoodly: could not materialize "${slug}": ${message}`);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
// Every locale the registration names — including one added to the
|
|
75
|
+
// config after the record was first materialized.
|
|
76
|
+
for (const [locale, segment] of Object.entries(spec.segments)) {
|
|
77
|
+
if (record.locales[locale])
|
|
78
|
+
continue;
|
|
79
|
+
attempted = true;
|
|
80
|
+
try {
|
|
81
|
+
record = await pages.addLocale(record.id, locale, { from: def, slug: segment });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
// One locale's row that cannot land — a segment or path an editor
|
|
85
|
+
// already claimed there — must not take the node or the whole list
|
|
86
|
+
// down: warn, skip that locale, keep materializing the rest. Only a
|
|
87
|
+
// concurrent add of the SAME locale is a race, and it skips silently.
|
|
88
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
89
|
+
if (!message.includes("already exists in locale")) {
|
|
90
|
+
console.warn(`smoodly: could not materialize "${slug}" in "${locale}": ${message}`);
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
}
|