smoodly 0.0.14 → 0.0.16

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 (67) hide show
  1. package/README.md +94 -15
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
  6. package/dist/admin/editor/CanvasOverlay.js +5 -5
  7. package/dist/admin/editor/EditorView.d.ts +2 -0
  8. package/dist/admin/editor/EditorView.js +251 -23
  9. package/dist/admin/editor/PageSettings.js +3 -2
  10. package/dist/admin/editor/SharedInspector.d.ts +23 -0
  11. package/dist/admin/editor/SharedInspector.js +13 -0
  12. package/dist/admin/editor/shared-edit.d.ts +39 -0
  13. package/dist/admin/editor/shared-edit.js +124 -0
  14. package/dist/admin/editor/useSharedEdit.d.ts +6 -0
  15. package/dist/admin/editor/useSharedEdit.js +18 -0
  16. package/dist/admin/forms/tabs.d.ts +9 -3
  17. package/dist/admin/next/op-handler.js +4 -3
  18. package/dist/admin/ops-impl.d.ts +3 -0
  19. package/dist/admin/ops-impl.js +198 -23
  20. package/dist/admin/ops.d.ts +32 -0
  21. package/dist/admin/serialize.d.ts +0 -1
  22. package/dist/admin/serialize.js +0 -1
  23. package/dist/admin/shared-items.js +14 -0
  24. package/dist/admin/shell/AdminApp.js +1 -1
  25. package/dist/admin/shell/EntriesList.js +19 -3
  26. package/dist/admin/shell/EntryForm.js +183 -77
  27. package/dist/admin/shell/IconRail.js +3 -3
  28. package/dist/admin/shell/PagesList.js +3 -2
  29. package/dist/admin/shell/SharedFields.d.ts +21 -0
  30. package/dist/admin/shell/SharedFields.js +15 -0
  31. package/dist/admin/shell/SharedForm.js +166 -52
  32. package/dist/admin/shell/SharedList.js +3 -2
  33. package/dist/admin/shell/entries-list.d.ts +2 -2
  34. package/dist/admin/shell/entries-list.js +3 -3
  35. package/dist/admin/supabase-auth.js +1 -1
  36. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  37. package/dist/admin/ui/ActionMenu.js +25 -0
  38. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  39. package/dist/admin/ui/HistoryModal.js +40 -0
  40. package/dist/admin/ui/Modal.d.ts +5 -1
  41. package/dist/admin/ui/Modal.js +3 -3
  42. package/dist/admin/ui/PublishControl.d.ts +31 -0
  43. package/dist/admin/ui/PublishControl.js +24 -0
  44. package/dist/admin/ui/format.d.ts +4 -0
  45. package/dist/admin/ui/format.js +9 -0
  46. package/dist/admin/ui/primitives.d.ts +13 -0
  47. package/dist/admin/ui/primitives.js +18 -2
  48. package/dist/admin/ui/status-label.d.ts +10 -0
  49. package/dist/admin/ui/status-label.js +14 -0
  50. package/dist/admin/ui/theme.js +46 -6
  51. package/dist/collections.d.ts +0 -6
  52. package/dist/collections.js +0 -1
  53. package/dist/entry-store.d.ts +38 -13
  54. package/dist/entry-store.js +90 -23
  55. package/dist/next/page-renderer.js +8 -4
  56. package/dist/shared.d.ts +8 -1
  57. package/dist/shared.js +10 -1
  58. package/dist/site.d.ts +2 -0
  59. package/dist/site.js +17 -7
  60. package/dist/sql.js +18 -15
  61. package/dist/store.d.ts +38 -13
  62. package/dist/store.js +82 -12
  63. package/dist/supabase-entry-store.d.ts +11 -6
  64. package/dist/supabase-entry-store.js +101 -25
  65. package/dist/supabase-store.d.ts +13 -6
  66. package/dist/supabase-store.js +88 -19
  67. package/package.json +1 -1
