smoodly 0.0.24 → 0.0.26

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 (148) hide show
  1. package/README.md +214 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +100 -22
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/autosave.d.ts +4 -1
  12. package/dist/admin/editor/autosave.js +24 -1
  13. package/dist/admin/editor/global-edit.d.ts +5 -1
  14. package/dist/admin/editor/global-edit.js +15 -5
  15. package/dist/admin/editor/locale-url.d.ts +7 -4
  16. package/dist/admin/editor/locale-url.js +14 -7
  17. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  18. package/dist/admin/editor/useGlobalEdit.js +1 -1
  19. package/dist/admin/editor/usePageLocale.js +9 -1
  20. package/dist/admin/forms/CropEditor.d.ts +4 -2
  21. package/dist/admin/forms/CropEditor.js +6 -3
  22. package/dist/admin/forms/DatePicker.d.ts +9 -0
  23. package/dist/admin/forms/DatePicker.js +133 -0
  24. package/dist/admin/forms/FieldWidget.js +11 -5
  25. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  26. package/dist/admin/forms/ImagePreview.js +11 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  28. package/dist/admin/forms/MediaWidget.js +13 -13
  29. package/dist/admin/forms/RichtextElementCard.js +1 -1
  30. package/dist/admin/forms/RichtextImageCard.js +14 -9
  31. package/dist/admin/forms/date-picker.d.ts +44 -0
  32. package/dist/admin/forms/date-picker.js +117 -0
  33. package/dist/admin/forms/elements-context.d.ts +3 -0
  34. package/dist/admin/forms/elements-context.js +7 -2
  35. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  36. package/dist/admin/forms/time-zone-context.js +15 -0
  37. package/dist/admin/forms/useRichtextEditor.js +10 -1
  38. package/dist/admin/next/bridge.d.ts +2 -1
  39. package/dist/admin/next/bridge.js +35 -5
  40. package/dist/admin/next/create-admin.d.ts +4 -0
  41. package/dist/admin/next/create-admin.js +3 -1
  42. package/dist/admin/next/index.d.ts +1 -0
  43. package/dist/admin/ops-impl.js +74 -24
  44. package/dist/admin/ops.d.ts +31 -12
  45. package/dist/admin/serialize.d.ts +2 -0
  46. package/dist/admin/serialize.js +1 -0
  47. package/dist/admin/shell/AdminApp.d.ts +3 -1
  48. package/dist/admin/shell/AdminApp.js +42 -2
  49. package/dist/admin/shell/EntriesList.js +17 -22
  50. package/dist/admin/shell/EntryForm.js +91 -13
  51. package/dist/admin/shell/GlobalForm.js +68 -9
  52. package/dist/admin/shell/GlobalList.js +10 -20
  53. package/dist/admin/shell/IconRail.js +14 -5
  54. package/dist/admin/shell/InviteModal.js +9 -5
  55. package/dist/admin/shell/ListView.js +2 -2
  56. package/dist/admin/shell/MediaScreen.js +11 -1
  57. package/dist/admin/shell/MembersPage.js +33 -15
  58. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  59. package/dist/admin/shell/PageDialogs.js +38 -0
  60. package/dist/admin/shell/PagesList.js +101 -74
  61. package/dist/admin/shell/RouteLink.js +1 -1
  62. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  63. package/dist/admin/shell/SchemaNotice.js +4 -0
  64. package/dist/admin/shell/SettingsView.js +11 -26
  65. package/dist/admin/shell/live-url.d.ts +4 -3
  66. package/dist/admin/shell/live-url.js +12 -3
  67. package/dist/admin/shell/locale-memory.d.ts +8 -0
  68. package/dist/admin/shell/locale-memory.js +23 -0
  69. package/dist/admin/shell/members-list.d.ts +2 -0
  70. package/dist/admin/shell/members-list.js +5 -0
  71. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  72. package/dist/admin/shell/page-dialogs.js +19 -0
  73. package/dist/admin/shell/presence-context.d.ts +9 -0
  74. package/dist/admin/shell/presence-context.js +35 -0
  75. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  76. package/dist/admin/shell/presence-realtime.js +22 -0
  77. package/dist/admin/shell/presence.d.ts +51 -0
  78. package/dist/admin/shell/presence.js +90 -0
  79. package/dist/admin/shell/router.d.ts +10 -7
  80. package/dist/admin/shell/router.js +18 -9
  81. package/dist/admin/shell/settings.d.ts +0 -6
  82. package/dist/admin/shell/settings.js +4 -5
  83. package/dist/admin/shell/stale.d.ts +28 -0
  84. package/dist/admin/shell/stale.js +17 -0
  85. package/dist/admin/tree-ops.js +3 -0
  86. package/dist/admin/ui/ActionMenu.js +26 -6
  87. package/dist/admin/ui/Combobox.d.ts +5 -0
  88. package/dist/admin/ui/Combobox.js +1 -1
  89. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  90. package/dist/admin/ui/ConfirmDialog.js +30 -0
  91. package/dist/admin/ui/Dialog.d.ts +13 -0
  92. package/dist/admin/ui/Dialog.js +54 -0
  93. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  94. package/dist/admin/ui/HistoryModal.js +7 -2
  95. package/dist/admin/ui/LockIcon.d.ts +4 -0
  96. package/dist/admin/ui/LockIcon.js +12 -0
  97. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  98. package/dist/admin/ui/PresenceLine.js +11 -0
  99. package/dist/admin/ui/ResizeHandle.js +4 -4
  100. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  101. package/dist/admin/ui/StaleBanner.js +9 -0
  102. package/dist/admin/ui/confirm.d.ts +35 -0
  103. package/dist/admin/ui/confirm.js +57 -0
  104. package/dist/admin/ui/primitives.d.ts +19 -4
  105. package/dist/admin/ui/primitives.js +12 -5
  106. package/dist/admin/ui/resize.d.ts +7 -4
  107. package/dist/admin/ui/resize.js +7 -4
  108. package/dist/admin/ui/theme.d.ts +1 -1
  109. package/dist/admin/ui/theme.js +373 -204
  110. package/dist/admin/validate.d.ts +4 -0
  111. package/dist/admin/validate.js +28 -0
  112. package/dist/cli/load-collections.d.ts +2 -0
  113. package/dist/cli/load-collections.js +26 -0
  114. package/dist/cli/main.d.ts +13 -0
  115. package/dist/cli/main.js +62 -0
  116. package/dist/cli/migrate.d.ts +23 -0
  117. package/dist/cli/migrate.js +64 -0
  118. package/dist/config.d.ts +4 -0
  119. package/dist/config.js +9 -0
  120. package/dist/draft-token.d.ts +31 -0
  121. package/dist/draft-token.js +41 -0
  122. package/dist/entry-store.d.ts +11 -8
  123. package/dist/entry-store.js +24 -10
  124. package/dist/env.d.ts +9 -0
  125. package/dist/env.js +19 -0
  126. package/dist/fields.d.ts +2 -0
  127. package/dist/fields.js +2 -0
  128. package/dist/index.d.ts +6 -3
  129. package/dist/index.js +4 -2
  130. package/dist/marks.d.ts +3 -0
  131. package/dist/marks.js +3 -0
  132. package/dist/migrations.d.ts +37 -0
  133. package/dist/migrations.js +73 -0
  134. package/dist/schema-version.d.ts +17 -0
  135. package/dist/schema-version.js +32 -0
  136. package/dist/sql-space.js +17 -0
  137. package/dist/sql.js +21 -0
  138. package/dist/store.d.ts +14 -8
  139. package/dist/store.js +30 -8
  140. package/dist/supabase-entry-store.d.ts +15 -5
  141. package/dist/supabase-entry-store.js +87 -22
  142. package/dist/supabase-store.d.ts +18 -5
  143. package/dist/supabase-store.js +83 -20
  144. package/dist/time-zone.d.ts +6 -0
  145. package/dist/time-zone.js +27 -0
  146. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  147. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  148. package/package.json +13 -3
