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.
Files changed (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  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/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. package/package.json +1 -1
@@ -1,28 +1,57 @@
1
- // packages/smoodly/src/admin/shell/EntryForm.tsx
2
1
  "use client";
3
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
4
- // The classic field view: same FieldWidget renderer as the editor sidebar,
5
- // no tabs (collections are fields-only by design). The safe-delete guard's
6
- // message renders verbatim it is product surface, not an error detail.
7
- //
8
- // Layout follows the design system's article-edit screen: top bar with the
9
- // breadcrumb and the save action, a roomy field column, and a 300px sidebar
10
- // holding status and document info.
3
+ // The classic field view for ONE locale of an entry (spec 2026-09-06
4
+ // §7): shared fields (every locale sees them) above, the locale's own
5
+ // fields the .localized() ones and the slug below, the same
6
+ // FieldWidget renderer as the editor sidebar, no tabs. The locale
7
+ // switcher loads that locale's merged fields; "Add locale" copies the
8
+ // locale on screen; "Remove from <locale>" sits in the sidebar behind a
9
+ // confirm. Status is per locale and immediate. The safe-delete guard's
10
+ // message renders verbatim — it is product surface.
11
11
  import { useEffect, useMemo, useState } from "react";
12
12
  import { FieldWidget } from "../forms/FieldWidget.js";
13
13
  import { relativeTime } from "../ui/format.js";
14
- import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar, } from "../ui/primitives.js";
14
+ import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
15
+ import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
16
+ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
17
+ import { BASE } from "./base.js";
15
18
  const STATUSES = ["Draft", "Published"];
