smoodly 0.0.15 → 0.0.17

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 (154) hide show
  1. package/README.md +238 -36
  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/custom-section.d.ts +10 -0
  6. package/dist/admin/custom-section.js +10 -0
  7. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  8. package/dist/admin/editor/CanvasOverlay.js +6 -6
  9. package/dist/admin/editor/EditorView.d.ts +2 -0
  10. package/dist/admin/editor/EditorView.js +322 -62
  11. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  12. package/dist/admin/editor/GlobalInspector.js +13 -0
  13. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  14. package/dist/admin/editor/InsertPicker.js +6 -4
  15. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  16. package/dist/admin/editor/InspectorFields.js +22 -0
  17. package/dist/admin/editor/Outline.d.ts +0 -1
  18. package/dist/admin/editor/Outline.js +3 -3
  19. package/dist/admin/editor/PageSettings.js +3 -2
  20. package/dist/admin/editor/global-edit.d.ts +39 -0
  21. package/dist/admin/editor/global-edit.js +124 -0
  22. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  23. package/dist/admin/editor/useGlobalEdit.js +18 -0
  24. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  25. package/dist/admin/forms/FieldWidget.js +38 -11
  26. package/dist/admin/forms/ListField.d.ts +9 -1
  27. package/dist/admin/forms/ListField.js +16 -8
  28. package/dist/admin/forms/RichtextElementCard.js +11 -5
  29. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  30. package/dist/admin/forms/RowsWidget.js +26 -0
  31. package/dist/admin/forms/SpanBar.d.ts +5 -0
  32. package/dist/admin/forms/SpanBar.js +37 -0
  33. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  34. package/dist/admin/forms/richtext-insert.js +3 -3
  35. package/dist/admin/forms/richtext-nodes.js +1 -1
  36. package/dist/admin/forms/tabs.d.ts +20 -8
  37. package/dist/admin/forms/tabs.js +19 -23
  38. package/dist/admin/forms/useRichtextEditor.js +8 -1
  39. package/dist/admin/global-items.d.ts +9 -0
  40. package/dist/admin/{shared-items.js → global-items.js} +29 -14
  41. package/dist/admin/index.d.ts +3 -3
  42. package/dist/admin/index.js +1 -1
  43. package/dist/admin/next/op-handler.js +4 -3
  44. package/dist/admin/ops-impl.d.ts +3 -0
  45. package/dist/admin/ops-impl.js +242 -56
  46. package/dist/admin/ops.d.ts +38 -6
  47. package/dist/admin/serialize.d.ts +18 -7
  48. package/dist/admin/serialize.js +27 -5
  49. package/dist/admin/shell/AdminApp.js +7 -7
  50. package/dist/admin/shell/EntriesList.js +19 -3
  51. package/dist/admin/shell/EntryForm.js +183 -77
  52. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  53. package/dist/admin/shell/GlobalFields.js +19 -0
  54. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  55. package/dist/admin/shell/GlobalForm.js +258 -0
  56. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  57. package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
  58. package/dist/admin/shell/IconRail.js +14 -7
  59. package/dist/admin/shell/PagesList.js +4 -8
  60. package/dist/admin/shell/entries-list.d.ts +2 -2
  61. package/dist/admin/shell/entries-list.js +3 -3
  62. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  63. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  64. package/dist/admin/supabase-auth.js +1 -1
  65. package/dist/admin/tree-ops.d.ts +10 -8
  66. package/dist/admin/tree-ops.js +28 -16
  67. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  68. package/dist/admin/ui/ActionMenu.js +25 -0
  69. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  70. package/dist/admin/ui/HistoryModal.js +40 -0
  71. package/dist/admin/ui/Modal.d.ts +5 -1
  72. package/dist/admin/ui/Modal.js +3 -3
  73. package/dist/admin/ui/PublishControl.d.ts +31 -0
  74. package/dist/admin/ui/PublishControl.js +24 -0
  75. package/dist/admin/ui/format.d.ts +4 -0
  76. package/dist/admin/ui/format.js +9 -0
  77. package/dist/admin/ui/primitives.d.ts +16 -1
  78. package/dist/admin/ui/primitives.js +20 -4
  79. package/dist/admin/ui/status-label.d.ts +10 -0
  80. package/dist/admin/ui/status-label.js +14 -0
  81. package/dist/admin/ui/theme.js +93 -13
  82. package/dist/admin/validate.d.ts +27 -2
  83. package/dist/admin/validate.js +47 -10
  84. package/dist/collections.d.ts +3 -9
  85. package/dist/collections.js +1 -2
  86. package/dist/conditions.d.ts +33 -0
  87. package/dist/conditions.js +79 -0
  88. package/dist/config.d.ts +26 -19
  89. package/dist/config.js +26 -25
  90. package/dist/css.d.ts +7 -0
  91. package/dist/css.js +57 -0
  92. package/dist/custom/CustomSection.d.ts +26 -0
  93. package/dist/custom/CustomSection.js +20 -0
  94. package/dist/custom/css.d.ts +3 -0
  95. package/dist/custom/css.js +28 -0
  96. package/dist/custom/index.d.ts +26 -0
  97. package/dist/custom/index.js +16 -0
  98. package/dist/custom/rows.d.ts +28 -0
  99. package/dist/custom/rows.js +129 -0
  100. package/dist/custom/schema.d.ts +32 -0
  101. package/dist/custom/schema.js +39 -0
  102. package/dist/entry-store.d.ts +42 -17
  103. package/dist/entry-store.js +107 -40
  104. package/dist/fields.d.ts +5 -2
  105. package/dist/fields.js +4 -2
  106. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  107. package/dist/{shared-id.js → global-id.js} +4 -4
  108. package/dist/global.d.ts +57 -0
  109. package/dist/global.js +51 -0
  110. package/dist/index.d.ts +28 -18
  111. package/dist/index.js +10 -5
  112. package/dist/media.js +1 -1
  113. package/dist/next/index.d.ts +1 -1
  114. package/dist/next/page-renderer.d.ts +4 -2
  115. package/dist/next/page-renderer.js +17 -13
  116. package/dist/next/queries.d.ts +6 -6
  117. package/dist/next/queries.js +7 -7
  118. package/dist/page.js +2 -2
  119. package/dist/pairing.d.ts +13 -7
  120. package/dist/pairing.js +4 -4
  121. package/dist/refs.d.ts +2 -2
  122. package/dist/refs.js +4 -4
  123. package/dist/render.d.ts +13 -1
  124. package/dist/render.js +10 -5
  125. package/dist/resolve.d.ts +11 -6
  126. package/dist/resolve.js +22 -12
  127. package/dist/revalidate.d.ts +4 -4
  128. package/dist/revalidate.js +4 -4
  129. package/dist/richtext-render.js +2 -2
  130. package/dist/richtext-walk.d.ts +5 -5
  131. package/dist/richtext-walk.js +4 -4
  132. package/dist/schema.d.ts +15 -10
  133. package/dist/schema.js +19 -7
  134. package/dist/section-wrapper.d.ts +18 -6
  135. package/dist/section-wrapper.js +24 -17
  136. package/dist/site.d.ts +17 -5
  137. package/dist/site.js +36 -12
  138. package/dist/sql-space.js +1 -1
  139. package/dist/sql.js +19 -16
  140. package/dist/store.d.ts +45 -13
  141. package/dist/store.js +88 -12
  142. package/dist/styles.d.ts +27 -0
  143. package/dist/styles.js +60 -0
  144. package/dist/supabase-entry-store.d.ts +11 -6
  145. package/dist/supabase-entry-store.js +115 -39
  146. package/dist/supabase-store.d.ts +13 -6
  147. package/dist/supabase-store.js +90 -20
  148. package/dist/zones.d.ts +9 -7
  149. package/dist/zones.js +5 -5
  150. package/package.json +1 -1
  151. package/dist/admin/shared-items.d.ts +0 -9
  152. package/dist/admin/shell/SharedForm.js +0 -134
  153. package/dist/shared.d.ts +0 -46
  154. package/dist/shared.js +0 -36
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
+ import type { Descriptor } from "../fields.ts";
2
3
  import type { ZonePolicy } from "../zones.ts";