@@ -1,11 +1,25 @@
1
1
  import type { ZonePolicy } from "../../zones.ts";
2
2
  import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
3
+ /** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
3
4
  export type PickerAt = {
4
5
  zone: string;
5
6
  index: number;
6
7
  x: number;
7
8
  y: number;
8
9
  };
10
+ export type PickerPlace = {
11
+ left: number;
12
+ top?: number;
13
+ bottom?: number;
14
+ maxHeight?: number;
15
+ };
16
+ /** Where the panel sits, given its natural height and the viewport: under the
17
+ * anchor when it fits there, above it when that side has more room, and never
18
+ * taller than the room on its side (the panel scrolls). Pure. */
19
+ export declare function placePicker(at: PickerAt, height: number, viewport: {
20
+ width: number;
21
+ height: number;
22
+ }): PickerPlace;
9
23
  export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
10
24
  at: PickerAt;
11
25
  policy: ZonePolicy | undefined;
@@ -1,20 +1,50 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The insert picker a "+" opens: the sections the zone's policy allows,
4
+ // then the Custom section where the zone allows it (spec 2026-09-14 §6),
5
+ // then the global items that can be placed there, or a note that the
6
+ // zone takes nothing. Clicking outside closes it. The panel hangs under
7
+ // the "+" when it fits, opens upward when there is more room above, and
8
+ // scrolls inside the room it has (2026-09-25) — a long section list near
9
+ // the bottom of the window used to run off screen.
10
+ import { useLayoutEffect, useRef, useState } from "react";
3
11
  import { allowedSections, allowedGlobals } from "../tree-ops.js";