19
+ /** The locale to edit: from the URL (the list links here with ?locale=), else the default. */
20
+ function localeFromUrl(registry) {
21
+ try {
22
+ const l = new URLSearchParams(window.location.search).get("locale");
23
+ if (l && registry.locales.supported.includes(l))
24
+ return l;
25
+ }
26
+ catch {
27
+ // no window — the default is fine
28
+ }
29
+ return registry.locales.default;
30
+ }
31
+ function writeLocaleToUrl(next) {
32
+ try {
33
+ const u = new URL(window.location.href);
34
+ u.searchParams.set("locale", next);
35
+ window.history.replaceState(null, "", u);
36
+ }
37
+ catch {
38
+ // the URL is cosmetic here
39
+ }
40
+ }
16
41
  export function EntryForm({ ops, registry, collection, id }) {
17
42
  const meta = registry.collections.find((c) => c.name === collection);
18
43
  const isNew = id === "new";
19
- const [fields, setFields] = useState({});
20
- const [status, setStatus] = useState("draft");
21
- const [stamps, setStamps] = useState(null);
44
+ const multilingual = registry.locales.supported.length > 1;
45
+ const [locale, setLocale] = useState(() => localeFromUrl(registry));
46
+ const [record, setRecord] = useState(null);
47
+ const [fields, setFields] = useState(isNew ? {} : null);
48
+ const [parentLocales, setParentLocales] = useState(registry.locales.supported);
22
49
  const [refOptions, setRefOptions] = useState({});
23
50
  const [banner, setBanner] = useState(null);
24
51
  const [fieldErrors, setFieldErrors] = useState({});
25
- const [loaded, setLoaded] = useState(isNew);
52
+ const [adding, setAdding] = useState(false);
53
+ const [reloadKey, setReloadKey] = useState(0);
54
+ const [dirty, setDirty] = useState(false);
26
55
  const refTargets = useMemo(() => {
27
56
  const out = {};
28
57
  for (const [key, d] of Object.entries(meta?.fields ?? {})) {
@@ -31,33 +60,50 @@ export function EntryForm({ ops, registry, collection, id }) {
31
60
  }
32
61
  return out;
33
62
  }, [meta]);
63
+ const localeKeys = useMemo(() => (meta ? localizedFieldKeys(meta) : []), [meta]);
64
+ // Load the locale's merged fields; an absent locale comes back with fields: null.
34
65
  useEffect(() => {
35
- if (!isNew) {
36
- ops.entries.get(collection, id).then((r) => {
37
- if (r.ok) {
38
- setFields(r.data.fields);
39
- setStatus(r.data.status);
40
- setLoaded(true);
41
- setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
42
- }
43
- else
44
- setBanner({ tone: "error", text: r.message });
45
- }).catch(() => setBanner({ tone: "error", text: "Couldn't reach the server." }));
46
- }
66
+ if (isNew)
67
+ return;
68
+ let cancelled = false;
69
+ setFields(null);
70
+ setFieldErrors({});
71
+ ops.entries.get(collection, id, locale).then((r) => {
72
+ if (cancelled)
73
+ return;
74
+ if (r.ok) {
75
+ setRecord(r.data.record);
76
+ setFields(r.data.fields);
77
+ setParentLocales(r.data.parentLocales);
78
+ setDirty(false);
79
+ }
80
+ else
81
+ setBanner({ tone: "error", text: r.message });
82
+ }).catch(() => { if (!cancelled)
83
+ setBanner({ tone: "error", text: "Couldn't reach the server." }); });
84
+ return () => { cancelled = true; };
85
+ }, [ops, collection, id, isNew, locale, reloadKey]);
86
+ // Ref options in the locale being edited: a target absent there is not offered.
87
+ useEffect(() => {
88
+ let cancelled = false;
47
89
  for (const target of new Set(Object.values(refTargets))) {
48
90
  const targetMeta = registry.collections.find((c) => c.name === target);
49
91
  ops.entries.list(target).then((r) => {
50
- if (!r.ok)
92
+ // A list issued for the previous locale must not overwrite this one's.
93
+ if (cancelled || !r.ok)
51
94
  return;
52
- const options = r.data.map((e) => ({
95
+ const options = r.data
96
+ .filter((e) => e.locales[locale])
97
+ .map((e) => ({
53
98
  id: e.id,
54
- title: String((targetMeta?.titleField && e.fields[targetMeta.titleField]) ?? e.id),
55
- status: e.status,
99
+ title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
100
+ status: e.locales[locale].status,
56
101
  }));
57
102
  setRefOptions((prev) => ({ ...prev, [target]: options }));
58
103
  }).catch(console.error);
59
104
  }
60
- }, [ops, collection, id, isNew, refTargets, registry]);
105
+ return () => { cancelled = true; };
106
+ }, [ops, refTargets, registry, locale]);
61
107
  if (!meta)
62
108
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
63
109
  const applyResult = (r, okText) => {
@@ -71,46 +117,118 @@ export function EntryForm({ ops, registry, collection, id }) {
71
117
  return false;
72
118
  };
73
119
  const save = async () => {
120
+ if (!fields)
121
+ return;
74
122
  if (isNew) {
75
- const r = await ops.entries.create(collection, fields);
123
+ const r = await ops.entries.create(collection, { locale, fields });
76
124
  if (applyResult(r, "Created.") && r.ok) {
77
- window.location.href = `/admin/${collection}/${encodeURIComponent(r.data.id)}`;
125
+ setDirty(false);
126
+ window.location.href = `${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`;
78
127
  }
79
128
  }
80
129
  else {
81
- const r = await ops.entries.save(collection, id, fields);
82
- if (applyResult(r, "Saved.") && r.ok)
83
- setStamps({ createdAt: r.data.createdAt, updatedAt: r.data.updatedAt });
130
+ const r = await ops.entries.save(collection, id, locale, fields);
131
+ if (applyResult(r, "Saved.") && r.ok) {
132
+ setRecord(r.data);
133
+ setDirty(false);
134
+ }
84
135
  }
85
136
  };
137
+ const row = record?.locales[locale];
138
+ const status = row?.status ?? "draft";
86
139
  const setPublished = async (next) => {
87
140
  if (next === status)
88
141
  return;
89
- const r = await ops.entries.setStatus(collection, id, next);
90
- if (applyResult(r, next === "published" ? "Published." : "Unpublished."))
91
- setStatus(next);
142
+ const r = await ops.entries.setStatus(collection, id, locale, next);
143
+ if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
144
+ setRecord(r.data);
145
+ };
146
+ const goToLocale = (next) => {
147
+ if (next === locale)
148
+ return;
149
+ writeLocaleToUrl(next);
150
+ setBanner(null);
151
+ setLocale(next);
152
+ };
153
+ const switchLocale = (next) => {
154
+ if (next === locale)
155
+ return;
156
+ if (dirty && !window.confirm("Discard unsaved changes?"))
157
+ return;
158
+ goToLocale(next);
159
+ };
160
+ /** Add a locale, copying the locale on screen (or the record's source locale when this one is absent). */
161
+ const doAddLocale = async (target) => {
162
+ if (!record || adding)
163
+ return;
164
+ if (dirty && !window.confirm("Discard unsaved changes?"))
165
+ return;
166
+ const from = record.locales[locale] ? locale : entrySourceLocale(record, registry);
167
+ setAdding(true);
168
+ let result;
169
+ try {
170
+ result = await ops.entries.addLocale(collection, id, target, { from });
171
+ }
172
+ catch {
173
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
174
+ }
175
+ setAdding(false);
176
+ if (!result.ok) {
177
+ setBanner({ tone: "error", text: result.message });
178
+ return;
179
+ }
180
+ setRecord(result.data);
181
+ if (target === locale)
182
+ setReloadKey((k) => k + 1);
183
+ else
184
+ goToLocale(target);
185
+ };
186
+ const doRemoveLocale = async () => {
187
+ if (!record)
188
+ return;
189
+ const remaining = Object.keys(record.locales).filter((l) => l !== locale);
190
+ if (remaining.length === 0)
191
+ return;
192
+ if (dirty && !window.confirm("Discard unsaved changes?"))
193
+ return;
194
+ if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
195
+ return;
196
+ let result;
197
+ try {
198
+ result = await ops.entries.removeLocale(collection, id, locale);
199
+ }
200
+ catch {
201
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
202
+ }
203
+ if (!result.ok) {
204
+ setBanner({ tone: "error", text: result.message });
205
+ return;
206
+ }
207
+ setRecord(result.data);
208
+ goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
92
209
  };
93
210
  const destroy = async () => {
94
- if (!window.confirm("Delete this entry?"))
211
+ if (!window.confirm(multilingual ? "Delete this entry in every language?" : "Delete this entry?"))
95
212
  return;
96
213
  const r = await ops.entries.delete(collection, id);
97
214
  if (r.ok)
98
- window.location.href = `/admin/${collection}`;
215
+ window.location.href = `${BASE}/${collection}`;
99
216
  else
100
217
  setBanner({ tone: "error", text: r.message });
101
218
  };
102
- const titleValue = meta.titleField ? fields[meta.titleField] : undefined;
103
- const heading = isNew ? "New entry" : (typeof titleValue === "string" && titleValue) || id;
104
- // The mount's path in the default locale prefixes the slug (spec §7);
105
- // a collection without a path has no URLs and shows no prefix.
106
- const base = meta.path?.[registry.locales.default];
219
+ const heading = isNew ? "New entry" : record ? entryTitle(record, meta, registry, locale) : id;
220
+ // The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
221
+ const base = meta.path?.[locale];
107
222
  const slugPrefix = base ? `/${base}/` : undefined;
108
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `/admin/${collection}`, items: [{ label: meta.title, href: `/admin/${collection}` }, { label: heading }] }), right: _jsx(Button, { onClick: save, disabled: !loaded, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), loaded
109
- ? Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => setFields((f) => ({ ...f, [key]: value })) }, key)))
110
- : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
223
+ const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
224
+ const shared = Object.entries(meta.fields).filter(([key]) => !localeKeys.includes(key));
225
+ const own = Object.entries(meta.fields).filter(([key]) => localeKeys.includes(key));
226
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
227
+ const absent = !isNew && record !== null && fields === null && !record.locales[locale];
228
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [{ label: meta.title, href: `${BASE}/${collection}` }, { label: heading }] }), multilingual && !isNew && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })), multilingual && isNew && _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => switchLocale(entrySourceLocale(record, registry)), children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: {
111
229
  display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px",
112
230
  minHeight: 0, overflow: "auto", borderLeft: "var(--border)",
113
- }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Status" }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: isNew || !loaded, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } }), isNew && _jsx("span", { className: "sm-hint", children: "Create the entry first \u2014 it starts as a draft." })] }), !isNew && stamps && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(stamps.createdAt) }), _jsx(InfoRow, { label: "Updated", value: relativeTime(stamps.updatedAt) }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })] })), !isNew && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsx(Button, { variant: "danger", disabled: !loaded, onClick: destroy, children: "Delete" })] }))] })] })] }));
231
+ }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: isNew || !row, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } }), isNew && _jsx("span", { className: "sm-hint", children: "Create the entry first \u2014 it starts as a draft." })] }), !isNew && record && row && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })] })), !isNew && record && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] })] }))] })] })] }));
114
232
  }
