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
@@ -77,7 +77,6 @@ export function serializeAdminConfig(config) {
77
77
  name: s.name,
78
78
  title: s.title,
79
79
  ...(s.section !== undefined ? { section: s.section } : {}),
80
- versions: s.versions === true,
81
80
  fields: serializeMap(s.fields),
82
81
  styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
83
82
  })),
@@ -55,6 +55,20 @@ export async function ensureSharedItems(entries, config) {
55
55
  }
56
56
  }
57
57
  }
58
+ // Every shared item is versioned (decided 2026-09-13). A row published before that has
59
+ // no pointers, so its next save would go live: publish it once more here, which snapshots
60
+ // what is live and points both pointers at it. Idempotent — pointed rows are skipped.
61
+ for (const locale of supported) {
62
+ const row = record.locales[locale];
63
+ if (!row || row.status !== "published" || row.publishedVersionId !== null)
64
+ continue;
65
+ try {
66
+ record = await entries.setStatus(item.name, record.id, locale, "published");
67
+ }
68
+ catch (e) {
69
+ console.warn(`smoodly: could not version shared item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
70
+ }
71
+ }
58
72
  out[item.name] = record;
59
73
  }
60
74
  return out;
@@ -48,7 +48,7 @@ export function AdminApp({ registry, op, auth }) {
48
48
  else if (isCollection && !tail)
49
49
  main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
50
50
  else if (isCollection && tail)
51
- main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
51
+ main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: decodeURIComponent(tail) });
52
52
  else if (head === "shared" && !tail)
53
53
  main = _jsx(SharedList, { ops: ops, registry: registry });
54
54
  else if (head === "shared" && tail)
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from "react";
8
8
  import { ListView } from "./ListView.js";
9
9
  import { canReorder, entryRows, entrySourceLocale } from "./entries-list.js";
10
10
  import { Button, StatusBadge } from "../ui/primitives.js";
11
+ import { statusLabel } from "../ui/status-label.js";
11
12
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
12
13
  import { relativeTime } from "../ui/format.js";
13
14
  import { useSortable } from "../ui/useSortable.js";
@@ -75,6 +76,21 @@ export function EntriesList({ ops, registry, collection }) {
75
76
  const open = (id) => {
76
77
  navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
77
78
  };
79
+ /** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
80
+ const createEntry = async () => {
81
+ let result;
82
+ try {
83
+ result = await ops.entries.create(collection, { locale, fields: {} });
84
+ }
85
+ catch {
86
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
87
+ }
88
+ if (!result.ok) {
89
+ window.alert(result.message);
90
+ return;
91
+ }
92
+ open(result.data.id);
93
+ };
78
94
  const addLocale = async (row) => {
79
95
  const record = records?.find((r) => r.id === row.id);
80
96
  if (!record)
@@ -95,10 +111,10 @@ export function EntriesList({ ops, registry, collection }) {
95
111
  }
96
112
  open(row.id);
97
113
  };
98
- return (_jsx(ListView, { title: meta.title, titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => { navigate(`${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`); }, children: "+ New entry" }), columns: [
114
+ return (_jsx(ListView, { title: meta.title, titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => { void createEntry(); }, children: "+ New entry" }), columns: [
99
115
  { label: "Title", width: "minmax(160px,1fr)" },
100
116
  { label: "Updated", width: "minmax(110px,160px)" },
101
- { label: "Status", width: "120px" },
117
+ { label: "Status", width: "170px" },
102
118
  ], empty: records ? "Nothing here yet." : "Loading…", sortable: canReorder(meta) ? { hook: sortable, canDrag: () => true } : undefined, rows: rows.map((row) => {
103
119
  const dim = row.present ? undefined : { opacity: 0.45 };
104
120
  return {
@@ -110,7 +126,7 @@ export function EntriesList({ ops, registry, collection }) {
110
126
  ? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
111
127
  : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
112
128
  row.status
113
- ? _jsx(StatusBadge, { status: row.status }, "c")
129
+ ? _jsx(StatusBadge, { status: statusLabel(row.status) }, "c")
114
130
  : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "c"),
115
131
  ],
116
132
  };
@@ -1,44 +1,73 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The classic field view for ONE locale of an entry (spec 2026-09-06
4
4
  // §7): the fields in schema order, the same FieldWidget renderer as the
5
5
  // editor sidebar, no tabs. In a multilingual project a field that is
6
6
  // NOT .localized() (nor the slug) carries a globe-off mark after its
7
- // name — every locale sees it — instead of the two "Shared across
8
- // languages" / "In <locale>" groups (dropped 2026-09-09). The locale
9
- // switcher loads that locale's merged fields; switching to a locale the
10
- // entry lacks shows the "Add <locale>" pane, which copies the locale on
11
- // screen; "Remove from <locale>" sits in the sidebar behind a confirm.
12
- // Status is per locale and immediate. The safe-delete guard's message
7
+ // name — every locale sees it. The locale switcher loads that locale's
8
+ // merged fields; switching to a locale the entry lacks shows the
9
+ // "Add <locale>" pane, which copies the locale on screen; "Remove from
10
+ // <locale>" sits in the sidebar behind a confirm.
11
+ //
12
+ // Since 2026-09-14 (spec collections draft/publish) the form autosaves
13
+ // like the page editor: no Save button, every edit persists after a
14
+ // debounce, the top bar carries the indicator, View live and the one
15
+ // publish control. A draft may be incomplete; publish validates it and
16
+ // lists the fields in an amber banner. The safe-delete guard's message
13
17
  // renders verbatim — it is product surface.
14
- import { useEffect, useMemo, useState } from "react";
18
+ import { useEffect, useMemo, useRef, useState } from "react";
15
19
  import { FieldWidget } from "../forms/FieldWidget.js";
16
- import { relativeTime } from "../ui/format.js";
20
+ import { revealField } from "../forms/reveal.js";
21
+ import { useDraftAutosave } from "../editor/autosave.js";
22
+ import { relativeTime, updatedLine } from "../ui/format.js";
17
23
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
18
- import { Banner, Breadcrumb, Button, SectionLabel, Segmented, TopBar, UnlocalizedMark } from "../ui/primitives.js";
24
+ import { Banner, Breadcrumb, Button, FieldErrorList, InfoRow, LinkButton, SaveIndicator, SectionLabel, TopBar, UnlocalizedMark, } from "../ui/primitives.js";
25
+ import { PublishControl } from "../ui/PublishControl.js";
26
+ import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
27
+ import { localeStatusOf } from "./pages-tree.js";
19
28
  import { ResizeHandle } from "../ui/ResizeHandle.js";
20
29
  import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
21
30
  import { BASE } from "./base.js";
22
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
23
32
  import { navigate, useLeaveGuard } from "./router.js";
24
- const STATUSES = ["Draft", "Published"];
25
33
  export function EntryForm({ ops, registry, collection, id }) {
26
34
  const meta = registry.collections.find((c) => c.name === collection);
27
- const isNew = id === "new";
28
35
  const multilingual = registry.locales.supported.length > 1;
29
36
  const [locale, setLocale] = useState(() => localeFromUrl(registry));
30
37
  const [record, setRecord] = useState(null);
31
- const [fields, setFields] = useState(isNew ? {} : null);
38
+ const [fields, setFields] = useState(null);
39
+ const [urls, setUrls] = useState({});
32
40
  const [parentLocales, setParentLocales] = useState(registry.locales.supported);
33
41
  const [refOptions, setRefOptions] = useState({});
34
- const [banner, setBanner] = useState(null);
42
+ const [notice, setNotice] = useState(null);
35
43
  const [fieldErrors, setFieldErrors] = useState({});
36
44
  const [adding, setAdding] = useState(false);
37
45
  const [reloadKey, setReloadKey] = useState(0);
46
+ /** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
38
47
  const [dirty, setDirty] = useState(false);
39
- // A nav click, the Back arrow, a list row: the router asks before
40
- // leaving unsaved changes; reload and close ask through beforeunload.
41
- useLeaveGuard(dirty);
48
+ /** Any edit on this screen yet: the indicator stays hidden until the first one (§6). */
49
+ const [touched, setTouched] = useState(false);
50
+ const [busy, setBusy] = useState(null);
51
+ /** The moment after a publish: the button reads "Published ✓". */
52
+ const [published, setPublished] = useState(false);
53
+ const [history, setHistory] = useState(null);
54
+ const [snapshot, setSnapshot] = useState(null);
55
+ const formRef = useRef(null);
56
+ const fieldsRef = useRef(fields);
57
+ fieldsRef.current = fields;
58
+ // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
59
+ const { saveState, mark, flush } = useDraftAutosave({
60
+ read: () => fieldsRef.current,
61
+ persist: async (value) => {
62
+ const r = await ops.entries.save(collection, id, locale, value);
63
+ if (r.ok)
64
+ setRecord(r.data);
65
+ return r;
66
+ },
67
+ onSaved: () => setDirty(false),
68
+ });
69
+ // Reload and close ask only while a save is pending or in flight.
70
+ useLeaveGuard(dirty || saveState !== "saved");
42
71
  const refTargets = useMemo(() => {
43
72
  const out = {};
44
73
  for (const [key, d] of Object.entries(meta?.fields ?? {})) {
@@ -50,26 +79,26 @@ export function EntryForm({ ops, registry, collection, id }) {
50
79
  const localeKeys = useMemo(() => (meta ? localizedFieldKeys(meta) : []), [meta]);
51
80
  // Load the locale's merged fields; an absent locale comes back with fields: null.
52
81
  useEffect(() => {
53
- if (isNew)
54
- return;
55
82
  let cancelled = false;
56
83
  setFields(null);
57
84
  setFieldErrors({});
85
+ setDirty(false);
86
+ setTouched(false);
58
87
  ops.entries.get(collection, id, locale).then((r) => {
59
88
  if (cancelled)
60
89
  return;
61
90
  if (r.ok) {
62
91
  setRecord(r.data.record);
63
92
  setFields(r.data.fields);
93
+ setUrls(r.data.urls);
64
94
  setParentLocales(r.data.parentLocales);
65
- setDirty(false);
66
95
  }
67
96
  else
68
- setBanner({ tone: "error", text: r.message });
97
+ setNotice({ tone: "error", text: r.message });
69
98
  }).catch(() => { if (!cancelled)
70
- setBanner({ tone: "error", text: "Couldn't reach the server." }); });
99
+ setNotice({ tone: "error", text: "Couldn't reach the server." }); });
71
100
  return () => { cancelled = true; };
72
- }, [ops, collection, id, isNew, locale, reloadKey]);
101
+ }, [ops, collection, id, locale, reloadKey]);
73
102
  // Ref options in the locale being edited: a target absent there is not offered.
74
103
  useEffect(() => {
75
104
  let cancelled = false;
@@ -93,64 +122,145 @@ export function EntryForm({ ops, registry, collection, id }) {
93
122
  }, [ops, refTargets, registry, locale]);
94
123
  if (!meta)
95
124
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
96
- const applyResult = (r, okText) => {
97
- if (r.ok) {
98
- setBanner({ tone: "ok", text: okText });
99
- setFieldErrors({});
100
- return true;
101
- }
102
- setBanner({ tone: "error", text: r.message });
103
- setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
104
- return false;
125
+ const row = record?.locales[locale];
126
+ const status = row ? localeStatusOf(row) : null;
127
+ /** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
128
+ const fail = (r) => {
129
+ if (r.code === "validation" && r.fields && r.fields.length > 0) {
130
+ setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
131
+ setNotice({ tone: "warn", text: r.message, fields: r.fields });
132
+ }
133
+ else
134
+ setNotice({ tone: "error", text: r.message });
105
135
  };
106
- const save = async () => {
107
- if (!fields)
108
- return;
109
- if (isNew) {
110
- const r = await ops.entries.create(collection, { locale, fields });
111
- if (applyResult(r, "Created.") && r.ok) {
112
- setDirty(false);
113
- // force: the create just landed, but React has not flushed setDirty(false) yet.
114
- navigate(`${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`, { force: true });
115
- }
136
+ /** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
137
+ const publish = async () => {
138
+ setNotice(null);
139
+ setBusy("publish");
140
+ if (!(await flush())) {
141
+ setBusy(null);
142
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — publish cancelled." });
143
+ return;
116
144
  }
117
- else {
118
- const r = await ops.entries.save(collection, id, locale, fields);
119
- if (applyResult(r, "Saved.") && r.ok) {
120
- setRecord(r.data);
121
- setDirty(false);
122
- }
145
+ let r;
146
+ try {
147
+ r = await ops.entries.setStatus(collection, id, locale, "published");
123
148
  }
124
- };
125
- const row = record?.locales[locale];
126
- const status = row?.status ?? "draft";
127
- const setPublished = async (next) => {
128
- if (next === status)
149
+ catch {
150
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
151
+ }
152
+ setBusy(null);
153
+ if (!r.ok) {
154
+ fail(r);
129
155
  return;
130
- const r = await ops.entries.setStatus(collection, id, locale, next);
131
- if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
156
+ }
157
+ setRecord(r.data);
158
+ setFieldErrors({});
159
+ setPublished(true);
160
+ setTimeout(() => setPublished(false), 1600);
161
+ };
162
+ const unpublish = async () => {
163
+ setNotice(null);
164
+ setBusy("unpublish");
165
+ let r;
166
+ try {
167
+ r = await ops.entries.setStatus(collection, id, locale, "draft");
168
+ }
169
+ catch {
170
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
171
+ }
172
+ setBusy(null);
173
+ if (r.ok)
132
174
  setRecord(r.data);
175
+ else
176
+ fail(r);
177
+ };
178
+ /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
179
+ const discard = async () => {
180
+ if (!window.confirm("Discard your changes and restore the published version?"))
181
+ return;
182
+ setNotice(null);
183
+ setBusy("discard");
184
+ if (!(await flush())) {
185
+ setBusy(null);
186
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — discard cancelled." });
187
+ return;
188
+ }
189
+ let r;
190
+ try {
191
+ r = await ops.entries.discardDraft(collection, id, locale);
192
+ }
193
+ catch {
194
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
195
+ }
196
+ setBusy(null);
197
+ if (!r.ok) {
198
+ fail(r);
199
+ return;
200
+ }
201
+ setRecord(r.data);
202
+ setReloadKey((k) => k + 1);
203
+ setNotice({ tone: "ok", text: "Changes discarded." });
204
+ };
205
+ /** History (spec 2026-09-13 history §5): flush first so the list's draft row is current. */
206
+ const openHistory = async () => {
207
+ if (!row)
208
+ return;
209
+ if (!(await flush())) {
210
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — history not opened." });
211
+ return;
212
+ }
213
+ const r = await ops.entries.listVersions(collection, id, locale);
214
+ if (!r.ok) {
215
+ fail(r);
216
+ return;
217
+ }
218
+ setSnapshot(null);
219
+ setHistory(historyRows(r.data, row));
220
+ };
221
+ const loadSnapshot = async (versionId) => {
222
+ const r = await ops.entries.getVersion(collection, id, versionId);
223
+ if (r.ok)
224
+ setSnapshot({ id: r.data.id, fields: r.data.fields });
225
+ else
226
+ fail(r);
227
+ };
228
+ const restore = async (versionId) => {
229
+ const r = await ops.entries.restore(collection, id, locale, versionId);
230
+ if (!r.ok) {
231
+ fail(r);
232
+ return false;
233
+ }
234
+ setRecord(r.data);
235
+ setReloadKey((k) => k + 1);
236
+ setNotice({ tone: "ok", text: "Version restored." });
237
+ return true;
133
238
  };
134
239
  const goToLocale = (next) => {
135
240
  if (next === locale)
136
241
  return;
137
242
  writeLocaleToUrl(next);
138
- setBanner(null);
243
+ setNotice(null);
139
244
  setLocale(next);
140
245
  };
141
- const switchLocale = (next) => {
246
+ /** A switch flushes the pending edit first: nothing is ever unsaved past the debounce. */
247
+ const switchLocale = async (next) => {
142
248
  if (next === locale)
143
249
  return;
144
- if (dirty && !window.confirm("Discard unsaved changes?"))
250
+ if (!(await flush())) {
251
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — stayed on this locale." });
145
252
  return;
253
+ }
146
254
  goToLocale(next);
147
255
  };
148
256
  /** Add a locale, copying the locale on screen (or the record's source locale when this one is absent). */
149
257
  const doAddLocale = async (target) => {
150
258
  if (!record || adding)
151
259
  return;
152
- if (dirty && !window.confirm("Discard unsaved changes?"))
260
+ if (!(await flush())) {
261
+ setNotice({ tone: "error", text: "Couldn't save your latest changes — locale not added." });
153
262
  return;
263
+ }
154
264
  const from = record.locales[locale] ? locale : entrySourceLocale(record, registry);
155
265
  setAdding(true);
156
266
  let result;
@@ -162,7 +272,7 @@ export function EntryForm({ ops, registry, collection, id }) {
162
272
  }
163
273
  setAdding(false);
164
274
  if (!result.ok) {
165
- setBanner({ tone: "error", text: result.message });
275
+ fail(result);
166
276
  return;
167
277
  }
168
278
  setRecord(result.data);
@@ -177,8 +287,6 @@ export function EntryForm({ ops, registry, collection, id }) {
177
287
  const remaining = Object.keys(record.locales).filter((l) => l !== locale);
178
288
  if (remaining.length === 0)
179
289
  return;
180
- if (dirty && !window.confirm("Discard unsaved changes?"))
181
- return;
182
290
  if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
183
291
  return;
184
292
  let result;
@@ -189,7 +297,7 @@ export function EntryForm({ ops, registry, collection, id }) {
189
297
  result = { ok: false, code: "internal", message: "Couldn't reach the server." };
190
298
  }
191
299
  if (!result.ok) {
192
- setBanner({ tone: "error", text: result.message });
300
+ fail(result);
193
301
  return;
194
302
  }
195
303
  setRecord(result.data);
@@ -202,28 +310,26 @@ export function EntryForm({ ops, registry, collection, id }) {
202
310
  if (r.ok)
203
311
  navigate(`${BASE}/${collection}`, { force: true });
204
312
  else
205
- setBanner({ tone: "error", text: r.message });
313
+ fail(r);
206
314
  };
207
- const heading = isNew ? "New entry" : record ? entryTitle(record, meta, registry, locale) : id;
315
+ const heading = record ? entryTitle(record, meta, registry, locale) : "…";
208
316
  // The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
209
317
  const base = meta.path?.[locale];
210
318
  const slugPrefix = base ? `/${base}/` : undefined;
211
319
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
212
- const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
213
- const absent = !isNew && record !== null && fields === null && !record.locales[locale];
320
+ const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
321
+ setFields((f) => ({ ...(f ?? {}), [key]: value }));
322
+ setDirty(true);
323
+ setTouched(true);
324
+ mark();
325
+ } }, key));
326
+ const absent = record !== null && fields === null && !record.locales[locale];
214
327
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [
215
328
  {
216
329
  label: meta.title, href: `${BASE}/${collection}`,
217
- trailing: multilingual && (isNew
218
- ? _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })
219
- : _jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })),
330
+ trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
220
331
  },
221
332
  { label: heading },
222
- ] }) }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: isNew ? "Create" : "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 }), 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 ? (Object.entries(meta.fields).map(widget)) : !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: 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 && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", 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 && (_jsx(_Fragment, { children: _jsxs("div", { className: "sm-card", 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" })] }) }))] })] })] }));
223
- }
224
- function InfoRow({ label, value, mono }) {
225
- 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: {
226
- color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
227
- overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
228
- }, children: value })] }));
333
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(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 ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _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)
334
+ revealField(formRef.current, path); } })] })), 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: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", 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" })] }))] })] })] }));
229
335
  }
@@ -18,12 +18,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  // ↑/↓ move between entries, a pointer down outside closes. A child being
19
19
  // active highlights its parent tile and the entry inside the menu.
20
20
  import { useEffect, useId, useRef, useState } from "react";
21
- import { ChevronDown, Image, LayoutPanelTop, Library, Settings2, Square } from "lucide-react";
21
+ import { ChevronDown, Globe, Image, LibraryBig, PanelTop, Settings } from "lucide-react";
22
22
  import { ThemeToggle } from "../ui/primitives.js";
23
23
  import { BASE } from "./base.js";
24
24
  import { RouteLink } from "./RouteLink.js";
25
25
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
26
- const ICON = { size: 18, strokeWidth: 1.75, "aria-hidden": true };
26
+ const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
27
27
  /** The app icon, inlined: a package can't ship a file into the host app's
28
28
  * /public. Brand blue on white in both themes, so no --logo-filter. */
29
29
  function AppIcon() {
@@ -70,5 +70,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
70
70
  const collectionActive = collections.some((c) => c.name === active);
71
71
  const name = email?.split("@")[0] ?? "Editor";
72
72
  const initials = name.slice(0, 2).toUpperCase();
73
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(Square, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(Library, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), registry.shared.length > 0 && link("shared", "Globals", _jsx(LayoutPanelTop, { ...ICON }), `${BASE}/shared`), link("settings", "Settings", _jsx(Settings2, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
73
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(LibraryBig, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), registry.shared.length > 0 && link("shared", "Globals", _jsx(Globe, { ...ICON }), `${BASE}/shared`), link("settings", "Settings", _jsx(Settings, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
74
74
  }
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
11
11
  import { ListView } from "./ListView.js";
12
12
  import { canDragRow, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
13
13
  import { Button, StatusBadge } from "../ui/primitives.js";
14
+ import { statusLabel } from "../ui/status-label.js";
14
15
  import { LocalePicker } from "../ui/LocaleSwitcher.js";
15
16
  import { useSortable } from "../ui/useSortable.js";
16
17
  import { BASE } from "./base.js";
@@ -207,7 +208,7 @@ export function PagesList({ ops, registry }) {
207
208
  { label: "Title", width: "minmax(160px,1fr)" },
208
209
  { label: "Path", width: "minmax(140px,260px)" },
209
210
  { label: "Template", width: "minmax(100px,160px)" },
210
- { label: "Status", width: "110px" },
211
+ { label: "Status", width: "170px" },
211
212
  { label: "", width: "32px" },
212
213
  ], empty: pages ? "No pages yet." : "Loading…", sortable: { hook: sortable, canDrag: (i) => canDragRow(rows[i]) }, rows: rows.map((row, i) => {
213
214
  // An absent node is still a row (structure is shared) but reads as dimmed.
@@ -225,7 +226,7 @@ export function PagesList({ ops, registry }) {
225
226
  : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
226
227
  _jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.templateTitle }, "k"),
227
228
  row.status
228
- ? _jsx(StatusBadge, { status: row.status }, "s")
229
+ ? _jsx(StatusBadge, { status: statusLabel(row.status) }, "s")
229
230
  : _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
230
231
  _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"),
231
232
  ],
@@ -0,0 +1,21 @@
1
+ import type { AdminShared } from "../serialize.ts";
2
+ import type { TabEntry } from "../forms/tabs.ts";
3
+ export type RefOption = {
4
+ id: string;
5
+ title: string;
6
+ status: string;
7
+ };
8
+ export declare function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
9
+ item: AdminShared;
10
+ entries: TabEntry[];
11
+ /** The merged draft fields; a visual item's styles under `$styles`. */
12
+ fields: Record<string, unknown>;
13
+ errors?: Record<string, string>;
14
+ /** Loaded ref options by target collection. */
15
+ refOptions: Record<string, RefOption[]>;
16
+ multilingual: boolean;
17
+ /** The content field with an active inline session: its richtext widget yields to a note. */
18
+ inlinePath?: string;
19
+ onEditHere?(): void;
20
+ onChange(ns: "fields" | "styles", key: string, value: unknown): void;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { STYLES_KEY } from "../../shared.js";
4
+ import { FieldWidget } from "../forms/FieldWidget.js";
5
+ import { UnlocalizedMark } from "../ui/primitives.js";
6
+ export function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }) {
7
+ const styles = (fields[STYLES_KEY] ?? {});
8
+ return (_jsx(_Fragment, { children: entries.map(({ key, ns, descriptor }) => {
9
+ if (ns === "meta")
10
+ return null; // a shared item has no meta namespace
11
+ const target = (descriptor.type === "ref" || descriptor.type === "refList") && typeof descriptor.target === "string" ? descriptor.target : null;
12
+ const localized = ns === "fields" && item.fields[key]?.localized === true;
13
+ return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: ns === "fields" ? fields[key] : styles[key], errors: errors, refOptions: target ? refOptions[target] ?? [] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
14
+ }) }));
15
+ }