4
12
  import { allowsCustom } from "../custom-section.js";
5
13
  import { SectionLabel } from "../ui/primitives.js";
6
- /** Keep the panel on screen; a static render (no window) leaves it where it was asked. */
7
- function clamp(at) {
14
+ const PICKER_W = 300;
15
+ const MARGIN = 12;
16
+ /** From `y` back up to the panel's bottom edge when it opens upward: the 12px the
17
+ * anchor sits under the button's centre, and the same 12px above it. */
18
+ const ABOVE_GAP = 24;
19
+ /** Where the panel sits, given its natural height and the viewport: under the
20
+ * anchor when it fits there, above it when that side has more room, and never
21
+ * taller than the room on its side (the panel scrolls). Pure. */
22
+ export function placePicker(at, height, viewport) {
23
+ const left = Math.max(MARGIN, Math.min(at.x, viewport.width - PICKER_W - MARGIN));
24
+ const below = viewport.height - MARGIN - at.y;
25
+ const above = at.y - ABOVE_GAP - MARGIN;
26
+ if (height <= below || below >= above)
27
+ return { left, top: at.y, maxHeight: Math.max(below, 0) };
28
+ return { left, bottom: viewport.height - (at.y - ABOVE_GAP), maxHeight: above };
29
+ }
30
+ /** The first paint, before the panel is measured; a static render (no window) leaves it where it was asked. */
31
+ function firstPlace(at) {
8
32
  if (typeof window === "undefined")
9
33
  return { left: at.x, top: at.y };
10
- return {
11
- left: Math.max(12, Math.min(at.x, window.innerWidth - 320)),
12
- top: Math.min(at.y, Math.max(12, window.innerHeight - 240)),
13
- };
34
+ return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
14
35
  }
15
36
  export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
16
37
  const sections = allowedSections(policy, registry.sections);
17
38
  const globals = allowedGlobals(policy, registry.globals, globalIds);
18
39
  const custom = allowsCustom(policy);
19
- return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", ...clamp(at) }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Global" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
40
+ const panel = useRef(null);
41
+ const [place, setPlace] = useState(() => firstPlace(at));
42
+ // Measure the panel's full height (scrollHeight ignores a max-height already set), then place it.
43
+ useLayoutEffect(() => {
44
+ const el = panel.current;
45
+ if (!el)
46
+ return;
47
+ setPlace(placePicker(at, el.scrollHeight + 2, { width: window.innerWidth, height: window.innerHeight }));
48
+ }, [at]);
49
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
20
50
  }
@@ -17,6 +17,6 @@ export function InspectorFields({ groups, values, render }) {
17
17
  const entries = visibleEntries(g.entries, values);
18
18
  if (entries.length === 0)
19
19
  return null;
20
- return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { mono: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
20
+ return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
21
21
  }) }));
22
22
  }
@@ -10,6 +10,6 @@ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAdd
10
10
  count: locked ? 0 : nodes.length,
11
11
  onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
12
  });
13
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
15
15
  }
@@ -32,7 +32,7 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
32
32
  return;
33
33
  }
34
34
  // A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