115
233
  function InfoRow({ label, value, mono }) {
116
234
  return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
@@ -1,21 +1,46 @@
1
1
  // packages/smoodly/src/admin/shell/PagesList.tsx
2
2
  "use client";
3
3
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
- // The Pages list: the tree of the design system's TreeTable, plus the
5
- // row menu (New subpage/subfolder, Add page here, Rename, Move, Delete)
6
- // as a floating popover positioned from the triggering click.
4
+ // The Pages list: the tree of the design system's TreeTable for ONE
5
+ // selected locale (spec 2026-09-06 §6), plus the row menu (New
6
+ // subpage/subfolder, Add page here, Add <locale>, Rename, Move, Remove
7
+ // from <locale>, Delete) as a floating popover positioned from the
8
+ // triggering click. Structure is shared, so every node has a row; a node
9
+ // absent from the selected locale is dimmed and offers "Add <locale>".
7
10
  import { useCallback, useEffect, useState } from "react";
8
11
  import { ListView } from "./ListView.js";
9
- import { openTemplates, pagesTreeRows } from "./pages-tree.js";
10
- import { Button, StatusBadge } from "../ui/primitives.js";
12
+ import { openTemplates, pagesTreeRows, sourceLocaleOf } from "./pages-tree.js";
13
+ import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
11
14
  import { BASE } from "./base.js";
12
15
  import { ErrorPane } from "./ErrorPane.js";
16
+ const LOCALE_KEY = "smoodly.pages.locale";
17
+ /** The selected locale survives a reload within the session; a stored
18
+ * value that is no longer supported falls back to the default. */
19
+ function rememberedLocale(registry) {
20
+ try {
21
+ const stored = window.sessionStorage.getItem(LOCALE_KEY);
22
+ if (stored && registry.locales.supported.includes(stored))
23
+ return stored;
24
+ }
25
+ catch {
26
+ // storage unavailable (private mode, no window) — the default is fine
27
+ }
28
+ return registry.locales.default;
29
+ }
13
30
  export function PagesList({ ops, registry }) {
14
31
  const [pages, setPages] = useState(null);
15
32
  const [error, setError] = useState(null);
16
33
  const [collapsed, setCollapsed] = useState(() => new Set());
17
34
  const [menu, setMenu] = useState(null);
18
- const locale = registry.locales.default;
35
+ const [locale, setLocale] = useState(() => rememberedLocale(registry));
36
+ const multilingual = registry.locales.supported.length > 1;
37
+ const pickLocale = (next) => {
38
+ setLocale(next);
39
+ try {
40
+ window.sessionStorage.setItem(LOCALE_KEY, next);
41
+ }
42
+ catch { /* see rememberedLocale */ }
43
+ };
19
44
  const load = useCallback(() => {
20
45
  ops.pages.list()
21
46
  .then((r) => (r.ok ? setPages(r.data) : setError(r.message)))
@@ -29,7 +54,10 @@ export function PagesList({ ops, registry }) {
29
54
  next.delete(id);
30
55
  else
31
56
  next.add(id); return next; });
32
- const openEditor = (id) => { window.location.href = `${BASE}/pages/${encodeURIComponent(id)}`; };
57
+ /** The editor opens in the locale selected here. */
58
+ const openEditor = (id) => {
59
+ window.location.href = `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
60
+ };
33
61
  /** Run an op; on success reload the list (and maybe navigate), else show the op's message. */
34
62
  const run = async (work, then) => {
35
63
  let result;
@@ -53,6 +81,7 @@ export function PagesList({ ops, registry }) {
53
81
  return creatable[0].name;
54
82
  return window.prompt(`Template? (${creatable.map((p) => p.name).join(", ")})`) || null;
55
83
  };
84
+ /** New page and New folder create in the SELECTED locale (spec §6). */
56
85
  const newNode = (parentId, kind) => {
57
86
  if (kind === "page" && noOpen)
58
87
  return;
@@ -62,7 +91,7 @@ export function PagesList({ ops, registry }) {
62
91
  const template = kind === "page" ? pickTemplate() : null;
63
92
  if (kind === "page" && !template)
64
93
  return;
65
- void run(() => ops.pages.create({ parentId, slug, template }), (page) => { if (template)
94
+ void run(() => ops.pages.create({ parentId, locale, slug, template }), (page) => { if (template)
66
95
  openEditor(page.id); });
67
96
  };
68
97
  const addPageHere = (row) => {
@@ -71,8 +100,23 @@ export function PagesList({ ops, registry }) {
71
100
  return;
72
101
  void run(() => ops.pages.setTemplate(row.id, template), (page) => openEditor(page.id));
73
102
  };
103
+ const addLocale = (row) => {
104
+ const from = sourceLocaleOf(row.record, registry);
105
+ if (!window.confirm(`Add "${row.title}" to ${locale}? Its ${from} content is copied as the first ${locale} draft.`))
106
+ return;
107
+ void run(() => ops.pages.addLocale(row.id, locale, { from }), (page) => { if (page.template !== null)
108
+ openEditor(page.id); });
109
+ };
110
+ const removeLocale = (row) => {
111
+ if (!window.confirm(`Remove "${row.title}" from ${locale}? Its ${locale} content and history are deleted.`))
112
+ return;
113
+ void run(() => ops.pages.removeLocale(row.id, locale));
114
+ };
74
115
  const rename = (row) => {
75
- const title = window.prompt("Title:", row.record.title);
116
+ const current = row.record.locales[locale];
117
+ if (!current)
118
+ return;
119
+ const title = window.prompt("Title:", current.title);
76
120
  if (title === null)
77
121
  return;
78
122
  // The site root's slug is the config's homePageSlug — title only here.
@@ -80,19 +124,24 @@ export function PagesList({ ops, registry }) {
80
124
  void run(() => ops.pages.rename(row.id, { locale, title }));
81
125
  return;
82
126
  }
83
- const slug = window.prompt("Slug (the URL segment):", row.record.slug);
127
+ const slug = window.prompt("Slug (the URL segment):", current.slug);
84
128
  if (slug === null)
85
129
  return;
86
130
  void run(() => ops.pages.rename(row.id, { locale, title, slug }));
87
131
  };
88
132
  const move = (row, dir) => void run(() => ops.pages.move(row.id, { parentId: row.parentId, index: row.index + dir }));
89
133
  const destroy = (row) => {
90
- if (!window.confirm(`Delete "${row.title}"?`))
134
+ const scope = row.locales.length > 1 ? ` in every language (${row.locales.join(", ")})` : "";
135
+ if (!window.confirm(`Delete "${row.title}"${scope}?`))
91
136
  return;
92
137
  void run(() => ops.pages.delete(row.id));
93
138
  };
94
139
  const menuItems = (row) => {
95
140
  const items = [];
141
+ if (!row.present) {
142
+ items.push({ label: `Add ${locale}`, run: () => addLocale(row) });
143
+ return items;
144
+ }
96
145
  if (row.kind === "mount" && row.template !== null)
97
146
  items.push({ label: "Edit index page", run: () => openEditor(row.id) });
98
147
  if (row.canHaveChildren && !noOpen)
@@ -107,12 +156,17 @@ export function PagesList({ ops, registry }) {
107
156
  items.push({ label: "Move up", run: () => move(row, -1) });
108
157
  if (!row.fixed && !row.isHome && row.index < row.siblingCount - 1)
109
158
  items.push({ label: "Move down", run: () => move(row, 1) });
159
+ if (multilingual && !row.fixed && !row.isHome && row.locales.length > 1) {
160
+ items.push({ label: `Remove from ${locale}`, run: () => removeLocale(row) });
161
+ }
110
162
  if (!row.fixed && !row.isHome && !row.hasChildren)
111
163
  items.push({ label: "Delete", run: () => destroy(row) });
112
164
  return items;
113
165
  };
114
166
  const onRow = (row) => {
115
- if (row.kind === "mount" && row.collection)
167
+ if (!row.present)
168
+ addLocale(row);
169
+ else if (row.kind === "mount" && row.collection)
116
170
  window.location.href = `${BASE}/${row.collection}`;
117
171
  else if (row.template !== null)
118
172
  openEditor(row.id);
@@ -122,25 +176,31 @@ export function PagesList({ ops, registry }) {
122
176
  if (error)
123
177
  return _jsx(ErrorPane, { message: error });
124
178
  const items = menu ? menuItems(menu.row) : [];
125
- return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", count: pages?.length, actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New folder" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
179
+ return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Pages", count: pages?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { variant: "secondary", onClick: () => newNode(null, "folder"), children: "+ New folder" }), _jsx(Button, { onClick: () => newNode(null, "page"), disabled: noOpen, title: noOpen ? "Every page registration is fixed — add an open one (a page schema with no `slug`) to create pages here." : undefined, children: "+ New page" })] }), columns: [
126
180
  { label: "Title", width: "minmax(160px,1fr)" },
127
181
  { label: "Path", width: "minmax(140px,260px)" },
128
182
  { label: "Template", width: "minmax(100px,160px)" },
129
183
  { label: "Status", width: "110px" },
130
184
  { label: "", width: "32px" },
131
- ], empty: pages ? "No pages yet." : "Loading…", rows: rows.map((row) => ({
132
- key: row.id,
133
- onClick: () => onRow(row),
134
- cells: [
135
- _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: row.depth * 20 }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
136
- _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p"),
137
- _jsx("span", { className: "sm-cell sm-cell--muted", children: row.templateTitle }, "k"),
138
- row.status
139
- ? _jsx(StatusBadge, { status: row.status }, "s")
140
- : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
141
- _jsx("button", { type: "button", className: "sm-rowmenu", "aria-label": "Row menu", onClick: (e) => { e.stopPropagation(); setMenu({ row, x: e.clientX, y: e.clientY }); }, onKeyDown: (e) => e.stopPropagation(), children: "\u00B7\u00B7\u00B7" }, "m"),
142
- ],
143
- })) }), menu && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setMenu(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
185
+ ], empty: pages ? "No pages yet." : "Loading…", rows: rows.map((row) => {
186
+ // An absent node is still a row (structure is shared) but reads as dimmed.
187
+ const dim = row.present ? undefined : { opacity: 0.45 };
188
+ return {
189
+ key: row.id,
190
+ onClick: () => onRow(row),
191
+ cells: [
192
+ _jsxs("span", { className: row.hasChildren ? "sm-cell sm-cell--tree sm-cell--parent" : "sm-cell sm-cell--tree", style: { paddingLeft: row.depth * 20, ...dim }, children: [_jsx("button", { type: "button", className: "sm-chevron", "aria-label": row.expanded ? "Collapse" : "Expand", "aria-expanded": row.hasChildren ? row.expanded : undefined, style: { visibility: row.hasChildren ? "visible" : "hidden" }, onClick: (e) => { e.stopPropagation(); toggle(row.id); }, onKeyDown: (e) => e.stopPropagation(), children: row.expanded ? "▾" : "▸" }), _jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: row.title })] }, "t"),
193
+ row.present
194
+ ? _jsx("span", { className: "sm-cell sm-cell--mono", children: row.path }, "p")
195
+ : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
196
+ _jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.templateTitle }, "k"),
197
+ row.status
198
+ ? _jsx(StatusBadge, { status: row.status }, "s")
199
+ : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
200
+ _jsx("button", { type: "button", className: "sm-rowmenu", "aria-label": "Row menu", onClick: (e) => { e.stopPropagation(); setMenu({ row, x: e.clientX, y: e.clientY }); }, onKeyDown: (e) => e.stopPropagation(), children: "\u00B7\u00B7\u00B7" }, "m"),
201
+ ],
202
+ };
203
+ }) }), menu && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setMenu(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
144
204
  position: "absolute",
145
205
  left: Math.max(12, Math.min(menu.x - 280, window.innerWidth - 312)),
146
206
  top: Math.min(menu.y + 8, Math.max(12, window.innerHeight - 300)),
@@ -0,0 +1,21 @@
1
+ import type { EntryRecord } from "../../entry-store.ts";
2
+ import type { AdminCollection, AdminRegistry } from "../serialize.ts";
3
+ import { type LocaleStatus } from "./pages-tree.ts";
4
+ export type EntryRow = {
5
+ id: string;
6
+ title: string;
7
+ present: boolean;
8
+ status: LocaleStatus | null;
9
+ /** The locale row's updatedAt when present, else the node's. */
10
+ updatedAt: number;
11
+ };
12
+ /** The title field's value in a locale: its own, else the default
13
+ * locale's, else any locale's, else the id. Display only — a shared
14
+ * title field reads the same from every locale. */
15
+ export declare function entryTitle(record: EntryRecord, meta: AdminCollection, registry: AdminRegistry, locale: string): string;
16
+ /** The locale an "Add <locale>" copies from: the default when the record has it, else its first. */
17
+ export declare function entrySourceLocale(record: EntryRecord, registry: AdminRegistry): string;
18
+ /** The form keys a locale owns: `.localized()` fields, and `slug` always
19
+ * (the same split entry-store.ts's splitFields makes). */
20
+ export declare function localizedFieldKeys(meta: AdminCollection): string[];
21
+ export declare function entryRows(records: EntryRecord[], meta: AdminCollection, registry: AdminRegistry, locale: string): EntryRow[];
@@ -0,0 +1,36 @@
1
+ import { localeStatusOf } from "./pages-tree.js";
2
+ /** The title field's value in a locale: its own, else the default
3
+ * locale's, else any locale's, else the id. Display only — a shared
4
+ * title field reads the same from every locale. */
5
+ export function entryTitle(record, meta, registry, locale) {
6
+ const key = meta.titleField;
7
+ const from = (l) => {
8
+ const row = record.locales[l];
9
+ if (!row || !key)
10
+ return undefined;
11
+ const value = key in row.fields ? row.fields[key] : record.fields[key];
12
+ return typeof value === "string" && value.length > 0 ? value : undefined;
13
+ };
14
+ return from(locale) ?? from(registry.locales.default) ?? Object.keys(record.locales).map(from).find((t) => t !== undefined) ?? record.id;
15
+ }
16
+ /** The locale an "Add <locale>" copies from: the default when the record has it, else its first. */
17
+ export function entrySourceLocale(record, registry) {
18
+ return record.locales[registry.locales.default] ? registry.locales.default : Object.keys(record.locales)[0];
19
+ }
20
+ /** The form keys a locale owns: `.localized()` fields, and `slug` always
21
+ * (the same split entry-store.ts's splitFields makes). */
22
+ export function localizedFieldKeys(meta) {
23
+ return Object.entries(meta.fields).filter(([key, d]) => key === "slug" || d.localized === true).map(([key]) => key);
24
+ }
25
+ export function entryRows(records, meta, registry, locale) {
26
+ return records.map((record) => {
27
+ const row = record.locales[locale];
28
+ return {
29
+ id: record.id,
30
+ title: entryTitle(record, meta, registry, locale),
31
+ present: row !== undefined,
32
+ status: row ? localeStatusOf(row) : null,
33
+ updatedAt: row ? row.updatedAt : record.updatedAt,
34
+ };
35
+ });
36
+ }
@@ -1,19 +1,31 @@
1
- import type { PageRecord } from "../../store.ts";
1
+ import type { PageLocale, PageRecord } from "../../store.ts";
2
2
  import type { AdminCollection, AdminPageTemplate, AdminRegistry } from "../serialize.ts";
3
+ type NodeLike = {
4
+ parentId: string | null;
5
+ locales: Record<string, {
6
+ slug: string;
7
+ }>;
8
+ };
3
9
  /** Registrations editors may create pages against: fixed ones are
4
10
  * materialized by Smoodly, never offered. */
5
11
  export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
6
12
  /** The collection a ROOT record is the mount of, if any — known from the
7
13
  * registry, never from a column (spec 2026-09-05 §4). */
8
- export declare function mountOf(record: {
9
- parentId: string | null;
10
- slug: string;
11
- }, registry: AdminRegistry): AdminCollection | null;
12
- /** Fixed pages and mounts: never renamed, moved or deleted by editors. */
13
- export declare function isFixedNode(record: {
14
- parentId: string | null;
15
- slug: string;
16
- }, registry: AdminRegistry): boolean;
14
+ export declare function mountOf(record: NodeLike, registry: AdminRegistry): AdminCollection | null;
15
+ /** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
16
+ export declare function isFixedNode(record: NodeLike, registry: AdminRegistry): boolean;
17
+ /** A node's title for display in a locale it may not exist in: its own,
18
+ * else the default locale's, else any. Display only never content. */
19
+ export declare function displayTitle(record: PageRecord, locale: string, registry: AdminRegistry): string;
20
+ /** The locale an "Add <locale>" copies from: the default when the node has it, else its first. */
21
+ export declare function sourceLocaleOf(record: PageRecord, registry: AdminRegistry): string;
22
+ export type LocaleStatus = "published" | "draft edits" | "draft";
23
+ /** Whether a locale's row is live, and if so whether the draft has moved
24
+ * on. `status` decides — spec 2026-09-06 §2 keeps it as a column so a
25
+ * future unpublish can clear it while the version pointers stay — and
26
+ * the pointers only tell "published" from "draft edits". The Pages list
27
+ * and the editor's badge both read it here so they cannot disagree. */
28
+ export declare function localeStatusOf(row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): LocaleStatus;
17
29
  export type PagesTreeRow = {
18
30
  id: string;
19
31
  parentId: string | null;
@@ -24,7 +36,12 @@ export type PagesTreeRow = {
24
36
  hasChildren: boolean;
25
37
  expanded: boolean;
26
38
  title: string;
27
- path: string;
39
+ /** The node's path in the selected locale; null when it is absent there. */
40
+ path: string | null;
41
+ /** Whether the node exists in the selected locale. */
42
+ present: boolean;
43
+ /** Every locale the node exists in. */
44
+ locales: string[];
28
45
  kind: "page" | "folder" | "mount";
29
46
  collection: string | null;
30
47
  fixed: boolean;
@@ -32,8 +49,9 @@ export type PagesTreeRow = {
32
49
  isHome: boolean;
33
50
  template: string | null;
34
51
  templateTitle: string;
35
- status: "published" | "draft edits" | "draft" | null;
52
+ status: LocaleStatus | null;
36
53
  canHaveChildren: boolean;
37
54
  record: PageRecord;
38
55
  };
39
56
  export declare function pagesTreeRows(records: PageRecord[], registry: AdminRegistry, locale: string, collapsed: ReadonlySet<string>): PagesTreeRow[];
57
+ export {};