3
4
  export type AdminField = Record<string, unknown> & {
4
5
  type: string;
@@ -14,8 +15,11 @@ export type AdminSection = {
14
15
  icon?: string;
15
16
  description?: string;
16
17
  fields: Record<string, AdminField>;
18
+ settings: Record<string, AdminField>;
19
+ /** Smoodly's styles set after the schema's toggle (spec 2026-09-14 §2); `{}` for `styles: false`. */
17
20
  styles: Record<string, AdminField>;
18
- meta: Record<string, AdminField>;
21
+ /** The custom-CSS box exists for this section: the project flag, unless the schema says `css: false` (spec 2026-09-14 §4). */
22
+ css: boolean;
19
23
  sample?: Record<string, unknown>;
20
24
  };
21
25
  export type AdminCollection = {
@@ -43,18 +47,19 @@ export type AdminPageTemplate = {
43
47
  /** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
44
48
  seo: boolean;
45
49
  };
46
- export type AdminShared = {
50
+ export type AdminGlobal = {
47
51
  name: string;
48
52
  title: string;
49
53
  /** The section a visual item renders through; absent = an object item. */
50
54
  section?: string;
51
- versions: boolean;
52
55
  fields: Record<string, AdminField>;
53
- /** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
56
+ /** The section's settings, styles set and css flag for a visual item; `{}` / `{}` / `false` for an object item. */
57
+ settings: Record<string, AdminField>;
54
58
  styles: Record<string, AdminField>;
59
+ css: boolean;
55
60
  };
56
- /** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts. */
57
- export type AdminElement = AdminSection & {
61
+ /** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts — no styles, no css. */
62
+ export type AdminElement = Omit<AdminSection, "styles" | "css"> & {
58
63
  contexts: string[];
59
64
  };
60
65
  export type AdminRegistry = {
@@ -62,11 +67,15 @@ export type AdminRegistry = {
62
67
  elements: AdminElement[];
63
68
  collections: AdminCollection[];
64
69
  pages: AdminPageTemplate[];
65
- shared: AdminShared[];
70
+ globals: AdminGlobal[];
71
+ /** The custom section (spec 2026-09-14 §6) as one synthetic section: its rows descriptor, the full styles set, the project css flag. */
72
+ custom: AdminSection;
66
73
  locales: {
67
74
  default: string;
68
75
  supported: string[];
69
76
  };
77
+ /** The project's custom-CSS flag (spec 2026-09-14 §4). */
78
+ css: boolean;
70
79
  /** Which slug the site root serves. */
71
80
  homePageSlug: string;
72
81
  /** How deep editors may nest pages; 1 = flat. */
@@ -77,4 +86,6 @@ export type AdminRegistry = {
77
86
  accept: string[];
78
87
  };
79
88
  };
89
+ /** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
90
+ export declare function sectionSettings(config: ResolvedConfig, name: string): Record<string, Descriptor>;
80
91
  export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
@@ -1,6 +1,8 @@
1
1
  import { fixedSlug } from "../page.js";
2
2
  import { collectionDepth, collectionOrder } from "../collections.js";
3
3
  import { perLocaleSegments } from "../paths.js";
4
+ import { styleDescriptors } from "../styles.js";
5
+ import { CUSTOM_NODE_TYPE, customFields, defaultRow } from "../custom/index.js";
4
6
  function serializeDescriptor(d) {
5
7
  const out = { type: d.type };
6
8
  for (const [key, value] of Object.entries(d)) {
@@ -22,6 +24,14 @@ function serializeDescriptor(d) {
22
24
  function serializeMap(map) {
23
25
  return Object.fromEntries(Object.entries(map ?? {}).map(([k, d]) => [k, serializeDescriptor(d)]));
24
26
  }
27
+ /** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
28
+ export function sectionSettings(config, name) {
29
+ return (config.registry.sections ?? []).find((s) => s.schema.name === name)?.schema.settings ?? {};
30
+ }
31
+ /** Whether a section gets the custom-CSS box: the project flag, unless the schema opts out (spec 2026-09-14 §4). */
32
+ function sectionCss(config, name) {
33
+ return config.css && (config.registry.sections ?? []).find((s) => s.schema.name === name)?.schema.css !== false;
34
+ }
25
35
  export function serializeAdminConfig(config) {
26
36
  const perLocale = (v) => perLocaleSegments(v, config.locales.supported);
27
37
  return {
@@ -33,8 +43,9 @@ export function serializeAdminConfig(config) {
33
43
  icon: s.icon,
34
44
  description: s.description,
35
45
  fields: serializeMap(s.fields),
46
+ settings: serializeMap(s.settings),
36
47
  styles: serializeMap(config.resolvedStyles(s.name)),
37
- meta: serializeMap(s.meta),
48
+ css: sectionCss(config, s.name),
38
49
  sample: s.sample,
39
50
  };
40
51
  }),
@@ -47,8 +58,7 @@ export function serializeAdminConfig(config) {
47
58
  description: e.description,
48
59
  contexts: [...(e.contexts ?? [])],
49
60
  fields: serializeMap(e.fields),
50
- styles: serializeMap(e.styles),
51
- meta: serializeMap(e.meta),
61
+ settings: serializeMap(e.settings),
52
62
  sample: e.sample,
53
63
  };
54
64
  }),
@@ -73,14 +83,26 @@ export function serializeAdminConfig(config) {
73
83
  seo: p.meta?.seo === true,
74
84
  };
75
85
  }),
