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
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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
|
|
20
|
-
const [
|
|
21
|
-
const [
|
|
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 [
|
|
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 (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
const options = r.data
|
|
96
|
+
.filter((e) => e.locales[locale])
|
|
97
|
+
.map((e) => ({
|
|
53
98
|
id: e.id,
|
|
54
|
-
title:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
215
|
+
window.location.href = `${BASE}/${collection}`;
|
|
99
216
|
else
|
|
100
217
|
setBanner({ tone: "error", text: r.message });
|
|
101
218
|
};
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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: [
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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):",
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export declare function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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:
|
|
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 {};
|