35
- if (key === "slug" && !confirmLiveUrl({ status: row.status, from: row.slug, to: next })) {
35
+ if (key === "slug" && !(await confirmLiveUrl({ status: row.status, from: row.slug, to: next }, { name: row.title, path }))) {
36
36
  setDraft((d) => ({ ...d, slug: row.slug }));
37
37
  return;
38
38
  }
@@ -11,5 +11,5 @@ function Counter({ n, max }) {
11
11
  export function SeoSettings({ meta, onPatch }) {
12
12
  const title = str(meta.title);
13
13
  const description = str(meta.description);
14
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(Field, { label: "Social image", hint: "1200\u00D7630 recommended", asDiv: true, children: _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v) }) }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
14
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v), label: "Social image", hint: "1200\u00D7630 recommended" }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
15
15
  }
@@ -1,5 +1,5 @@
1
1
  import type { OpResult } from "../ops.ts";
2
- export type SaveState = "saved" | "saving" | "error";
2
+ export type SaveState = "saved" | "saving" | "error" | "stale";
3
3
  export type DraftAutosave = {
4
4
  /** Record a local change; a debounced save follows. */
5
5
  mark(): void;
@@ -11,6 +11,8 @@ export type DraftAutosave = {
11
11
  subscribe(listener: () => void): () => void;
12
12
  /** Cancel pending timers; call on unmount. */
13
13
  dispose(): void;
14
+ /** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
15
+ reset(): void;
14
16
  };
15
17
  export declare function createDraftAutosave<T>(opts: {
16
18
  read: () => T | null;
@@ -28,4 +30,5 @@ export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
28
30
  saveState: SaveState;
29
31
  mark: () => void;
30
32
  flush: () => Promise<boolean>;
33
+ reset: () => void;
31
34
  };
@@ -46,6 +46,12 @@ export function createDraftAutosave(opts) {
46
46
  opts.onSaved?.();
47
47
  return true;
48
48
  }
49
+ else if (result.code === "conflict" && result.stale) {
50
+ // The guard refused (spec 2026-09-26 concurrency §4): terminal, never retried —
51
+ // the view freezes and only a reload (reset) brings the controller back.
52
+ setState("stale");
53
+ return false;
54
+ }
49
55
  else {
50
56
  // Keep local state; retry with a doubling, capped delay.
51
57
  setState("error");
@@ -62,12 +68,16 @@ export function createDraftAutosave(opts) {
62
68
  return () => { listeners.delete(listener); };
63
69
  },
64
70
  mark() {
71
+ if (state === "stale")
72
+ return;
65
73
  revision += 1;
66
74
  if (debounceTimer)
67
75
  clearTimeout(debounceTimer);
68
76
  debounceTimer = setTimeout(() => { debounceTimer = null; void save(); }, debounceMs);
69
77
  },
70
78
  flush() {
79
+ if (state === "stale")
80
+ return Promise.resolve(false);
71
81
  if (revision === savedRevision)
72
82
  return Promise.resolve(true);
73
83
  if (debounceTimer) {
@@ -86,6 +96,19 @@ export function createDraftAutosave(opts) {
86
96
  retryTimer = null;
87
97
  }
88
98
  },
99
+ reset() {
100
+ if (debounceTimer) {
101
+ clearTimeout(debounceTimer);
102
+ debounceTimer = null;
103
+ }
104
+ if (retryTimer) {
105
+ clearTimeout(retryTimer);
106
+ retryTimer = null;
107
+ }
108
+ savedRevision = revision;
109
+ retryDelay = retryBaseMs;
110
+ setState("saved");
111
+ },
89
112
  };
90
113
  }
91
114
  /** React glue: one controller per mount, always calling the latest
@@ -104,5 +127,5 @@ export function useDraftAutosave(opts) {
104
127
  }));
105
128
  useEffect(() => () => controller.dispose(), [controller]);
106
129
  const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
107
- return { saveState, mark: controller.mark, flush: controller.flush };
130
+ return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
108
131
  }
@@ -1,6 +1,7 @@
1
1
  import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
2
2
  import type { AdminOps, OpResult } from "../ops.ts";
3
3
  import { type SaveState } from "./autosave.ts";
4
+ import { type StaleInfo } from "../shell/stale.ts";
4
5
  export type GlobalEditItem = {
5
6
  name: string;
6
7
  locale: string;
@@ -15,6 +16,8 @@ export type GlobalEditState = {
15
16
  items: Record<string, GlobalEditItem>;
16
17
  saveState: SaveState;
17
18
  loadError: string | null;
19
+ /** The guard's refusal (spec 2026-09-26 concurrency §4): set once, cleared only by reset(). */
20
+ stale: StaleInfo | null;
18
21
  };
19
22
  export type GlobalEdit = {
20
23
  getState(): GlobalEditState;
@@ -32,7 +35,8 @@ export type GlobalEdit = {
32
35
  };
33
36
  export declare function createGlobalEdit(opts: {
34
37
  ops: Pick<AdminOps, "globals">;
35
- onSaved?: () => void;
38
+ /** Per saved item, with its name (presence announces each; spec 2026-09-26 concurrency §5). */
39
+ onSaved?: (name: string) => void;
36
40
  debounceMs?: number;
37
41
  retryBaseMs?: number;
38
42
  retryCapMs?: number;
@@ -1,8 +1,11 @@
1
1
  import { setAtPath } from "../field-paths.js";
2
2
  import { createDraftAutosave } from "./autosave.js";
3
+ import { draftToken } from "../../draft-token.js";
4
+ import { isStale, staleInfoOf } from "../shell/stale.js";
3
5
  export function createGlobalEdit(opts) {
4
6
  let items = {};
5
7
  let loadError = null;
8
+ let stale = null;
6
9
  let generation = 0;
7
10
  const loading = new Set();
8
11
  const rev = new Map();
@@ -20,7 +23,7 @@ export function createGlobalEdit(opts) {
20
23
  const at = rev.get(item.name) ?? 0;
21
24
  let r;
22
25
  try {
23
- r = await opts.ops.globals.save(item.name, item.locale, item.fields);
26
+ r = await opts.ops.globals.save(item.name, item.locale, item.fields, draftToken(item.record.locales[item.locale]));
24
27
  }
25
28
  catch {
26
29
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -30,8 +33,11 @@ export function createGlobalEdit(opts) {
30
33
  savedRev.set(item.name, at);
31
34
  if (current)
32
35
  items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
36
+ opts.onSaved?.(item.name);
33
37
  }
34
38
  else {
39
+ if (isStale(r))
40
+ stale = staleInfoOf(r);
35
41
  failed ?? (failed = r);
36
42
  if (current) {
37
43
  items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
@@ -41,13 +47,12 @@ export function createGlobalEdit(opts) {
41
47
  emit();
42
48
  return failed ?? { ok: true, data: null };
43
49
  },
44
- onSaved: opts.onSaved,
45
50
  debounceMs: opts.debounceMs,
46
51
  retryBaseMs: opts.retryBaseMs,
47
52
  retryCapMs: opts.retryCapMs,
48
53
  });
49
54
  autosave.subscribe(emit);
50
- const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
55
+ const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError, stale }));
51
56
  return {
52
57
  getState,
53
58
  subscribe(listener) {
@@ -98,7 +103,7 @@ export function createGlobalEdit(opts) {
98
103
  }
99
104
  let r;
100
105
  try {
101
- r = await opts.ops.globals.setStatus(name, item.locale, status);
106
+ r = await opts.ops.globals.setStatus(name, item.locale, status, draftToken(item.record.locales[item.locale]));
102
107
  }
103
108
  catch {
104
109
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -107,6 +112,10 @@ export function createGlobalEdit(opts) {
107
112
  items = { ...items, [name]: { ...items[name], record: r.data } };
108
113
  emit();
109
114
  }
115
+ else if (isStale(r)) {
116
+ stale = staleInfoOf(r);
117
+ emit();
118
+ }
110
119
  return r;
111
120
  },
112
121
  reset() {
@@ -116,7 +125,8 @@ export function createGlobalEdit(opts) {
116
125
  savedRev.clear();
117
126
  loading.clear();
118
127
  loadError = null;
119
- autosave.dispose();
128
+ stale = null;
129
+ autosave.reset(); // timers cleared, and a stale controller comes back for the new canvas
120
130
  emit();
121
131
  },
122
132
  dispose: autosave.dispose,
@@ -1,12 +1,15 @@
1
1
  import type { AdminRegistry } from "../serialize.ts";
2
2
  type Locales = AdminRegistry["locales"];
3
- /** A supported ?locale from the query string, else the default. */
4
- export declare function localeFromSearch(search: string, locales: Locales): string;
3
+ /** A supported ?locale from the query string, else the fallback (the default). */
4
+ export declare function localeFromSearch(search: string, locales: Locales, fallback?: string): string;
5
5
  /** The same URL pointed at a locale, keeping the rest of the query
6
6
  * string — a bare `?locale=…` would drop every other param. */
7
7
  export declare function withLocale(href: string, next: string): string;
8
+ /** The URL's locale, else the remembered one, else the default — and
9
+ * remembered from here on. Read once per screen, not per render. */
8
10
  export declare function localeFromUrl(registry: AdminRegistry): string;
9
- /** Point the current URL at a locale in place. A replace, so the router
10
- * never asks the leave guard — the form's own confirm covers a switch. */
11
+ /** Point the current URL at a locale in place, remembering it. A replace,
12
+ * so the router never asks the leave guard — the form's own confirm
13
+ * covers a switch. */
11
14
  export declare function writeLocaleToUrl(next: string): void;
12
15
  export {};
@@ -1,8 +1,9 @@
1
1
  import { navigate } from "../shell/router.js";
2
- /** A supported ?locale from the query string, else the default. */
3
- export function localeFromSearch(search, locales) {
2
+ import { rememberLocale, rememberedLocale } from "../shell/locale-memory.js";
3
+ /** A supported ?locale from the query string, else the fallback (the default). */
4
+ export function localeFromSearch(search, locales, fallback = locales.default) {
4
5
  const l = new URLSearchParams(search).get("locale");
5
- return l && locales.supported.includes(l) ? l : locales.default;
6
+ return l && locales.supported.includes(l) ? l : fallback;
6
7
  }
7
8
  /** The same URL pointed at a locale, keeping the rest of the query
8
9
  * string — a bare `?locale=…` would drop every other param. */
@@ -11,20 +12,26 @@ export function withLocale(href, next) {
11
12
  u.searchParams.set("locale", next);
12
13
  return u.toString();
13
14
  }
15
+ /** The URL's locale, else the remembered one, else the default — and
16
+ * remembered from here on. Read once per screen, not per render. */
14
17
  export function localeFromUrl(registry) {
15
18
  try {
16
- return localeFromSearch(window.location.search, registry.locales);
19
+ const locale = localeFromSearch(window.location.search, registry.locales, rememberedLocale(registry.locales));
20
+ rememberLocale(locale);
21
+ return locale;
17
22
  }
18
23
  catch {
19
24
  // no window — the default is fine
20
25
  return registry.locales.default;
21
26
  }
22
27
  }
23
- /** Point the current URL at a locale in place. A replace, so the router
24
- * never asks the leave guard — the form's own confirm covers a switch. */
28
+ /** Point the current URL at a locale in place, remembering it. A replace,
29
+ * so the router never asks the leave guard — the form's own confirm
30
+ * covers a switch. */
25
31
  export function writeLocaleToUrl(next) {
32
+ rememberLocale(next);
26
33
  try {
27
- navigate(withLocale(window.location.href, next), { replace: true });
34
+ void navigate(withLocale(window.location.href, next), { replace: true });
28
35
  }
29
36
  catch {
30
37
  // no window (or no history) — the URL is cosmetic here
@@ -2,5 +2,5 @@ import type { AdminOps } from "../ops.ts";
2
2
  import { type GlobalEdit, type GlobalEditState } from "./global-edit.ts";
3
3
  export declare function useGlobalEdit({ ops, onSaved }: {
4
4
  ops: Pick<AdminOps, "globals">;
5
- onSaved?: () => void;
5
+ onSaved?: (name: string) => void;
6
6
  }): GlobalEditState & Pick<GlobalEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -7,7 +7,7 @@ import { createGlobalEdit } from "./global-edit.js";
7
7
  export function useGlobalEdit({ ops, onSaved }) {
8
8
  const latest = useRef(onSaved);
9
9
  latest.current = onSaved;
10
- const [controller] = useState(() => createGlobalEdit({ ops, onSaved: () => latest.current?.() }));
10
+ const [controller] = useState(() => createGlobalEdit({ ops, onSaved: (name) => latest.current?.(name) }));
11
11
  useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
12
12
  const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
13
13
  return {
@@ -5,6 +5,7 @@
5
5
  import { useState } from "react";
6
6
  import { sourceLocaleOf } from "../shell/pages-tree.js";
7
7
  import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
8
+ import { confirm } from "../ui/confirm.js";
8
9
  export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
9
10
  const [locale, setLocale] = useState(() => localeFromUrl(registry));
10
11
  const [adding, setAdding] = useState(false);
@@ -59,7 +60,14 @@ export function usePageLocale({ registry, ops, id, record, setRecord, flush, fai
59
60
  const remaining = Object.keys(record.locales).filter((l) => l !== locale);
60
61
  if (remaining.length === 0)
61
62
  return;
62
- if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
63
+ const ok = await confirm({
64
+ title: `Remove this page from ${locale}?`,
65
+ body: `Its ${locale} content and history are deleted. Other languages are not affected.`,
66
+ confirmLabel: `Remove from ${locale}`,
67
+ destructive: true,
68
+ subject: { name: record.locales[locale]?.title ?? id },
69
+ });
70
+ if (!ok)
63
71
  return;
64
72
  // Let a pending save land before the row goes, so no save races the
65
73
  // delete — and if it cannot land, stop: a retry mid-flight would
@@ -1,10 +1,12 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { AssetRecord, ImageValue } from "../../media.ts";
3
3
  import type { AdminField } from "../serialize.ts";
4
- export declare function CropEditor({ asset, descriptor, value, onSave, onClose }: {
4
+ export declare function CropEditor({ asset, descriptor, value, caption, onSave, onClose }: {
5
5
  asset: AssetRecord;
6
6
  descriptor: AdminField;
7
7
  value: ImageValue;
8
- onSave(next: ImageValue): void;
8
+ /** Given, a Caption field is shown and its text handed back beside the value. */
9
+ caption?: string;
10
+ onSave(next: ImageValue, caption?: string): void;
9
11
  onClose(): void;
10
12
  }): ReactElement;
@@ -5,6 +5,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  // switch when several), a draggable hotspot, the per-use alt override
6
6
  // and a live preview at the chosen aspect. Pointer deltas become image
7
7
  // fractions through the stage's width; the math is in crop-editor.ts.
8
+ // A richtext image card passes its `caption` too (2026-09-25): the card
9
+ // shows no inputs, so the modal is where both texts are edited.
8
10
  import { useRef, useState } from "react";
9
11
  import { parseAspect } from "../../media.js";
10
12
  import { frameFor } from "../../image/crop-math.js";
@@ -12,7 +14,7 @@ import { Button, Field, Segmented } from "../ui/primitives.js";
12
14
  import { Modal } from "../ui/Modal.js";
13
15
  import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
14
16
  const pct = (n) => `${n * 100}%`;
15
- export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
17
+ export function CropEditor({ asset, descriptor, value, caption, onSave, onClose }) {
16
18
  const declared = descriptor.aspect;
17
19
  const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
18
20
  const width = asset.width ?? 1, height = asset.height ?? 1;
@@ -21,6 +23,7 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
21
23
  const aspect = chosen ? parseAspect(chosen) : undefined;
22
24
  const [state, setState] = useState(() => initialState(value, aspect, imageAspect));
23
25
  const [alt, setAlt] = useState(value.alt ?? "");
26
+ const [cap, setCap] = useState(caption ?? "");
24
27
  const stage = useRef(null);
25
28
  const drag = useRef(null);
26
29
  const pick = (next) => { setChosen(next); setState((s) => relock(s, parseAspect(next), imageAspect)); };
@@ -56,6 +59,6 @@ export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
56
59
  ...value, crop, hotspot,
57
60
  ...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
58
61
  ...(options.length > 1 && chosen ? { aspect: chosen } : { aspect: undefined }),
59
- });
60
- return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) })] })] }) }));
62
+ }, caption === undefined ? undefined : cap);
63
+ return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) }), caption !== undefined && (_jsx(Field, { label: "Caption", hint: "Shown under the image.", children: _jsx("input", { className: "sm-input", value: cap, onChange: (e) => setCap(e.target.value) }) }))] })] }) }));
61
64
  }
@@ -0,0 +1,9 @@
1
+ export declare function DatePicker({ kind, value, onChange, label, initialOpen }: {
2
+ kind: "date" | "datetime";
3
+ value: unknown;
4
+ onChange(value: unknown): void;
5
+ /** Accessible name of the text box; "Date" when absent. */
6
+ label?: string;
7
+ /** Start open: static-markup tests. */
8
+ initialOpen?: boolean;
9
+ }): import("react").JSX.Element;