76
- shared: (config.registry.shared ?? []).map((s) => ({
86
+ globals: (config.registry.globals ?? []).map((s) => ({
77
87
  name: s.name,
78
88
  title: s.title,
79
89
  ...(s.section !== undefined ? { section: s.section } : {}),
80
- versions: s.versions === true,
81
90
  fields: serializeMap(s.fields),
91
+ settings: serializeMap(s.section !== undefined ? sectionSettings(config, s.section) : {}),
82
92
  styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
93
+ css: s.section !== undefined ? sectionCss(config, s.section) : false,
83
94
  })),
95
+ custom: {
96
+ name: CUSTOM_NODE_TYPE,
97
+ title: "Custom section",
98
+ description: "Your own rows of columns",
99
+ fields: serializeMap(customFields(config.custom.toolset)),
100
+ settings: {},
101
+ styles: serializeMap(styleDescriptors(config.styles)),
102
+ css: config.css,
103
+ sample: { rows: [defaultRow()] },
104
+ },
105
+ css: config.css,
84
106
  locales: config.locales,
85
107
  homePageSlug: config.homePageSlug,
86
108
  pageDepth: config.pageDepth,
@@ -17,8 +17,8 @@ import { makeClientOps } from "../client-ops.js";
17
17
  import { PagesList } from "./PagesList.js";
18
18
  import { EntriesList } from "./EntriesList.js";
19
19
  import { EntryForm } from "./EntryForm.js";
20
- import { SharedList } from "./SharedList.js";
21
- import { SharedForm } from "./SharedForm.js";
20
+ import { GlobalList } from "./GlobalList.js";
21
+ import { GlobalForm } from "./GlobalForm.js";
22
22
  import { EditorView } from "../editor/EditorView.js";
23
23
  import { LoginView } from "./LoginView.js";
24
24
  import { useAdminSession } from "./session.js";
@@ -48,11 +48,11 @@ 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) });
52
- else if (head === "shared" && !tail)
53
- main = _jsx(SharedList, { ops: ops, registry: registry });
54
- else if (head === "shared" && tail)
55
- main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
51
+ main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: decodeURIComponent(tail) });
52
+ else if (head === "globals" && !tail)
53
+ main = _jsx(GlobalList, { ops: ops, registry: registry });
54
+ else if (head === "globals" && tail)
55
+ main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
56
56
  else if (head === "media")