@@ -2,24 +2,30 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // One Globals item (shared content, spec 2026-09-07 §6), one locale: the
4
4
  // entry form's shape without what a singleton never has — no slug, no
5
- // "new", no delete, no add/remove locale (every supported locale exists
6
- // by construction). The fields in schema order, a globe-off mark after
7
- // the name of each that is not .localized(), and for a visual item a
8
- // Styles group edited under the reserved `$styles` key.
9
- // Status is per locale and immediate. "Used on N pages" is the refs
10
- // index's answer.
11
- import { useEffect, useMemo, useState } from "react";
5
+ // delete, no add/remove locale (every supported locale exists by
6
+ // construction), no site URL. The fields in schema order, a globe-off
7
+ // mark after the name of each that is not .localized(), and for a visual
8
+ // item a Styles group edited under the reserved `$styles` key. Since
9
+ // 2026-09-14 it autosaves like the entry form: no Save button, the top
10
+ // bar carries the indicator and the one publish control, publish
11
+ // validates the draft and lists the fields in an amber banner. "Used on
12
+ // N pages" is the refs index's answer.
13
+ import { useEffect, useMemo, useRef, useState } from "react";
12
14
  import { STYLES_KEY } from "../../shared.js";
13
- import { FieldWidget } from "../forms/FieldWidget.js";
14
- import { relativeTime } from "../ui/format.js";
15
+ import { revealField } from "../forms/reveal.js";
16
+ import { useDraftAutosave } from "../editor/autosave.js";
17
+ import { updatedLine } from "../ui/format.js";
15
18
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
16
- import { Banner, Breadcrumb, Button, SectionLabel, Segmented, TopBar, UnlocalizedMark } from "../ui/primitives.js";
19
+ import { Banner, Breadcrumb, FieldErrorList, InfoRow, SaveIndicator, SectionLabel, TopBar } from "../ui/primitives.js";
20
+ import { PublishControl } from "../ui/PublishControl.js";
21
+ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
22
+ import { localeStatusOf } from "./pages-tree.js";
17
23
  import { ResizeHandle } from "../ui/ResizeHandle.js";
18
24
  import { entryTitle } from "./entries-list.js";
19
25
  import { BASE } from "./base.js";
26
+ import { SharedFields } from "./SharedFields.js";
20
27
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
21
28
  import { useLeaveGuard } from "./router.js";
22
- const STATUSES = ["Draft", "Published"];
23
29
  export function SharedForm({ ops, registry, name }) {
24
30
  const item = registry.shared.find((s) => s.name === name);
25
31
  const multilingual = registry.locales.supported.length > 1;
@@ -28,10 +34,29 @@ export function SharedForm({ ops, registry, name }) {
28
34
  const [fields, setFields] = useState(null);
29
35
  const [usage, setUsage] = useState([]);
30
36
  const [refOptions, setRefOptions] = useState({});
31
- const [banner, setBanner] = useState(null);
37
+ const [notice, setNotice] = useState(null);
32
38
  const [fieldErrors, setFieldErrors] = useState({});
33
39
  const [dirty, setDirty] = useState(false);
34
- useLeaveGuard(dirty);
40
+ const [touched, setTouched] = useState(false);
41
+ const [busy, setBusy] = useState(null);
42
+ const [published, setPublished] = useState(false);
43
+ const [reloadKey, setReloadKey] = useState(0);
44
+ const [history, setHistory] = useState(null);
45
+ const [snapshot, setSnapshot] = useState(null);
46
+ const formRef = useRef(null);
47
+ const fieldsRef = useRef(fields);
48
+ fieldsRef.current = fields;
49
+ const { saveState, mark, flush } = useDraftAutosave({
50
+ read: () => fieldsRef.current,
51
+ persist: async (value) => {
52
+ const r = await ops.shared.save(name, locale, value);
53
+ if (r.ok)
54
+ setRecord(r.data);
55
+ return r;
56
+ },
57
+ onSaved: () => setDirty(false),
58
+ });
59
+ useLeaveGuard(dirty || saveState !== "saved");
35
60
  const refTargets = useMemo(() => {
36
61
  const out = {};
37
62
  for (const [key, d] of Object.entries(item?.fields ?? {})) {
@@ -40,11 +65,12 @@ export function SharedForm({ ops, registry, name }) {
40
65
  }
41
66
  return out;
42
67
  }, [item]);
43
- const localeKeys = useMemo(() => Object.entries(item?.fields ?? {}).filter(([, d]) => d.localized === true).map(([key]) => key), [item]);
44
68
  useEffect(() => {
45
69
  let cancelled = false;
46
70
  setFields(null);
47
71
  setFieldErrors({});
72
+ setDirty(false);
73
+ setTouched(false);
48
74
  ops.shared.get(name, locale).then((r) => {
49
75
  if (cancelled)
50
76
  return;
@@ -52,14 +78,13 @@ export function SharedForm({ ops, registry, name }) {
52
78
  setRecord(r.data.record);
53
79
  setFields(r.data.fields);
54
80
  setUsage(r.data.usage);
55
- setDirty(false);
56
81
  }
57
82
  else
58
- setBanner({ tone: "error", text: r.message });
83
+ setNotice({ tone: "error", text: r.message });
59
84
  }).catch(() => { if (!cancelled)
60
- setBanner({ tone: "error", text: "Couldn't reach the server." }); });
85
+ setNotice({ tone: "error", text: "Couldn't reach the server." }); });
61
86
  return () => { cancelled = true; };
62
- }, [ops, name, locale]);
87
+ }, [ops, name, locale, reloadKey]);
63
88
  // Ref options in the locale being edited — the entry form's rule.
