smoodly 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// One shared item, one locale (spec 2026-09-07 §6): the entry form's
|
|
4
|
+
// shape without what a singleton never has — no slug, no "new", no
|
|
5
|
+
// delete, no add/remove locale (every supported locale exists by
|
|
6
|
+
// construction). Shared fields above, the locale's own below, and for a
|
|
7
|
+
// visual item a Styles group edited under the reserved `$styles` key.
|
|
8
|
+
// Status is per locale and immediate. "Used on N pages" is the refs
|
|
9
|
+
// index's answer.
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { STYLES_KEY } from "../../shared.js";
|
|
12
|
+
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
13
|
+
import { relativeTime } from "../ui/format.js";
|
|
14
|
+
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
15
|
+
import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
|
|
16
|
+
import { entryTitle } from "./entries-list.js";
|
|
17
|
+
import { BASE } from "./base.js";
|
|
18
|
+
const STATUSES = ["Draft", "Published"];
|
|
19
|
+
function localeFromUrl(registry) {
|
|
20
|
+
try {
|
|
21
|
+
const l = new URLSearchParams(window.location.search).get("locale");
|
|
22
|
+
if (l && registry.locales.supported.includes(l))
|
|
23
|
+
return l;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// no window — the default is fine
|
|
27
|
+
}
|
|
28
|
+
return registry.locales.default;
|
|
29
|
+
}
|
|
30
|
+
function writeLocaleToUrl(next) {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(window.location.href);
|
|
33
|
+
u.searchParams.set("locale", next);
|
|
34
|
+
window.history.replaceState(null, "", u);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// the URL is cosmetic here
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function SharedForm({ ops, registry, name }) {
|
|
41
|
+
const item = registry.shared.find((s) => s.name === name);
|
|
42
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
43
|
+
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
44
|
+
const [record, setRecord] = useState(null);
|
|
45
|
+
const [fields, setFields] = useState(null);
|
|
46
|
+
const [usage, setUsage] = useState([]);
|
|
47
|
+
const [refOptions, setRefOptions] = useState({});
|
|
48
|
+
const [banner, setBanner] = useState(null);
|
|
49
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
50
|
+
const [dirty, setDirty] = useState(false);
|
|
51
|
+
const refTargets = useMemo(() => {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [key, d] of Object.entries(item?.fields ?? {})) {
|
|
54
|
+
if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
|
|
55
|
+
out[key] = d.target;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}, [item]);
|
|
59
|
+
const localeKeys = useMemo(() => Object.entries(item?.fields ?? {}).filter(([, d]) => d.localized === true).map(([key]) => key), [item]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let cancelled = false;
|
|
62
|
+
setFields(null);
|
|
63
|
+
setFieldErrors({});
|
|
64
|
+
ops.shared.get(name, locale).then((r) => {
|
|
65
|
+
if (cancelled)
|
|
66
|
+
return;
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
setRecord(r.data.record);
|
|
69
|
+
setFields(r.data.fields);
|
|
70
|
+
setUsage(r.data.usage);
|
|
71
|
+
setDirty(false);
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
setBanner({ tone: "error", text: r.message });
|
|
75
|
+
}).catch(() => { if (!cancelled)
|
|
76
|
+
setBanner({ tone: "error", text: "Couldn't reach the server." }); });
|
|
77
|
+
return () => { cancelled = true; };
|
|
78
|
+
}, [ops, name, locale]);
|
|
79
|
+
// Ref options in the locale being edited — the entry form's rule.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
for (const target of new Set(Object.values(refTargets))) {
|
|
83
|
+
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
84
|
+
ops.entries.list(target).then((r) => {
|
|
85
|
+
if (cancelled || !r.ok)
|
|
86
|
+
return;
|
|
87
|
+
const options = r.data
|
|
88
|
+
.filter((e) => e.locales[locale])
|
|
89
|
+
.map((e) => ({ id: e.id, title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id, status: e.locales[locale].status }));
|
|
90
|
+
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
91
|
+
}).catch(console.error);
|
|
92
|
+
}
|
|
93
|
+
return () => { cancelled = true; };
|
|
94
|
+
}, [ops, refTargets, registry, locale]);
|
|
95
|
+
if (!item)
|
|
96
|
+
return _jsx("p", { style: { padding: 32 }, children: "No such shared item." });
|
|
97
|
+
const applyResult = (r, okText) => {
|
|
98
|
+
if (r.ok) {
|
|
99
|
+
setBanner({ tone: "ok", text: okText });
|
|
100
|
+
setFieldErrors({});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
setBanner({ tone: "error", text: r.message });
|
|
104
|
+
setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
const save = async () => {
|
|
108
|
+
if (!fields)
|
|
109
|
+
return;
|
|
110
|
+
const r = await ops.shared.save(name, locale, fields);
|
|
111
|
+
if (applyResult(r, "Saved.") && r.ok) {
|
|
112
|
+
setRecord(r.data);
|
|
113
|
+
setDirty(false);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const row = record?.locales[locale];
|
|
117
|
+
const status = row?.status ?? "draft";
|
|
118
|
+
const setPublished = async (next) => {
|
|
119
|
+
if (next === status)
|
|
120
|
+
return;
|
|
121
|
+
const r = await ops.shared.setStatus(name, locale, next);
|
|
122
|
+
if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
|
|
123
|
+
setRecord(r.data);
|
|
124
|
+
};
|
|
125
|
+
const switchLocale = (next) => {
|
|
126
|
+
if (next === locale)
|
|
127
|
+
return;
|
|
128
|
+
if (dirty && !window.confirm("Discard unsaved changes?"))
|
|
129
|
+
return;
|
|
130
|
+
writeLocaleToUrl(next);
|
|
131
|
+
setBanner(null);
|
|
132
|
+
setLocale(next);
|
|
133
|
+
};
|
|
134
|
+
const shared = Object.entries(item.fields).filter(([key]) => !localeKeys.includes(key));
|
|
135
|
+
const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
|
|
136
|
+
const styles = (fields?.[STYLES_KEY] ?? {});
|
|
137
|
+
const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
|
|
138
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
139
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "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 }), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), 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)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
|
|
140
|
+
setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
|
|
141
|
+
setDirty(true);
|
|
142
|
+
} }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: { display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px", minHeight: 0, overflow: "auto", borderLeft: "var(--border)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: !row, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } })] }), 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: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })] }))] })] })] }));
|
|
143
|
+
}
|
|
144
|
+
function InfoRow({ label, value, mono }) {
|
|
145
|
+
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: { color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: value })] }));
|
|
146
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The Shared content list (spec 2026-09-07 §6): every registered item is
|
|
4
|
+
// a row, edited on its own page. Nothing here is created or deleted by
|
|
5
|
+
// editors — the registry declares the items and ops.shared.list
|
|
6
|
+
// materializes them — so there is no "+ New".
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { ListView } from "./ListView.js";
|
|
9
|
+
import { sharedRows } from "./shared-list.js";
|
|
10
|
+
import { Segmented, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { relativeTime } from "../ui/format.js";
|
|
12
|
+
import { BASE } from "./base.js";
|
|
13
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
14
|
+
const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
|
|
15
|
+
function rememberedLocale(registry) {
|
|
16
|
+
try {
|
|
17
|
+
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
18
|
+
if (stored && registry.locales.supported.includes(stored))
|
|
19
|
+
return stored;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// storage unavailable — the default is fine
|
|
23
|
+
}
|
|
24
|
+
return registry.locales.default;
|
|
25
|
+
}
|
|
26
|
+
export function SharedList({ ops, registry }) {
|
|
27
|
+
const [items, setItems] = useState(null);
|
|
28
|
+
const [error, setError] = useState(null);
|
|
29
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
30
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
31
|
+
const pickLocale = (next) => {
|
|
32
|
+
setLocale(next);
|
|
33
|
+
try {
|
|
34
|
+
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
35
|
+
}
|
|
36
|
+
catch { /* see rememberedLocale */ }
|
|
37
|
+
};
|
|
38
|
+
const load = useCallback(() => {
|
|
39
|
+
ops.shared.list()
|
|
40
|
+
.then((r) => (r.ok ? setItems(r.data) : setError(r.message)))
|
|
41
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
42
|
+
}, [ops]);
|
|
43
|
+
useEffect(load, [load]);
|
|
44
|
+
if (error)
|
|
45
|
+
return _jsx(ErrorPane, { message: error });
|
|
46
|
+
const rows = items ? sharedRows(items, registry, locale) : [];
|
|
47
|
+
return (_jsx(ListView, { title: "Shared content", count: items ? rows.length : undefined, actions: multilingual ? _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }) : undefined, columns: [
|
|
48
|
+
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
|
+
{ label: "Kind", width: "90px" },
|
|
50
|
+
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
51
|
+
{ label: "Status", width: "120px" },
|
|
52
|
+
], empty: items ? "No shared content is registered." : "Loading…", rows: rows.map((row) => ({
|
|
53
|
+
key: row.name,
|
|
54
|
+
href: `${BASE}/shared/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`,
|
|
55
|
+
cells: [
|
|
56
|
+
_jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
|
|
57
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
58
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
|
|
59
|
+
_jsx(StatusBadge, { status: row.status }, "s"),
|
|
60
|
+
],
|
|
61
|
+
})) }));
|
|
62
|
+
}
|
|
@@ -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 {};
|
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
// Pure view-model for the Pages list — the tree of the design system's
|
|
2
2
|
// TreeTable (indent per depth, chevron on parents, the derived path as
|
|
3
|
-
// the slug column)
|
|
3
|
+
// the slug column) for ONE selected locale (spec 2026-09-06 §6). The
|
|
4
|
+
// structure is shared, so every node has a row; presence is per locale,
|
|
5
|
+
// so a node absent from the selected locale is dimmed with no path and
|
|
6
|
+
// an "Add" action. The shell is not DOM-tested; every decision that
|
|
4
7
|
// matters lives here.
|
|
5
|
-
import { titleFor } from "../../localize.js";
|
|
6
8
|
import { buildPageTree } from "../../paths.js";
|
|
7
9
|
/** Registrations editors may create pages against: fixed ones are
|
|
8
10
|
* materialized by Smoodly, never offered. */
|
|
9
11
|
export function openTemplates(templates) {
|
|
10
12
|
return templates.filter((t) => t.slug === undefined);
|
|
11
13
|
}
|
|
14
|
+
/** The default-locale slug that identifies a ROOT node to the registry;
|
|
15
|
+
* a root that exists in another locale only is never code-owned. */
|
|
16
|
+
const rootSlug = (record, registry) => record.parentId === null ? record.locales[registry.locales.default]?.slug : undefined;
|
|
12
17
|
/** The collection a ROOT record is the mount of, if any — known from the
|
|
13
18
|
* registry, never from a column (spec 2026-09-05 §4). */
|
|
14
19
|
export function mountOf(record, registry) {
|
|
15
|
-
|
|
20
|
+
const slug = rootSlug(record, registry);
|
|
21
|
+
if (slug === undefined)
|
|
16
22
|
return null;
|
|
17
|
-
return registry.collections.find((c) => c.path?.[registry.locales.default] ===
|
|
23
|
+
return registry.collections.find((c) => c.path?.[registry.locales.default] === slug) ?? null;
|
|
18
24
|
}
|
|
19
|
-
/** Fixed pages and mounts: never renamed, moved or
|
|
25
|
+
/** Fixed pages and mounts: never renamed, moved, deleted or stripped of a locale by editors. */
|
|
20
26
|
export function isFixedNode(record, registry) {
|
|
21
|
-
|
|
27
|
+
const slug = rootSlug(record, registry);
|
|
28
|
+
return slug !== undefined && (registry.pages.some((t) => t.slug === slug) || mountOf(record, registry) !== null);
|
|
29
|
+
}
|
|
30
|
+
/** A node's title for display in a locale it may not exist in: its own,
|
|
31
|
+
* else the default locale's, else any. Display only — never content. */
|
|
32
|
+
export function displayTitle(record, locale, registry) {
|
|
33
|
+
return (record.locales[locale]?.title ??
|
|
34
|
+
record.locales[registry.locales.default]?.title ??
|
|
35
|
+
Object.values(record.locales)[0]?.title ??
|
|
36
|
+
record.id);
|
|
37
|
+
}
|
|
38
|
+
/** The locale an "Add <locale>" copies from: the default when the node has it, else its first. */
|
|
39
|
+
export function sourceLocaleOf(record, registry) {
|
|
40
|
+
return record.locales[registry.locales.default] ? registry.locales.default : Object.keys(record.locales)[0];
|
|
41
|
+
}
|
|
42
|
+
/** Whether a locale's row is live, and if so whether the draft has moved
|
|
43
|
+
* on. `status` decides — spec 2026-09-06 §2 keeps it as a column so a
|
|
44
|
+
* future unpublish can clear it while the version pointers stay — and
|
|
45
|
+
* the pointers only tell "published" from "draft edits". The Pages list
|
|
46
|
+
* and the editor's badge both read it here so they cannot disagree. */
|
|
47
|
+
export function localeStatusOf(row) {
|
|
48
|
+
if (row.status !== "published")
|
|
49
|
+
return "draft";
|
|
50
|
+
// Pointers exist on pages and on versioned collections; a row without
|
|
51
|
+
// them is live as it stands.
|
|
52
|
+
if (row.publishedVersionId === null)
|
|
53
|
+
return "published";
|
|
54
|
+
return row.publishedVersionId === row.draftVersionId ? "published" : "draft edits";
|
|
22
55
|
}
|
|
23
56
|
export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
24
57
|
const opts = { locales: registry.locales, homePageSlug: registry.homePageSlug };
|
|
@@ -27,8 +60,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
27
60
|
const walk = (nodes, depth, parentId) => {
|
|
28
61
|
nodes.forEach((node, index) => {
|
|
29
62
|
const r = node.record;
|
|
63
|
+
const row = r.locales[locale];
|
|
64
|
+
const present = row !== undefined;
|
|
30
65
|
const mount = mountOf(r, registry);
|
|
31
|
-
const isHome = r.parentId === null && r.slug === registry.homePageSlug;
|
|
66
|
+
const isHome = r.parentId === null && r.locales[registry.locales.default]?.slug === registry.homePageSlug;
|
|
32
67
|
const expanded = !collapsed.has(r.id);
|
|
33
68
|
rows.push({
|
|
34
69
|
id: r.id,
|
|
@@ -38,8 +73,10 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
38
73
|
siblingCount: nodes.length,
|
|
39
74
|
hasChildren: node.children.length > 0,
|
|
40
75
|
expanded,
|
|
41
|
-
title:
|
|
76
|
+
title: displayTitle(r, locale, registry),
|
|
42
77
|
path: node.path,
|
|
78
|
+
present,
|
|
79
|
+
locales: Object.keys(r.locales),
|
|
43
80
|
kind: mount ? "mount" : r.template === null ? "folder" : "page",
|
|
44
81
|
collection: mount?.name ?? null,
|
|
45
82
|
fixed: isFixedNode(r, registry),
|
|
@@ -48,12 +85,9 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
|
|
|
48
85
|
templateTitle: mount
|
|
49
86
|
? r.template === null ? "Collection" : `Collection · ${templateTitle(r.template)}`
|
|
50
87
|
: r.template === null ? "Folder" : templateTitle(r.template),
|
|
51
|
-
status: r.template === null
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
? r.publishedVersionId === r.draftVersionId ? "published" : "draft edits"
|
|
55
|
-
: "draft",
|
|
56
|
-
canHaveChildren: !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
88
|
+
status: !row || r.template === null ? null : localeStatusOf(row),
|
|
89
|
+
// A child needs its parent in the locale (the parent rule), so an absent node offers none.
|
|
90
|
+
canHaveChildren: present && !mount && !isHome && depth + 1 < registry.pageDepth,
|
|
57
91
|
record: r,
|
|
58
92
|
});
|
|
59
93
|
if (expanded)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntryRecord } from "../../entry-store.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
import { type LocaleStatus } from "./pages-tree.ts";
|
|
4
|
+
export type SharedRow = {
|
|
5
|
+
name: string;
|
|
6
|
+
title: string;
|
|
7
|
+
flavour: "Visual" | "Fields";
|
|
8
|
+
status: LocaleStatus;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function sharedRows(items: {
|
|
12
|
+
name: string;
|
|
13
|
+
record: EntryRecord;
|
|
14
|
+
}[], registry: AdminRegistry, locale: string): SharedRow[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { localeStatusOf } from "./pages-tree.js";
|
|
2
|
+
export function sharedRows(items, registry, locale) {
|
|
3
|
+
const byName = new Map(items.map((i) => [i.name, i.record]));
|
|
4
|
+
return registry.shared.flatMap((item) => {
|
|
5
|
+
const record = byName.get(item.name);
|
|
6
|
+
const row = record?.locales[locale];
|
|
7
|
+
if (!record || !row)
|
|
8
|
+
return [];
|
|
9
|
+
return [{
|
|
10
|
+
name: item.name,
|
|
11
|
+
title: item.title,
|
|
12
|
+
flavour: item.section !== undefined ? "Visual" : "Fields",
|
|
13
|
+
status: localeStatusOf(row),
|
|
14
|
+
updatedAt: row.updatedAt,
|
|
15
|
+
}];
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/admin/tree-ops.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageTree, TreeNode } from "../render.tsx";
|
|
2
2
|
import type { ZonePolicy } from "../zones.ts";
|
|
3
|
-
import type { AdminSection } from "./serialize.ts";
|
|
3
|
+
import type { AdminSection, AdminShared } from "./serialize.ts";
|
|
4
4
|
export declare function zoneOf(tree: PageTree, id: string): string | null;
|
|
5
5
|
export declare function findNode(tree: PageTree, id: string): TreeNode | null;
|
|
6
6
|
export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
|
|
@@ -9,11 +9,18 @@ export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageT
|
|
|
9
9
|
export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
|
|
10
10
|
export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
|
|
11
11
|
export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
|
|
12
|
+
/** The visual items the picker offers in a zone: materialized (an id is
|
|
13
|
+
* known) and rendering a section the zone's policy allows. */
|
|
14
|
+
export declare function allowedShared(policy: ZonePolicy | undefined, shared: AdminShared[], sharedIds: Record<string, string>): AdminShared[];
|
|
15
|
+
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
16
|
+
* names the registration, `ref` is the item's row id. */
|
|
17
|
+
export declare function placementNode(item: AdminShared, ref: string, id: string): TreeNode;
|
|
12
18
|
export declare function nodeFromSample(section: AdminSection, id: string): TreeNode;
|
|
13
19
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
14
20
|
* remove — so someone has to put it there. This does, from the component's
|
|
15
|
-
* sample, for
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
21
|
+
* sample (or, for a zone bound to a shared item, a placement), for every
|
|
22
|
+
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
23
|
+
* which also covers pages that predate a locked zone being added to their
|
|
24
|
+
* schema. Returns the same tree when nothing changed. */
|
|
25
|
+
export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
|
|
19
26
|
export declare function newNodeId(type: string): string;
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SHARED_NODE_TYPE } from "../shared.js";
|
|
1
2
|
export function zoneOf(tree, id) {
|
|
2
3
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
3
4
|
if (nodes.some((n) => n.id === id))
|
|
@@ -60,6 +61,22 @@ export function allowedSections(policy, all) {
|
|
|
60
61
|
const allow = new Set(policy.allow);
|
|
61
62
|
return all.filter((s) => allow.has(s.name));
|
|
62
63
|
}
|
|
64
|
+
/** The visual items the picker offers in a zone: materialized (an id is
|
|
65
|
+
* known) and rendering a section the zone's policy allows. */
|
|
66
|
+
export function allowedShared(policy, shared, sharedIds) {
|
|
67
|
+
if (!policy || policy.kind === "locked")
|
|
68
|
+
return [];
|
|
69
|
+
return shared.filter((s) => {
|
|
70
|
+
if (s.section === undefined || sharedIds[s.name] === undefined)
|
|
71
|
+
return false;
|
|
72
|
+
return policy.allow === "*" || policy.allow.includes(s.section);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
76
|
+
* names the registration, `ref` is the item's row id. */
|
|
77
|
+
export function placementNode(item, ref, id) {
|
|
78
|
+
return { id, type: SHARED_NODE_TYPE, fields: { item: item.name, ref } };
|
|
79
|
+
}
|
|
63
80
|
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
64
81
|
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
65
82
|
* crashes the first section view that maps over it. */
|
|
@@ -89,21 +106,35 @@ export function nodeFromSample(section, id) {
|
|
|
89
106
|
}
|
|
90
107
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
91
108
|
* remove — so someone has to put it there. This does, from the component's
|
|
92
|
-
* sample, for
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
109
|
+
* sample (or, for a zone bound to a shared item, a placement), for every
|
|
110
|
+
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
111
|
+
* which also covers pages that predate a locked zone being added to their
|
|
112
|
+
* schema. Returns the same tree when nothing changed. */
|
|
113
|
+
export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
|
|
96
114
|
let next = null;
|
|
97
115
|
for (const [zone, policy] of Object.entries(policies)) {
|
|
98
116
|
if (policy.kind !== "locked")
|
|
99
117
|
continue;
|
|
100
118
|
if ((tree.zones[zone] ?? []).length > 0)
|
|
101
119
|
continue;
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
120
|
+
let node = null;
|
|
121
|
+
if (policy.shared !== undefined) {
|
|
122
|
+
// Bound to a shared item (spec 2026-09-07 §4): a placement, once
|
|
123
|
+
// the editor has loaded the item's row id; until then leave the
|
|
124
|
+
// zone empty rather than seed a plain section there.
|
|
125
|
+
const ref = sharedIds[policy.shared];
|
|
126
|
+
if (ref === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
node = placementNode({ name: policy.shared }, ref, newNodeId(SHARED_NODE_TYPE));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const section = sections.find((s) => s.name === policy.component);
|
|
132
|
+
if (!section)
|
|
133
|
+
continue;
|
|
134
|
+
node = nodeFromSample(section, newNodeId(section.name));
|
|
135
|
+
}
|
|
105
136
|
next ?? (next = { ...tree, zones: { ...tree.zones } });
|
|
106
|
-
next.zones[zone] = [
|
|
137
|
+
next.zones[zone] = [node];
|
|
107
138
|
}
|
|
108
139
|
return next ?? tree;
|
|
109
140
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
+
export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
|
|
3
|
+
registry: AdminRegistry;
|
|
4
|
+
/** The locales the record has; `[value]` while nothing is loaded. */
|
|
5
|
+
present: string[];
|
|
6
|
+
parentLocales: string[];
|
|
7
|
+
value: string;
|
|
8
|
+
adding: boolean;
|
|
9
|
+
onSwitch(locale: string): void;
|
|
10
|
+
onAdd(locale: string): void;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
|
|
4
|
+
const missing = registry.locales.supported.filter((l) => !present.includes(l) && l !== value);
|
|
5
|
+
return (_jsxs("select", { className: "sm-input", style: { width: "auto" }, value: value, "aria-label": "Locale", onChange: (e) => { const v = e.target.value; if (v.startsWith("+"))
|
|
6
|
+
onAdd(v.slice(1));
|
|
7
|
+
else
|
|
8
|
+
onSwitch(v); }, children: [present.map((l) => _jsx("option", { value: l, children: l }, l)), !present.includes(value) && _jsxs("option", { value: value, children: [value, " (not yet)"] }), missing.length > 0 && (_jsx("optgroup", { label: "Add locale", disabled: adding, children: missing.map((l) => {
|
|
9
|
+
const blocked = !parentLocales.includes(l);
|
|
10
|
+
const reason = `add ${l} to the parent first`;
|
|
11
|
+
return blocked
|
|
12
|
+
? _jsxs("option", { value: `+${l}`, disabled: true, title: reason, children: ["+ ", l, " \u2014 ", reason] }, l)
|
|
13
|
+
: _jsxs("option", { value: `+${l}`, children: ["+ ", l] }, l);
|
|
14
|
+
}) }))] }));
|
|
15
|
+
}
|