57
57
  main = _jsx(MediaScreen, {});
58
58
  else
@@ -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
  }
@@ -0,0 +1,24 @@
1
+ import type { AdminGlobal } from "../serialize.ts";
2
+ import type { Group } from "../forms/tabs.ts";
3
+ import { type NodeValues } from "../editor/InspectorFields.tsx";
4
+ export type RefOption = {
5
+ id: string;
6
+ title: string;
7
+ status: string;
8
+ };
9
+ /** The item's value map as the inspector reads it: the reserved keys pulled out into their namespaces. */
10
+ export declare function globalValues(fields: Record<string, unknown>): NodeValues;
11
+ export declare function GlobalFields({ item, groups, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
12
+ item: AdminGlobal;
13
+ groups: Group[];
14
+ /** The merged draft fields; a visual item's settings, styles and css under their reserved keys. */
15
+ fields: Record<string, unknown>;
16
+ errors?: Record<string, string>;
17
+ /** Loaded ref options by target collection. */
18
+ refOptions: Record<string, RefOption[]>;
19
+ multilingual: boolean;
20
+ /** The content field with an active inline session: its richtext widget yields to a note. */
21
+ inlinePath?: string;
22
+ onEditHere?(): void;
23
+ onChange(ns: "fields" | "settings" | "styles" | "css", key: string, value: unknown): void;
24
+ }): import("react").JSX.Element;