64
89
  useEffect(() => {
65
90
  let cancelled = false;
@@ -78,57 +103,146 @@ export function SharedForm({ ops, registry, name }) {
78
103
  }, [ops, refTargets, registry, locale]);
79
104
  if (!item)
80
105
  return _jsx("p", { style: { padding: 32 }, children: "No such shared item." });
81
- const applyResult = (r, okText) => {
82
- if (r.ok) {
83
- setBanner({ tone: "ok", text: okText });
84
- setFieldErrors({});
85
- return true;
86
- }
87
- setBanner({ tone: "error", text: r.message });
88
- setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
89
- return false;
106
+ const row = record?.locales[locale];
107
+ const status = row ? localeStatusOf(row) : null;
108
+ const fail = (r) => {
109
+ if (r.code === "validation" && r.fields && r.fields.length > 0) {
110
+ setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
111
+ setNotice({ tone: "warn", text: r.message, fields: r.fields });
112
+ }
113
+ else
114
+ setNotice({ tone: "error", text: r.message });
90
115
  };
91
- const save = async () => {
92
- if (!fields)
116
+ const publish = async () => {
117
+ setNotice(null);
118
+ setBusy("publish");
119
+ if (!(await flush())) {
120
+ setBusy(null);
121
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — publish cancelled." });
122
+ return;
123
+ }
124
+ let r;
125
+ try {
126
+ r = await ops.shared.setStatus(name, locale, "published");
127
+ }
128
+ catch {
129
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
130
+ }
131
+ setBusy(null);
132
+ if (!r.ok) {
133
+ fail(r);
93
134
  return;
94
- const r = await ops.shared.save(name, locale, fields);
95
- if (applyResult(r, "Saved.") && r.ok) {
135
+ }
136
+ setRecord(r.data);
137
+ setFieldErrors({});
138
+ setPublished(true);
139
+ setTimeout(() => setPublished(false), 1600);
140
+ };
141
+ const unpublish = async () => {
142
+ setNotice(null);
143
+ setBusy("unpublish");
144
+ let r;
145
+ try {
146
+ r = await ops.shared.setStatus(name, locale, "draft");
147
+ }
148
+ catch {
149
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
150
+ }
151
+ setBusy(null);
152
+ if (r.ok)
96
153
  setRecord(r.data);
97
- setDirty(false);
154
+ else
155
+ fail(r);
156
+ };
157
+ const discard = async () => {
158
+ if (!window.confirm("Discard your changes and restore the published version?"))
159
+ return;
160
+ setNotice(null);
161
+ setBusy("discard");
162
+ if (!(await flush())) {
163
+ setBusy(null);
164
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — discard cancelled." });
165
+ return;
166
+ }
167
+ let r;
168
+ try {
169
+ r = await ops.shared.discardDraft(name, locale);
98
170
  }
171
+ catch {
172
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
173
+ }
174
+ setBusy(null);
175
+ if (!r.ok) {
176
+ fail(r);
177
+ return;
178
+ }
179
+ setRecord(r.data);
180
+ setReloadKey((k) => k + 1);
181
+ setNotice({ tone: "ok", text: "Changes discarded." });
99
182
  };
100
- const row = record?.locales[locale];
101
- const status = row?.status ?? "draft";
102
- const setPublished = async (next) => {
103
- if (next === status)
183
+ const openHistory = async () => {
184
+ if (!row)
104
185
  return;
105
- const r = await ops.shared.setStatus(name, locale, next);
106
- if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
107
- setRecord(r.data);
186
+ if (!(await flush())) {
187
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — history not opened." });
188
+ return;
189
+ }
190
+ const r = await ops.shared.listVersions(name, locale);
191
+ if (!r.ok) {
192
+ fail(r);
193
+ return;
194
+ }
195
+ setSnapshot(null);
196
+ setHistory(historyRows(r.data, row));
197
+ };
198
+ const loadSnapshot = async (versionId) => {
199
+ const r = await ops.shared.getVersion(name, versionId);
200
+ if (r.ok)
201
+ setSnapshot({ id: r.data.id, fields: r.data.fields });
202
+ else
203
+ fail(r);
204
+ };
205
+ const restore = async (versionId) => {
206
+ const r = await ops.shared.restore(name, locale, versionId);
207
+ if (!r.ok) {
208
+ fail(r);
209
+ return false;
210
+ }
211
+ setRecord(r.data);
212
+ setReloadKey((k) => k + 1);
213
+ setNotice({ tone: "ok", text: "Version restored." });
214
+ return true;
108
215
  };
109
- const switchLocale = (next) => {
216
+ const switchLocale = async (next) => {
110
217
  if (next === locale)
111
218
  return;
112
- if (dirty && !window.confirm("Discard unsaved changes?"))
219
+ if (!(await flush())) {
220
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — stayed on this locale." });
113
221
  return;
222
+ }
114
223
  writeLocaleToUrl(next);
115
- setBanner(null);
224
+ setNotice(null);
116
225
  setLocale(next);
117
226
  };
118
- const styles = (fields?.[STYLES_KEY] ?? {});
119
227
  const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
120
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
228
+ const fieldEntries = Object.entries(item.fields).map(([key, descriptor]) => ({ key, ns: "fields", descriptor }));
229
+ const styleEntries = item.section !== undefined
230
+ ? Object.entries(item.styles).map(([key, descriptor]) => ({ key, ns: "styles", descriptor }))
231
+ : [];
232
+ const onChange = (ns, key, value) => {
233
+ setFields((f) => ns === "fields"
234
+ ? { ...(f ?? {}), [key]: value }
235
+ : { ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } });
236
+ setDirty(true);
237
+ setTouched(true);
238
+ mark();
239
+ };
121
240
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [
122
241
  {
123
242
  label: "Globals", href: `${BASE}/shared`,
124
- trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined })),
243
+ trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
125
244
  },
126
245
  { label: item.title },
127
- ] }) }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", 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: [Object.entries(item.fields).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) => {
128
- setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
129
- setDirty(true);
130
- } }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [_jsxs("div", { className: "sm-card", 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 && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", 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 })] }) }))] })] })] }));
131
- }
132
- function InfoRow({ label, value, mono }) {
133
- 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 })] }));
246
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(SharedFields, { item: item, entries: fieldEntries, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), styleEntries.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), _jsx(SharedFields, { item: item, entries: styleEntries, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
247
+ revealField(formRef.current, path); } })] })), 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: [_jsx(SharedFields, { item: item, entries: fieldEntries, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), styleEntries.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), _jsx(SharedFields, { item: item, entries: styleEntries, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
134
248
  }
@@ -8,6 +8,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
8
8
  import { useCallback, useEffect, useState } from "react";
9
9
  import { ListView } from "./ListView.js";
10
10
  import { sharedRows } from "./shared-list.js";
11
+ import { statusLabel } from "../ui/status-label.js";
11
12
  import { StatusBadge } from "../ui/primitives.js";
12
13
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
13
14
  import { relativeTime } from "../ui/format.js";
@@ -50,7 +51,7 @@ export function SharedList({ ops, registry }) {
50
51
  { label: "Title", width: "minmax(160px,1fr)" },
51
52
  { label: "Kind", width: "90px" },
52
53
  { label: "Updated", width: "minmax(110px,160px)" },
53
- { label: "Status", width: "120px" },
54
+ { label: "Status", width: "170px" },
54
55
  ], empty: items ? "No shared content is registered." : "Loading…", rows: rows.map((row) => ({
55
56
  key: row.name,
56
57
  href: `${BASE}/shared/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`,
@@ -58,7 +59,7 @@ export function SharedList({ ops, registry }) {
58
59
  _jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
59
60
  _jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
60
61
  _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
61
- _jsx(StatusBadge, { status: row.status }, "s"),
62
+ _jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
62
63
  ],
63
64
  })) }));
64
65
  }
@@ -9,8 +9,8 @@ export type EntryRow = {
9
9
  /** The locale row's updatedAt when present, else the node's. */
10
10
  updatedAt: number;
11
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
12
+ /** The entry's title in the selected locale, else the default locale's, else any locale's;
13
+ * "Untitled" when every one is empty (spec 2026-09-14 §4). Display only — a shared
14
14
  * title field reads the same from every locale. */
15
15
  export declare function entryTitle(record: EntryRecord, meta: AdminCollection, registry: AdminRegistry, locale: string): string;
16
16
  /** The locale an "Add <locale>" copies from: the default when the record has it, else its first. */
@@ -1,6 +1,6 @@
1
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
2
+ /** The entry's title in the selected locale, else the default locale's, else any locale's;
3
+ * "Untitled" when every one is empty (spec 2026-09-14 §4). Display only — a shared
4
4
  * title field reads the same from every locale. */
5
5
  export function entryTitle(record, meta, registry, locale) {
6
6
  const key = meta.titleField;
@@ -11,7 +11,7 @@ export function entryTitle(record, meta, registry, locale) {
11
11
  const value = key in row.fields ? row.fields[key] : record.fields[key];
12
12
  return typeof value === "string" && value.length > 0 ? value : undefined;
13
13
  };
14
- return from(locale) ?? from(registry.locales.default) ?? Object.keys(record.locales).map(from).find((t) => t !== undefined) ?? record.id;
14
+ return from(locale) ?? from(registry.locales.default) ?? Object.keys(record.locales).map(from).find((t) => t !== undefined) ?? "Untitled";
15
15
  }
16
16
  /** The locale an "Add <locale>" copies from: the default when the record has it, else its first. */
17
17
  export function entrySourceLocale(record, registry) {
@@ -11,7 +11,7 @@ export function supabaseAdminAuth(input) {
11
11
  return {
12
12
  async verify(token) {
13
13
  const res = await auth.auth.getUser(token);
14
- return res.data.user ? { userId: res.data.user.id } : null;
14
+ return res.data.user ? { userId: res.data.user.id, email: res.data.user.email ?? null } : null;
15
15
  },
16
16
  async isEditor(userId) {
17
17
  const res = await input.db.from("editors").select("user_id").eq("user_id", userId).limit(1);
@@ -0,0 +1,11 @@
1
+ export type ActionMenuItem = {
2
+ label: string;
3
+ run(): void;
4
+ disabled?: boolean;
5
+ danger?: boolean;
6
+ };
7
+ export declare function ActionMenu({ label, items, disabled }: {
8
+ label: string;
9
+ items: ActionMenuItem[];
10
+ disabled?: boolean;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A round three-dot button and its popover of actions (spec 2026-09-13
4
+ // publish flow §2). Items are always rendered and disabled when they do
5
+ // not apply — the layout never shifts and the editor learns what exists.
6
+ // The popover is the Pages list's row menu shape (`.sm-picker`), anchored
7
+ // under the button instead of at the pointer.
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { cx } from "./primitives.js";
10
+ export function ActionMenu({ label, items, disabled }) {
11
+ const [open, setOpen] = useState(false);
12
+ const ref = useRef(null);
13
+ useEffect(() => {
14
+ if (!open)
15
+ return;
16
+ const onDown = (e) => { if (!ref.current?.contains(e.target))
17
+ setOpen(false); };
18
+ const onKey = (e) => { if (e.key === "Escape")
19
+ setOpen(false); };
20
+ document.addEventListener("pointerdown", onDown);
21
+ document.addEventListener("keydown", onKey);
22
+ return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
23
+ }, [open]);
24
+ return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: "sm-dotmenu", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
25
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { PageLocale } from "../../store.ts";
3
+ import type { VersionSummary } from "../ops.ts";
4
+ export type HistoryTag = "Draft" | "Live" | null;
5
+ export type HistoryRow = VersionSummary & {
6
+ tag: HistoryTag;
7
+ };
8
+ export declare const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
9
+ /** The rows for a locale's versions: the unpublished draft is Draft, the
10
+ * row the site serves Live, an older publish carries nothing. */
11
+ export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
12
+ export declare function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }: {
13
+ rows: HistoryRow[];
14
+ /** The current draft's version id: Restore is disabled on it. */
15
+ draftId: string | null;
16
+ /** The main area for the selected version. */
17
+ render(versionId: string): ReactNode;
18
+ /** The selection changed — fired once for the initial row too; the entry screens load the snapshot here. */
19
+ onSelect?(versionId: string): void;
20
+ /** Resolves true when the restore landed; the modal then closes itself. */
21
+ onRestore(versionId: string): Promise<boolean>;
22
+ onClose(): void;
23
+ }): ReactElement;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The History modal (spec 2026-09-13 history §5): the locale's versions on
4
+ // the left — newest first, the unpublished draft tagged Draft and the row
5
+ // the site serves Live — and the selected version on the right, rendered
6
+ // by the screen that opened it (an iframe of the site's draft route for a
7
+ // page, a disabled form for an entry or a shared item). Restore confirms,
8
+ // hands the id to the screen, and closes once the restore landed.
9
+ import { useEffect, useState } from "react";
10
+ import { versionTime } from "./format.js";
11
+ import { Modal } from "./Modal.js";
12
+ import { Button, cx } from "./primitives.js";
13
+ export const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
14
+ /** The rows for a locale's versions: the unpublished draft is Draft, the
15
+ * row the site serves Live, an older publish carries nothing. */
16
+ export function historyRows(versions, row) {
17
+ const live = row.status === "published";
18
+ return versions.map((v) => ({
19
+ ...v,
20
+ tag: v.id === row.draftVersionId && (v.id !== row.publishedVersionId || !live) ? "Draft"
21
+ : v.id === row.publishedVersionId && live ? "Live"
22
+ : null,
23
+ }));
24
+ }
25
+ export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }) {
26
+ const [selected, setSelected] = useState(rows[0]?.id ?? null);
27
+ const [busy, setBusy] = useState(false);
28
+ useEffect(() => { if (selected)
29
+ onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
30
+ const restore = async () => {
31
+ if (!selected || !window.confirm(RESTORE_CONFIRM))
32
+ return;
33
+ setBusy(true);
34
+ const landed = await onRestore(selected);
35
+ setBusy(false);
36
+ if (landed)
37
+ onClose();
38
+ };
39
+ return (_jsx(Modal, { title: "History", width: 1200, height: "85vh", flush: true, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Close" }), _jsx(Button, { disabled: busy || !selected || selected === draftId, onClick: () => { void restore(); }, children: busy ? "Restoring…" : "Restore" })] }), children: _jsxs("div", { className: "sm-history", children: [_jsxs("div", { className: "sm-history__list", role: "listbox", "aria-label": "Versions", children: [rows.length === 0 && _jsx("span", { className: "sm-hint", style: { padding: 12 }, children: "No versions yet." }), rows.map((r) => (_jsxs("button", { type: "button", role: "option", "aria-selected": r.id === selected, className: cx("sm-history__row", r.id === selected && "sm-history__row--active"), onClick: () => setSelected(r.id), children: [_jsx("span", { children: versionTime(r.savedAt) }), r.tag && _jsx("span", { className: cx("sm-history__tag", r.tag === "Live" && "sm-history__tag--live"), children: r.tag })] }, r.id)))] }), _jsx("div", { className: "sm-history__main", children: selected ? render(selected) : null })] }) }));
40
+ }
@@ -1,7 +1,11 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
- export declare function Modal({ title, width, onClose, children, footer }: {
2
+ export declare function Modal({ title, width, height, flush, onClose, children, footer }: {
3
3
  title: string;
4
4
  width?: number;
5
+ /** A fixed panel height (e.g. `"85vh"`) for a modal whose body must fill it; default: the content's. */
6
+ height?: number | string;
7
+ /** Drops the body padding for a modal whose body is a two-pane layout (History). */
8
+ flush?: boolean;
5
9
  onClose(): void;
6
10
  children: ReactNode;
7
11
  footer?: ReactNode;
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // overflow the same way. Static rendering (no document) keeps it inline.
9
9
  import { useEffect, useState } from "react";
10
10
  import { createPortal } from "react-dom";
11
- import { IconButton } from "./primitives.js";
11
+ import { cx, IconButton } from "./primitives.js";
12
12
  function adminRoot() {
13
13
  if (typeof document === "undefined")
14
14
  return null;
15
15
  return document.querySelector(".sm-admin") ?? document.body;
16
16
  }
17
- export function Modal({ title, width = 720, onClose, children, footer }) {
17
+ export function Modal({ title, width = 720, height, flush = false, onClose, children, footer }) {
18
18
  const [root] = useState(adminRoot);
19
19
  useEffect(() => {
20
20
  const onKey = (e) => { if (e.key === "Escape")
@@ -22,6 +22,6 @@ export function Modal({ title, width = 720, onClose, children, footer }) {
22
22
  window.addEventListener("keydown", onKey);
23
23
  return () => window.removeEventListener("keydown", onKey);
24
24
  }, [onClose]);
25
- const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "sm-modal__body", children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
25
+ const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width, ...(height !== undefined ? { height } : {}) }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: cx("sm-modal__body", flush && "sm-modal__body--flush"), children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
26
26
  return root ? createPortal(modal, root) : modal;
27
27
  }
@@ -0,0 +1,31 @@
1
+ import type { LocaleStatus } from "../shell/pages-tree.ts";
2
+ import { type ActionMenuItem } from "./ActionMenu.tsx";
3
+ export type PublishBusy = "publish" | "discard" | "unpublish" | null;
4
+ /** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
5
+ * every collection since 2026-09-14), then the two publish-flow actions — always rendered,
6
+ * disabled when they do not apply. */
7
+ export declare function publishMenu(opts: {
8
+ can: {
9
+ discard: boolean;
10
+ unpublish: boolean;
11
+ };
12
+ onDiscard(): void;
13
+ onUnpublish(): void;
14
+ onHistory?(): void;
15
+ }): ActionMenuItem[];
16
+ export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
17
+ /** The locale's stored state; null while the document loads or before it exists. */
18
+ status: LocaleStatus | null;
19
+ /** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
20
+ pending?: boolean;
21
+ busy?: PublishBusy;
22
+ /** The moment after a publish: the button reads "Published ✓". */
23
+ flash?: boolean;
24
+ /** No document to act on (loading, a new unsaved entry). */
25
+ disabled?: boolean;
26
+ onPublish(): void;
27
+ onDiscard(): void;
28
+ onUnpublish(): void;
29
+ /** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
30
+ onHistory?(): void;
31
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ActionMenu } from "./ActionMenu.js";
4
+ import { Button, StatusBadge } from "./primitives.js";
5
+ import { publishActions, statusLabel } from "./status-label.js";
6
+ const BUSY_WORD = {
7
+ publish: "Publishing…", discard: "Discarding…", unpublish: "Unpublishing…",
8
+ };
9
+ /** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
10
+ * every collection since 2026-09-14), then the two publish-flow actions — always rendered,
11
+ * disabled when they do not apply. */
12
+ export function publishMenu(opts) {
13
+ return [
14
+ ...(opts.onHistory ? [{ label: "History", run: opts.onHistory }] : []),
15
+ { label: "Discard changes", danger: true, disabled: !opts.can.discard, run: opts.onDiscard },
16
+ { label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
17
+ ];
18
+ }
19
+ export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
20
+ const can = publishActions(status, pending);
21
+ const inert = disabled || busy !== null;
22
+ const word = busy ? BUSY_WORD[busy] : flash ? "Published ✓" : "Publish";
23
+ return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
24
+ }
@@ -1 +1,5 @@
1
1
  export declare function relativeTime(ms: number, now?: number): string;
2
+ /** A version's saved time: an absolute date and time — History compares moments, not distances. */
3
+ export declare function versionTime(ms: number): string;
4
+ /** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
5
+ export declare function updatedLine(updatedAt: number, updatedBy: string | null, now?: number): string;
@@ -23,3 +23,12 @@ export function relativeTime(ms, now = Date.now()) {
23
23
  month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }),
24
24
  });
25
25
  }
26
+ /** A version's saved time: an absolute date and time — History compares moments, not distances. */
27
+ export function versionTime(ms) {
28
+ return new Date(ms).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
29
+ }
30
+ /** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
31
+ export function updatedLine(updatedAt, updatedBy, now = Date.now()) {
32
+ const when = relativeTime(updatedAt, now);
33
+ return updatedBy ? `${when} · ${updatedBy}` : when;
34
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
2
2
  import type { SaveState } from "../editor/autosave.ts";
3
+ import type { FieldError } from "../validate.ts";
3
4
  /** The wordmark, inlined: a package can't ship a file into the host app's
4
5
  * /public. Its colour is the --logo-color token (accent in the warm look). */
5
6
  export declare function Logo({ height }: {
@@ -115,10 +116,22 @@ export declare function SectionLabel({ children, action, mono, style }: {
115
116
  export declare function Divider({ bleed }: {
116
117
  bleed?: number;
117
118
  }): import("react").JSX.Element;
119
+ /** The lines under a publish-validation banner: one per field, each focusing its widget
120
+ * when the screen can reveal it (spec 2026-09-14 §2). */
121
+ export declare function FieldErrorList({ fields, onReveal }: {
122
+ fields: FieldError[];
123
+ onReveal?(path: string): void;
124
+ }): import("react").JSX.Element | null;
118
125
  export declare function Banner({ tone, children, onDismiss }: {
119
126
  tone: "ok" | "error" | "warn";
120
127
  children: ReactNode;
121
128
  onDismiss?: () => void;
122
129
  }): import("react").JSX.Element;
123
130
  export declare function cx(...parts: (string | false | null | undefined)[]): string;
131
+ /** One line of a form's info block: a muted label, the value right-aligned and clipped. */
132
+ export declare function InfoRow({ label, value, mono }: {
133
+ label: string;
134
+ value: string;
135
+ mono?: boolean;
136
+ }): import("react").JSX.Element;
124
137
  export {};