@elytracms/studio 0.0.42 → 0.0.44

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 (229) hide show
  1. package/dist/components/app-views/studio-config.d.ts +12 -0
  2. package/dist/components/app-views/studio-config.js.map +1 -1
  3. package/dist/components/assets/asset-detail.js +1 -1
  4. package/dist/components/assets/asset-detail.js.map +1 -1
  5. package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
  6. package/dist/components/assets/assets-collection-view-source.js +42 -51
  7. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  8. package/dist/components/assets/assets-module.js +19 -6
  9. package/dist/components/assets/assets-module.js.map +1 -1
  10. package/dist/components/assets/assets-store.d.ts +2 -1
  11. package/dist/components/assets/assets-store.js +4 -8
  12. package/dist/components/assets/assets-store.js.map +1 -1
  13. package/dist/components/assets/assets-view-preference.d.ts +0 -8
  14. package/dist/components/assets/assets-view-preference.js +12 -13
  15. package/dist/components/assets/assets-view-preference.js.map +1 -1
  16. package/dist/components/auth-context.d.ts +2 -1
  17. package/dist/components/auth-context.js +6 -8
  18. package/dist/components/auth-context.js.map +1 -1
  19. package/dist/components/builder/builder-store.d.ts +2 -2
  20. package/dist/components/builder/builder-store.js +4 -8
  21. package/dist/components/builder/builder-store.js.map +1 -1
  22. package/dist/components/cms/bulk-actions.js +2 -1
  23. package/dist/components/cms/bulk-actions.js.map +1 -1
  24. package/dist/components/cms/cell-edit.js +2 -1
  25. package/dist/components/cms/cell-edit.js.map +1 -1
  26. package/dist/components/cms/cms-composition-validators.d.ts +25 -0
  27. package/dist/components/cms/cms-composition-validators.js +75 -0
  28. package/dist/components/cms/cms-composition-validators.js.map +1 -0
  29. package/dist/components/cms/cms-labels.d.ts +15 -0
  30. package/dist/components/cms/cms-labels.js +27 -0
  31. package/dist/components/cms/cms-labels.js.map +1 -0
  32. package/dist/components/cms/cms-store.d.ts +23 -10
  33. package/dist/components/cms/cms-store.js +87 -165
  34. package/dist/components/cms/cms-store.js.map +1 -1
  35. package/dist/components/cms/collection-table.js +1 -1
  36. package/dist/components/cms/collection-table.js.map +1 -1
  37. package/dist/components/cms/detail-editor.js +111 -348
  38. package/dist/components/cms/detail-editor.js.map +1 -1
  39. package/dist/components/cms/discard-draft.d.ts +18 -0
  40. package/dist/components/cms/discard-draft.js +18 -0
  41. package/dist/components/cms/discard-draft.js.map +1 -1
  42. package/dist/components/cms/document-editor-context.d.ts +2 -1
  43. package/dist/components/cms/document-editor-context.js +4 -9
  44. package/dist/components/cms/document-editor-context.js.map +1 -1
  45. package/dist/components/cms/document-ref-utils.d.ts +33 -0
  46. package/dist/components/cms/document-ref-utils.js +47 -0
  47. package/dist/components/cms/document-ref-utils.js.map +1 -0
  48. package/dist/components/cms/document-staging.d.ts +8 -3
  49. package/dist/components/cms/document-staging.js +81 -21
  50. package/dist/components/cms/document-staging.js.map +1 -1
  51. package/dist/components/cms/field-control.js +1 -1
  52. package/dist/components/cms/field-control.js.map +1 -1
  53. package/dist/components/cms/issues-pane.js +1 -1
  54. package/dist/components/cms/issues-pane.js.map +1 -1
  55. package/dist/components/cms/rich-text/block-commands.d.ts +36 -0
  56. package/dist/components/cms/rich-text/block-commands.js +47 -0
  57. package/dist/components/cms/rich-text/block-commands.js.map +1 -0
  58. package/dist/components/cms/rich-text/slash-command.js +25 -8
  59. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  60. package/dist/components/cms/rich-text/toolbar.js +3 -2
  61. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  62. package/dist/components/cms/use-compare-selection.d.ts +27 -0
  63. package/dist/components/cms/use-compare-selection.js +29 -0
  64. package/dist/components/cms/use-compare-selection.js.map +1 -0
  65. package/dist/components/cms/use-discard-controller.d.ts +44 -0
  66. package/dist/components/cms/use-discard-controller.js +51 -0
  67. package/dist/components/cms/use-discard-controller.js.map +1 -0
  68. package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
  69. package/dist/components/cms/use-embed-inspector-host.js +17 -0
  70. package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
  71. package/dist/components/cms/use-field-focus.d.ts +38 -0
  72. package/dist/components/cms/use-field-focus.js +70 -0
  73. package/dist/components/cms/use-field-focus.js.map +1 -0
  74. package/dist/components/cms/use-live-url.d.ts +41 -0
  75. package/dist/components/cms/use-live-url.js +55 -0
  76. package/dist/components/cms/use-live-url.js.map +1 -0
  77. package/dist/components/cms/use-publish-controller.d.ts +49 -0
  78. package/dist/components/cms/use-publish-controller.js +102 -0
  79. package/dist/components/cms/use-publish-controller.js.map +1 -0
  80. package/dist/components/cms/use-published-perspective.d.ts +67 -0
  81. package/dist/components/cms/use-published-perspective.js +67 -0
  82. package/dist/components/cms/use-published-perspective.js.map +1 -0
  83. package/dist/components/cms/use-restore-version.d.ts +57 -0
  84. package/dist/components/cms/use-restore-version.js +66 -0
  85. package/dist/components/cms/use-restore-version.js.map +1 -0
  86. package/dist/components/cms/use-save-mode.d.ts +35 -0
  87. package/dist/components/cms/use-save-mode.js +54 -0
  88. package/dist/components/cms/use-save-mode.js.map +1 -0
  89. package/dist/components/cms/version-compare.js +14 -17
  90. package/dist/components/cms/version-compare.js.map +1 -1
  91. package/dist/components/cms/versions-model.d.ts +31 -0
  92. package/dist/components/cms/versions-model.js +30 -0
  93. package/dist/components/cms/versions-model.js.map +1 -1
  94. package/dist/components/cms/versions-pane.js +21 -19
  95. package/dist/components/cms/versions-pane.js.map +1 -1
  96. package/dist/components/cms/view-bar.js +9 -6
  97. package/dist/components/cms/view-bar.js.map +1 -1
  98. package/dist/components/collab/collab-context.d.ts +2 -1
  99. package/dist/components/collab/collab-context.js +4 -8
  100. package/dist/components/collab/collab-context.js.map +1 -1
  101. package/dist/components/collection-view/collection-view-context.d.ts +9 -1
  102. package/dist/components/collection-view/collection-view-context.js +4 -9
  103. package/dist/components/collection-view/collection-view-context.js.map +1 -1
  104. package/dist/components/components/component-detail.js +2 -5
  105. package/dist/components/components/component-detail.js.map +1 -1
  106. package/dist/components/components/component-preview.js +8 -4
  107. package/dist/components/components/component-preview.js.map +1 -1
  108. package/dist/components/components/registry-browser.js +24 -5
  109. package/dist/components/components/registry-browser.js.map +1 -1
  110. package/dist/components/components/transfer-dialog.d.ts +21 -6
  111. package/dist/components/components/transfer-dialog.js +120 -31
  112. package/dist/components/components/transfer-dialog.js.map +1 -1
  113. package/dist/components/components/transfer.d.ts +74 -8
  114. package/dist/components/components/transfer.js +87 -2
  115. package/dist/components/components/transfer.js.map +1 -1
  116. package/dist/components/delivery-health-banner.d.ts +12 -6
  117. package/dist/components/delivery-health-banner.js +27 -29
  118. package/dist/components/delivery-health-banner.js.map +1 -1
  119. package/dist/components/dev-login-badge.d.ts +0 -2
  120. package/dist/components/dev-login-badge.js +1 -3
  121. package/dist/components/dev-login-badge.js.map +1 -1
  122. package/dist/components/playground-badge.d.ts +0 -2
  123. package/dist/components/playground-badge.js +0 -2
  124. package/dist/components/playground-badge.js.map +1 -1
  125. package/dist/components/project-list.js +2 -6
  126. package/dist/components/project-list.js.map +1 -1
  127. package/dist/components/project-session.d.ts +6 -2
  128. package/dist/components/project-session.js +32 -83
  129. package/dist/components/project-session.js.map +1 -1
  130. package/dist/components/project-switcher.js +2 -6
  131. package/dist/components/project-switcher.js.map +1 -1
  132. package/dist/components/rail-store-status.d.ts +7 -5
  133. package/dist/components/rail-store-status.js +12 -9
  134. package/dist/components/rail-store-status.js.map +1 -1
  135. package/dist/components/rail-view-source.d.ts +111 -0
  136. package/dist/components/rail-view-source.js +139 -0
  137. package/dist/components/rail-view-source.js.map +1 -0
  138. package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
  139. package/dist/components/redirects/redirects-collection-view-source.js +42 -70
  140. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  141. package/dist/components/redirects/redirects-store.d.ts +2 -1
  142. package/dist/components/redirects/redirects-store.js +4 -8
  143. package/dist/components/redirects/redirects-store.js.map +1 -1
  144. package/dist/components/section-state.d.ts +12 -3
  145. package/dist/components/section-state.js +8 -4
  146. package/dist/components/section-state.js.map +1 -1
  147. package/dist/components/studio-context.d.ts +2 -1
  148. package/dist/components/studio-context.js +4 -8
  149. package/dist/components/studio-context.js.map +1 -1
  150. package/dist/components/studio-shell.js +1 -1
  151. package/dist/components/studio-shell.js.map +1 -1
  152. package/dist/components/theme-context.d.ts +2 -1
  153. package/dist/components/theme-context.js +4 -8
  154. package/dist/components/theme-context.js.map +1 -1
  155. package/dist/components/use-hydration-phase.d.ts +65 -0
  156. package/dist/components/use-hydration-phase.js +64 -0
  157. package/dist/components/use-hydration-phase.js.map +1 -0
  158. package/dist/components/users/users-collection-view-source.d.ts +13 -1
  159. package/dist/components/users/users-collection-view-source.js +60 -79
  160. package/dist/components/users/users-collection-view-source.js.map +1 -1
  161. package/dist/components/users/users-store.d.ts +2 -1
  162. package/dist/components/users/users-store.js +4 -8
  163. package/dist/components/users/users-store.js.map +1 -1
  164. package/dist/components/workspace-projects.d.ts +14 -0
  165. package/dist/components/workspace-projects.js +21 -0
  166. package/dist/components/workspace-projects.js.map +1 -1
  167. package/dist/lib/ai/anthropic-provider.d.ts +29 -0
  168. package/dist/lib/ai/anthropic-provider.js +59 -0
  169. package/dist/lib/ai/anthropic-provider.js.map +1 -0
  170. package/dist/lib/ai/apply-content.d.ts +9 -0
  171. package/dist/lib/ai/apply-content.js +170 -0
  172. package/dist/lib/ai/apply-content.js.map +1 -0
  173. package/dist/lib/ai/config.d.ts +58 -0
  174. package/dist/lib/ai/config.js +134 -0
  175. package/dist/lib/ai/config.js.map +1 -0
  176. package/dist/lib/ai/fake-provider.d.ts +34 -0
  177. package/dist/lib/ai/fake-provider.js +31 -0
  178. package/dist/lib/ai/fake-provider.js.map +1 -0
  179. package/dist/lib/ai/index.d.ts +22 -0
  180. package/dist/lib/ai/index.js +23 -0
  181. package/dist/lib/ai/index.js.map +1 -0
  182. package/dist/lib/ai/knowledge.d.ts +39 -0
  183. package/dist/lib/ai/knowledge.js +60 -0
  184. package/dist/lib/ai/knowledge.js.map +1 -0
  185. package/dist/lib/ai/provenance.d.ts +20 -0
  186. package/dist/lib/ai/provenance.js +15 -0
  187. package/dist/lib/ai/provenance.js.map +1 -0
  188. package/dist/lib/ai/route.d.ts +24 -0
  189. package/dist/lib/ai/route.js +90 -0
  190. package/dist/lib/ai/route.js.map +1 -0
  191. package/dist/lib/ai/shape.d.ts +35 -0
  192. package/dist/lib/ai/shape.js +113 -0
  193. package/dist/lib/ai/shape.js.map +1 -0
  194. package/dist/lib/ai/types.d.ts +227 -0
  195. package/dist/lib/ai/types.js +2 -0
  196. package/dist/lib/ai/types.js.map +1 -0
  197. package/dist/lib/data/data-store.d.ts +2 -1
  198. package/dist/lib/data/data-store.js +4 -8
  199. package/dist/lib/data/data-store.js.map +1 -1
  200. package/dist/lib/delivery/instant-publish.d.ts +9 -13
  201. package/dist/lib/delivery/instant-publish.js +0 -7
  202. package/dist/lib/delivery/instant-publish.js.map +1 -1
  203. package/dist/lib/diff-view-preference.js +4 -4
  204. package/dist/lib/diff-view-preference.js.map +1 -1
  205. package/dist/lib/i18n/messages.en.js +5 -2
  206. package/dist/lib/i18n/messages.en.js.map +1 -1
  207. package/dist/lib/local-preference.d.ts +37 -0
  208. package/dist/lib/local-preference.js +36 -0
  209. package/dist/lib/local-preference.js.map +1 -1
  210. package/dist/lib/persistence/convex-adapter.d.ts +3 -1
  211. package/dist/lib/persistence/convex-adapter.js +20 -0
  212. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  213. package/dist/lib/rail.js +7 -6
  214. package/dist/lib/rail.js.map +1 -1
  215. package/dist/lib/required-context.d.ts +24 -0
  216. package/dist/lib/required-context.js +28 -0
  217. package/dist/lib/required-context.js.map +1 -0
  218. package/dist/lib/theme.js +11 -4
  219. package/dist/lib/theme.js.map +1 -1
  220. package/dist/lib/workspace/index.d.ts +9 -0
  221. package/dist/lib/workspace/index.js +11 -0
  222. package/dist/lib/workspace/index.js.map +1 -1
  223. package/dist/ui/index.d.ts +1 -0
  224. package/dist/ui/index.js +1 -0
  225. package/dist/ui/index.js.map +1 -1
  226. package/dist/ui/segmented-control.d.ts +46 -0
  227. package/dist/ui/segmented-control.js +13 -0
  228. package/dist/ui/segmented-control.js.map +1 -0
  229. package/package.json +8 -2
@@ -1,24 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
3
3
  import { Check, ChevronDown, CircleAlert, CircleCheck, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
4
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
- import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, clearHiddenValues, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
4
+ import { useCallback, useMemo, useState } from 'react';
5
+ import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, } from '@elytracms/core/cms-core';
6
6
  import { blockRootsOf } from '@elytracms/core/project-graph';
7
7
  import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, CollapsibleSection, Field, Heading, Row, Spacer, Stack, Text, toast, } from '@elytracms/studio/ui';
8
8
  import { getStudioRegistry } from '@elytracms/studio/lib/registry';
9
9
  import { collectEmbedRefs, resolveReuseCapability } from '../builder/reuse';
10
10
  import { cn } from '@elytracms/studio/lib/utils';
11
- import { useOperationCache } from '@elytracms/studio/lib/query';
12
11
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
13
- import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, setStudioLocale, } from '@elytracms/studio/lib/locale';
14
- import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
12
+ import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, } from '@elytracms/studio/lib/locale';
15
13
  import { FallbackChip } from './cell';
16
14
  import { FieldConnectionControl } from './field-connection';
17
15
  import { FieldControl } from './field-control';
18
16
  // EC-322: the lazy-wrapped page builder — the heavy canvas subtree code-splits out
19
17
  // of the detail-view critical path and downloads only when a `blocks` field opens.
20
18
  import { CompositionFieldEditor } from '../builder/composition-field-editor-lazy';
21
- import { fieldLabel } from './cms-store';
19
+ import { fieldLabel } from './cms-labels';
22
20
  import { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source';
23
21
  import { CollectionViewProvider, useCollectionView } from '../collection-view/collection-view-context';
24
22
  import { resolveFieldAccess } from '@elytracms/studio/lib/collection-view/collection-view-source';
@@ -32,8 +30,6 @@ import { VersionsPane } from './versions-pane';
32
30
  // EC-322: the lazy-wrapped compare view — its diff UI code-splits off the detail-view
33
31
  // critical path and loads only when the Compare tab opens.
34
32
  import { VersionCompare } from './version-compare-lazy';
35
- import { useDocumentVersionsQuery } from './use-document-versions';
36
- import { discardDraftStatus } from './discard-draft';
37
33
  import { IssuesPane } from './issues-pane';
38
34
  import { DetailsPane } from './details-pane';
39
35
  import { DocumentUrlPath } from './document-url-path';
@@ -42,8 +38,18 @@ import { DocumentUrlPath } from './document-url-path';
42
38
  import { EmbedInspectorProvider } from './rich-text/embed-inspector-context';
43
39
  import { EmbedInspector } from './rich-text/embed-inspector';
44
40
  import { useSetBreadcrumb } from '../breadcrumb-context';
45
- import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
46
- import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
41
+ import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
42
+ // EC-325 — the controller hooks EditorBody orchestrates (each independently
43
+ // unit-tested at its pure core); the shell threads shared draft/ref/replaceDraft
44
+ // state across their boundaries.
45
+ import { usePublishedPerspective } from './use-published-perspective';
46
+ import { useSaveMode } from './use-save-mode';
47
+ import { useDiscardController } from './use-discard-controller';
48
+ import { usePublishController, publishGate } from './use-publish-controller';
49
+ import { useFieldFocus } from './use-field-focus';
50
+ import { useCompareSelection } from './use-compare-selection';
51
+ import { useLiveUrl } from './use-live-url';
52
+ import { useEmbedInspectorHost } from './use-embed-inspector-host';
47
53
  export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
48
54
  // AD-19: the edit shell is a portable surface over a CollectionViewSource, just
49
55
  // like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
@@ -114,99 +120,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
114
120
  // Stable so the document-editor context value (EC-224) doesn't churn each render.
115
121
  const ref = useMemo(() => ({ collection: collection.id, id: doc.id }), [collection.id, doc.id]);
116
122
  const confirmTargets = useMemo(() => [{ collection: collection.id, id: doc.id }], [collection.id, doc.id]);
117
- // EC-226: the draft/published VIEW perspective. Draft = the editable working
118
- // draft; Published = the pinned published snapshot, rendered read-only. Only
119
- // meaningful when the document is published.
120
- const published = source.publishing?.isPublished(ref) ?? false;
121
- const [perspective, setPerspective] = useState('draft');
122
- const [publishedSnapshot, setPublishedSnapshot] = useState({ status: 'loading' });
123
- // Snap back to Draft if the document is unpublished while viewing Published.
124
- useEffect(() => {
125
- if (!published)
126
- setPerspective('draft');
127
- }, [published]);
128
- // Load the pinned published snapshot when viewing Published (fresh each switch,
129
- // so it reflects the current pin after a re-publish). `loadDocumentVersions` is
130
- // a stable callback, so this only re-runs on a perspective/ref change.
131
- const loadVersions = source.versioning?.load;
132
- useEffect(() => {
133
- if (perspective !== 'published')
134
- return;
135
- if (!loadVersions)
136
- return;
137
- let cancelled = false;
138
- setPublishedSnapshot({ status: 'loading' });
139
- void (async () => {
140
- const view = await loadVersions(ref);
141
- if (cancelled)
142
- return;
143
- const pinned = view.publishedVersion !== undefined
144
- ? view.versions.find((v) => v.version === view.publishedVersion)?.snapshot
145
- : undefined;
146
- setPublishedSnapshot(pinned ? { status: 'ready', doc: pinned } : { status: 'missing' });
147
- })();
148
- return () => {
149
- cancelled = true;
150
- };
151
- }, [perspective, ref, loadVersions]);
152
- // The whole fieldset is read-only when viewing Published. The form/canvas
153
- // render the pinned snapshot ONLY once it has loaded — never the draft, which
154
- // must not be presented as published; until then the body shows a placeholder.
155
- const viewReadOnly = perspective === 'published';
156
- const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
157
- const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
158
- // EC-316 — "Discard changes": revert the draft to exactly the published
159
- // content. Only meaningful for a publishing + versioned rail AND a published
160
- // document, so the history query is gated off otherwise (never a wasted
161
- // fetch on e.g. the users rail or a never-published draft).
162
- const canDiscard = canPublish && canVersions;
163
- const { versionsQuery: discardVersionsQuery, versionKey: discardVersionKey } = useDocumentVersionsQuery(ref, {
164
- enabled: canDiscard && published,
165
- });
166
- const discardCache = useOperationCache();
167
- const [discardConfirm, setDiscardConfirm] = useState(false);
168
- const [discardBusy, setDiscardBusy] = useState(false);
169
- const discardView = discardVersionsQuery.data ?? null;
170
- const publishedVersionNumber = discardView?.publishedVersion;
171
- const publishedSnapshotForDiscard = publishedVersionNumber !== undefined
172
- ? discardView?.versions.find((v) => v.version === publishedVersionNumber)?.snapshot
173
- : undefined;
174
- // One pure decision (tested in discard-draft.test.ts): hidden / disabled-
175
- // with-reason / ready. Its semantic equality is the SAME comparator the
176
- // Versions pane's publish gate uses, so the two never contradict (EC-298).
177
- const discardStatus = discardDraftStatus({
178
- canDiscard,
179
- published,
180
- viewReadOnly,
181
- publishedSnapshot: publishedSnapshotForDiscard,
182
- collection,
183
- draft,
184
- });
185
- const discardDisabledReason = discardStatus === 'view-read-only'
186
- ? translate('cms.editor.switchToDraftToDiscard')
187
- : discardStatus === 'up-to-date'
188
- ? translate('cms.editor.discardDraftUpToDateHint')
189
- : discardStatus === 'loading'
190
- ? translate('common.loading')
191
- : undefined;
192
- // Reuses the EC-298 restore machinery (the same op the Compare view's
193
- // "Restore version" button calls) — never a parallel write path: reverting
194
- // the draft IS restoring the published version's snapshot, whole-document.
195
- async function handleDiscardDraft() {
196
- if (publishedVersionNumber === undefined)
197
- return;
198
- setDiscardBusy(true);
199
- try {
200
- const restored = await source.versioning?.restore(ref, publishedVersionNumber, { kind: 'all' });
201
- if (restored)
202
- replaceDraft(restored);
203
- await discardCache.invalidate(discardVersionKey);
204
- }
205
- finally {
206
- setDiscardBusy(false);
207
- setDiscardConfirm(false);
208
- }
123
+ // EC-224 — the working draft's two out-of-band mutators, shared across the
124
+ // controller hooks below and the document-editor context. `save` persists the
125
+ // current buffer; `replaceDraft` adopts a restored snapshot and bumps the nonce
126
+ // to REMOUNT the uncontrolled canvas from it (else the next edit clobbers the
127
+ // restore — the canvas builds its graph once at mount and lifts edits OUT).
128
+ function save() {
129
+ source.save(draft);
209
130
  }
131
+ function replaceDraft(next) {
132
+ setDraft(next);
133
+ setRestoreNonce((n) => n + 1);
134
+ }
135
+ // EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective AND the pinned
136
+ // published snapshot, both derived from the ONE shared version-history query
137
+ // (AD-17: the single operations-backed cache — no raw `source.versioning.load`
138
+ // in this component). `canDiscard`/`versionKey`/`publishedVersionNumber`/
139
+ // `publishedSnapshotDoc` feed the Discard controller below off that same query.
140
+ const { perspective, setPerspective, published, canDiscard, versionKey, publishedVersionNumber, publishedSnapshotDoc, publishedSnapshot, viewReadOnly, viewDoc, publishedPending, } = usePublishedPerspective({ ref, source, canPublish, canVersions, draft });
210
141
  const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(doc), [draft, doc]);
211
142
  const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
212
143
  // EC-300/AD-16: composition embeds (hard references) whose target isn't published
@@ -219,38 +150,29 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
219
150
  const errorCount = schemaIssues.filter((i) => i.severity === 'error').length;
220
151
  const hasErrors = errorCount > 0;
221
152
  const hasUnpublishedRefs = referenceIssues.length > 0;
222
- // --- Save mode (EC-225 autosave · EC-240 explicit) -------------------------
223
- // A publishing rail AUTOSAVES: the draft persists 1s after the last edit and
224
- // publish is the deliberate commit. A non-publishing rail (users, EC-240) has no
225
- // publish step, so silent apply is the wrong default for authority data — it
226
- // SAVES EXPLICITLY instead: edits stay in the draft until the Save button, and a
227
- // navigation guard protects unsaved changes. (Role stays in `members`, applied
228
- // on Save and enforced immediately — never behind a publish pointer.)
229
- // Refs let the unmount flush read the latest values without re-subscribing.
230
- const explicitSave = !canPublish;
231
- const draftRef = useRef(draft);
232
- draftRef.current = draft;
233
- const dirtyRef = useRef(dirty);
234
- dirtyRef.current = dirty;
235
- const sourceRef = useRef(source);
236
- sourceRef.current = source;
237
- const explicitSaveRef = useRef(explicitSave);
238
- explicitSaveRef.current = explicitSave;
239
- useEffect(() => {
240
- if (explicitSave)
241
- return; // explicit rails persist only via the Save button
242
- if (!dirty)
243
- return;
244
- const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000);
245
- return () => window.clearTimeout(timer);
246
- }, [dirty, draft, explicitSave]);
247
- // Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
248
- // debounce window) so nothing is lost. Explicit rails never flush silently —
249
- // their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
250
- useEffect(() => () => {
251
- if (!explicitSaveRef.current && dirtyRef.current)
252
- sourceRef.current.save(draftRef.current);
253
- }, []);
153
+ // EC-225 autosave · EC-240 explicit save — the persistence discipline for this
154
+ // rail (a publishing rail autosaves + flushes on unmount; a non-publishing rail
155
+ // saves explicitly and relies on the `UnsavedChangesGuard` below for navigation).
156
+ const { explicitSave } = useSaveMode({ canPublish, dirty, draft, source });
157
+ // EC-316 / EC-325 — the "Discard changes" controller: the pure enablement
158
+ // decision plus the shared restore sequence (flush → restore the pinned published
159
+ // version whole → replaceDraft → invalidate). Destructured to the names the header
160
+ // menu + confirm dialog already use.
161
+ const { status: discardStatus, disabledReason: discardDisabledReason, confirmOpen: discardConfirm, setConfirmOpen: setDiscardConfirm, busy: discardBusy, discard: handleDiscardDraft, } = useDiscardController({
162
+ canDiscard,
163
+ published,
164
+ viewReadOnly,
165
+ publishedSnapshotDoc,
166
+ publishedVersionNumber,
167
+ versionKey,
168
+ collection,
169
+ draft,
170
+ source,
171
+ docRef: ref,
172
+ replaceDraft,
173
+ dirty,
174
+ save,
175
+ });
254
176
  const errorsByField = useMemo(() => {
255
177
  const map = new Map();
256
178
  for (const issue of issues) {
@@ -291,40 +213,28 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
291
213
  // not a field-row — it takes over its tab with the full page-builder canvas.
292
214
  // Convention: such a field lives on its own dedicated fieldset tab.
293
215
  const canvasField = tabFields.find(isBlocksField);
294
- const [highlightedField, setHighlightedField] = useState(null);
295
- // EC-285: the form-path inspector's active pane, controlled so a jump to a
296
- // `sidebar`-group field can open the Details pane (where it renders). Undefined
297
- // = uncontrolled (the sidebar defaults to its first pane).
298
- const [inspectorPaneId, setInspectorPaneId] = useState();
299
- // 0.0.41: the selected rich-text component embed, if any — a `componentEmbed` node
300
- // selection swaps the inspector sidebar for the embed's prop form. Fed by the
301
- // rich-text field through the EmbedInspector context; `clearActiveEmbed` keys on the
302
- // node id so a fast A→B reselect can't let A's deselect wipe B (selection race).
303
- const [activeEmbed, setActiveEmbed] = useState(null);
304
- const embedInspectorApi = useMemo(() => ({
305
- setActiveEmbed: (payload) => setActiveEmbed(payload),
306
- clearActiveEmbed: (nodeId) => setActiveEmbed((prev) => (prev?.nodeId === nodeId ? null : prev)),
307
- }), []);
308
- // EC-298: the open version comparison. The Compare tab is STICKY — opening it
309
- // sets the selection and focuses the tab; tabbing to the real fields keeps it
310
- // open (the selection persists) so an editor can flip between diff and fields;
311
- // only its ✕ closes it. `activeTab === COMPARE_TAB` is when the diff is visible.
312
- const [compare, setCompare] = useState(null);
313
- const startCompare = useCallback((base) => {
314
- setCompare({ base, target: 'draft' });
315
- setActiveTab(COMPARE_TAB);
316
- setInspectorPaneId('versions');
317
- }, []);
318
- const setCompareBase = useCallback((base) => {
319
- setCompare((c) => (c ? { ...c, base } : { base, target: 'draft' }));
320
- }, []);
321
- const setCompareTarget = useCallback((target) => {
322
- setCompare((c) => (c ? { ...c, target } : c));
323
- }, []);
324
- const exitCompare = useCallback(() => {
325
- setCompare(null);
326
- setActiveTab((prev) => (prev === COMPARE_TAB ? tabs[0] : prev));
327
- }, [tabs]);
216
+ // EC-172 / EC-283 — the field-focus/highlight controller (jump-to-field from the
217
+ // reference deep-link `focusPath` and the Issues pane). Owns the highlight + the
218
+ // controlled inspector-pane state and the scroll/clear timers.
219
+ const { highlightedField, inspectorPaneId, setInspectorPaneId, focusField } = useFieldFocus({
220
+ collection,
221
+ source,
222
+ focusPath,
223
+ setActiveTab,
224
+ });
225
+ // 0.0.41 — the selection-driven embed-inspector host: a rich-text `componentEmbed`
226
+ // selection swaps the inspector sidebar for the embed's prop form (the `api` is fed
227
+ // to the rich-text field through the EmbedInspector context).
228
+ const { activeEmbed, embedInspectorApi } = useEmbedInspectorHost();
229
+ // EC-298 — the sticky version-compare selection. Opening it focuses the Compare tab
230
+ // + the Versions pane; tabbing to the real fields keeps it open (only its ✕ closes
231
+ // it). `activeTab === COMPARE_TAB` is when the diff is visible.
232
+ const { compare, startCompare, setCompareBase, setCompareTarget, exitCompare } = useCompareSelection({
233
+ tabs,
234
+ compareTab: COMPARE_TAB,
235
+ setActiveTab,
236
+ setInspectorPaneId,
237
+ });
328
238
  // A content tab (schema tab or References). Selecting one leaves the Compare
329
239
  // view VISIBLE-wise but keeps the comparison open (sticky) — the diff is one
330
240
  // click away on its still-present tab chip.
@@ -342,49 +252,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
342
252
  const titleCase = tab !== REFERENCES_TAB && declaredLabel === undefined;
343
253
  return (_jsx("button", { type: "button", "aria-pressed": active, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors', titleCase && 'capitalize', active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: display }, tab));
344
254
  };
345
- // Focus a field by name: switch to its tab (and locale, when given), scroll the
346
- // control into view, and ring-highlight it. Shared by the reference deep-link
347
- // (`focusPath`, EC-172) and the Issues pane's jump-to-field (EC-283).
348
- const focusTimersRef = useRef([]);
349
- const focusField = useCallback((fieldName, localeHint) => {
350
- const field = collection.fields.find((f) => f.name === fieldName);
351
- if (!field)
352
- return;
353
- // EC-285: a `sidebar`-group field renders in the Details inspector pane, not
354
- // a content tab — open that pane instead of switching tabs.
355
- if (field.form?.group === SIDEBAR_FIELD_GROUP) {
356
- setInspectorPaneId('details');
357
- }
358
- else {
359
- const fieldTabs = tabOrder(collection.form);
360
- setActiveTab(field.form?.tab ?? fieldTabs[0] ?? 'main');
361
- }
362
- if (localeHint !== undefined && source.localeConfig.locales.includes(localeHint)) {
363
- setStudioLocale(localeHint);
364
- }
365
- setHighlightedField(field.name);
366
- // Clear any timers from a prior jump so back-to-back jumps don't fight.
367
- focusTimersRef.current.forEach((t) => window.clearTimeout(t));
368
- const scrollTimer = window.setTimeout(() => {
369
- // Some controls (relation/asset pickers) don't render the input id —
370
- // the field label (`htmlFor`) is always present.
371
- const control = document.getElementById(`f-${field.name}`) ??
372
- document.querySelector(`label[for="${CSS.escape(`f-${field.name}`)}"]`);
373
- control?.scrollIntoView({ block: 'center', behavior: 'smooth' });
374
- }, 60);
375
- const clearTimer = window.setTimeout(() => setHighlightedField(null), 3200);
376
- focusTimersRef.current = [scrollTimer, clearTimer];
377
- }, [collection, source.localeConfig]);
378
- useEffect(() => () => focusTimersRef.current.forEach((t) => window.clearTimeout(t)), []);
379
- // Reference deep links (EC-172): `?field=<index path>` pinpoints the exact field.
380
- useEffect(() => {
381
- if (!focusPath)
382
- return;
383
- const parsed = parseDocumentFieldPath(focusPath);
384
- if (!parsed)
385
- return;
386
- focusField(parsed.field, parsed.locale);
387
- }, [focusPath, focusField]);
388
255
  function getValue(field) {
389
256
  // EC-226: read from the active VIEW (the draft, or the pinned published
390
257
  // snapshot when viewing Published) — never directly from `draft`.
@@ -446,20 +313,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
446
313
  : null;
447
314
  return (_jsx(Field, { className: cn('rounded-md transition-shadow', highlightedField === field.name && 'bg-primary/5 p-2 ring-2 ring-primary/70'), label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(field) }), field.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null, localized ? _jsx(Badge, { variant: "outline", children: locale }) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " ", translate('cms.editor.readOnlyBadge')] })) : null, _jsx(Spacer, {}), connection && !fieldReadOnly ? (_jsx(FieldConnectionControl, { connection: connection, fieldName: field.name, locale: locale, onFillFromBase: () => setDraft((d) => fillFieldFromBase(collection, d, field.name, locale, source.localeConfig)), onResetToBase: () => setDraft((d) => resetFieldToBase(collection, d, field.name, locale)) })) : null] }), htmlFor: `f-${field.name}`, hint: localizedLabel(field.form?.description) || undefined, error: fieldReadOnly ? undefined : errorsByField.get(field.name), children: _jsx(FieldControl, { id: `f-${field.name}`, field: field, value: getValue(field), onChange: (v) => setValue(field, v), readOnly: fieldReadOnly }) }, field.name));
448
315
  }
449
- function save() {
450
- source.save(draft);
451
- }
452
- // EC-224: a version restore swaps the working draft for an older snapshot; the
453
- // open editor adopts it so the form/canvas re-render from the restored content
454
- // (the operation already persisted it, so it lands clean/non-dirty).
455
- function replaceDraft(next) {
456
- setDraft(next);
457
- // The canvas editor is uncontrolled after mount (it builds its graph once and
458
- // lifts edits OUT). A restore replaces the draft from outside, so bump the
459
- // nonce to REMOUNT the canvas from the restored value — otherwise it keeps the
460
- // pre-restore tree and the next edit would clobber the restore.
461
- setRestoreNonce((n) => n + 1);
462
- }
463
316
  // The frame-owned `Versions` pane (EC-224) — appended after any view-supplied
464
317
  // pane (the canvas's `Properties`). One instance feeds both the form view's
465
318
  // sidebar and the canvas view's sidebar.
@@ -520,75 +373,20 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
520
373
  ...(canVersions ? [versionsPane] : []),
521
374
  issuesPane,
522
375
  ];
523
- // EC-268: the Publish button is its own indicator — pending while the publish
524
- // round-trips, a brief success, and an error toast if it rejects at transport.
525
- const [publishState, setPublishState] = useState('idle');
526
- // EC-300/AD-16: confirm-through guard when the page embeds unpublished reusables.
527
- const [publishConfirm, setPublishConfirm] = useState(false);
528
- // 0.0.39: the split-button "Publish & close" intent — remembered across the
529
- // confirm-through dialog so a successful publish returns to the list, matching the
530
- // retired EC-316 "Save & close". Cleared on cancel or a publish failure (no navigate).
531
- const closeAfterPublishRef = useRef(false);
532
- // Gate publish on the confirm-through dialog when there are unpublished hard
533
- // references (they'd render empty on the live site); schema errors already
534
- // disable the button, so they never reach here.
535
- function handlePublish() {
536
- if (hasUnpublishedRefs) {
537
- setPublishConfirm(true);
538
- return;
539
- }
540
- void doPublish();
541
- }
542
- // 0.0.39: publish, then return to the rail's list (rail-owned `listPath`, AD-19),
543
- // the same navigation the removed "Save & close" did. Shares handlePublish's exact
544
- // gating + confirm-through; the close happens only after the publish resolves.
545
- function handlePublishAndClose() {
546
- closeAfterPublishRef.current = true;
547
- handlePublish();
548
- }
549
- async function doPublish() {
550
- // EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME.
551
- // While editing, the draft keeps them so an accidental hide (e.g. flipping a
552
- // link's type) is recoverable; publishing is the intentional moment we commit
553
- // the cleanup — into the draft AND the snapshot, so both go clean together (the
554
- // reference index updates off the saved draft, so a now-unlinked page becomes
555
- // deletable once the change is published). Also captures the latest edit in case
556
- // Publish is clicked inside the autosave debounce window; the serial persister
557
- // runs the upsert before the publish so the snapshot is exactly this draft (EC-225).
558
- const cleaned = clearHiddenValues(collection, draft);
559
- if (cleaned !== draft)
560
- setDraft(cleaned);
561
- if (cleaned !== draft || dirty)
562
- source.save(cleaned);
563
- // Publish snapshots the draft as a version and pins it (EC-224). The live
564
- // row stays a continuous draft — published-ness is the version pointer
565
- // (`source.publishing.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
566
- // flip reconciles against the backend (EC-225), so a logical rejection the
567
- // gate doesn't cover (schema divergence) self-corrects the badge. A hard
568
- // failure (transport) rejects the flush — surface it loudly (EC-268).
569
- setPublishState('pending');
570
- try {
571
- await source.publishing?.publish(ref);
572
- setPublishState('success');
573
- setTimeout(() => setPublishState('idle'), 1800);
574
- // "Publish & close": return to the list once the publish landed (never on a
575
- // failure). Rail-owned `listPath`, falling back to the project index (AD-19).
576
- if (closeAfterPublishRef.current) {
577
- closeAfterPublishRef.current = false;
578
- void navigate({
579
- to: STUDIO_PROJECT_ROUTE,
580
- params: { projectId, _splat: source.listPath ?? studioPath.index() },
581
- });
582
- }
583
- }
584
- catch (error) {
585
- closeAfterPublishRef.current = false;
586
- setPublishState('idle');
587
- toast.error(error instanceof Error
588
- ? translate('cms.editor.publishFailedReason', { error: error.message })
589
- : translate('cms.editor.publishFailed'));
590
- }
591
- }
376
+ // EC-268 / EC-300 / 0.0.39 — the publish controller: the publish indicator state,
377
+ // the EC-300 confirm-through guard (unpublished hard refs would render empty on
378
+ // the live site), and the "Publish & close" navigation. Handlers are named to
379
+ // match the header split button + the confirm-through dialog below.
380
+ const { publishState, publishConfirm, handlePublish, handlePublishAndClose, confirmPublish, cancelPublishConfirm, } = usePublishController({
381
+ source,
382
+ docRef: ref,
383
+ collection,
384
+ projectId,
385
+ draft,
386
+ setDraft,
387
+ dirty,
388
+ hasUnpublishedRefs,
389
+ });
592
390
  function handleConfirmedAction() {
593
391
  if (confirmAction === 'unpublish') {
594
392
  setConfirmAction(null);
@@ -637,41 +435,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
637
435
  ];
638
436
  }, [singleton, collection, draft, locale, source.localeConfig, source.listPath, doc.id, projectId]);
639
437
  useSetBreadcrumb(breadcrumbItems);
640
- // EC-205/EC-216: the LIVE URL derives strictly from PUBLISHED data — the
641
- // document's pinned published snapshot and its published ancestors — never the
642
- // draft (an unpublished slug/parent edit must not leak into the live URL). The
643
- // pool is exactly what the live site serves, so a published doc resolves to the
644
- // URL visitors actually get; `undefined` until the doc is published (and the
645
- // pool has loaded). The collection being routable gates the disabled hint below.
646
- const publishedDocs = usePublishedDocuments(source.projectId, collection.id);
647
- const liveUrl = useMemo(() => {
648
- const published = publishedDocs.byId.get(doc.id);
649
- if (!published)
650
- return undefined;
651
- return urlForDocument({
652
- routes: resolveDeploymentRoutes(),
653
- frontendUrl: resolveDeploymentFrontendUrl(),
654
- localeConfig: source.localeConfig,
655
- }, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
656
- }, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
657
- // 0.0.42 (round 3): the composed URL row lives in the header under the title; its
658
- // LEAF segment carries the open-live affordance, mirroring the retired "View live"
659
- // button's gating EXACTLY. `href` present (published + frontendUrl) → the leaf opens
660
- // the live page in a new tab; absent → a muted, non-interactive icon with the same
661
- // reason tooltip (loading / publish-first / set-frontendUrl). The draft-derived path
662
- // itself still updates as you type — only the OPEN-live action is published-gated, so
663
- // the draft/live distinction is preserved.
664
- const headerLive = routableCollection
665
- ? liveUrl?.href
666
- ? { href: liveUrl.href, title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }) }
667
- : {
668
- title: publishedDocs.status === 'loading'
669
- ? translate('cms.editor.loadingLiveUrl')
670
- : !published
671
- ? translate('cms.editor.publishToViewLive')
672
- : translate('cms.editor.setFrontendUrlHint'),
673
- }
674
- : undefined;
438
+ // EC-205/EC-216 — the header composed-URL row's live-link descriptor. Derives
439
+ // strictly from PUBLISHED data (the pinned snapshot + published ancestors), never
440
+ // the draft, so an unpublished slug/parent edit can't leak into the live URL; the
441
+ // draft-derived path itself (rendered by `DocumentUrlPath`) still updates as you
442
+ // type — only the OPEN-live action is published-gated. `undefined` when the
443
+ // collection is non-routable.
444
+ const { headerLive } = useLiveUrl({ source, collection, doc, locale, published, routableCollection });
675
445
  // EC-240: hide the tab bar when there is nothing to navigate — a single content
676
446
  // tab with no References/Compare (the users rail). When framework tabs ARE
677
447
  // present we KEEP the content tab(s) so there is always a way back to the form;
@@ -681,14 +451,15 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
681
451
  const hasFrameworkTabs = showReferences || compare !== null;
682
452
  const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
683
453
  const showLensRow = showContentTabs || canLocales || canPublish;
684
- // 0.0.39 split Publish button — the shared gating/label, computed once so the
685
- // primary "Publish" and the "Publish & close" item can never diverge.
686
- const publishDisabled = hasErrors || viewReadOnly || publishState === 'pending';
687
- const publishTitle = viewReadOnly
688
- ? translate('cms.editor.switchToDraftToPublish')
689
- : hasErrors
690
- ? translate(errorCount === 1 ? 'cms.editor.fixIssuesToPublish.one' : 'cms.editor.fixIssuesToPublish.other', { count: errorCount })
691
- : undefined;
454
+ // 0.0.39 split Publish button — the shared gate (disabled + title), computed once
455
+ // (pure `publishGate`) so the primary "Publish" and the "Publish & close" item can
456
+ // never diverge.
457
+ const { disabled: publishDisabled, title: publishTitle } = publishGate({
458
+ hasErrors,
459
+ viewReadOnly,
460
+ publishState,
461
+ errorCount,
462
+ });
692
463
  const publishButtonLabel = publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] }));
693
464
  // Offer "Publish & close" only when there IS a list to return to — the same gate
694
465
  // the removed EC-316 "Save & close" used (never a singleton or a list-less rail).
@@ -747,18 +518,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
747
518
  if (!open)
748
519
  setConfirmAction(null);
749
520
  }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
750
- setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open) {
751
- setPublishConfirm(false);
752
- closeAfterPublishRef.current = false;
753
- } }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
521
+ setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
522
+ cancelPublishConfirm(); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
754
523
  ? 'cms.editor.publishAlsoHasIssues.one'
755
- : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
756
- setPublishConfirm(false);
757
- closeAfterPublishRef.current = false;
758
- }, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
759
- setPublishConfirm(false);
760
- void doPublish();
761
- }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
524
+ : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: cancelPublishConfirm, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: confirmPublish, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
762
525
  }
763
526
  /**
764
527
  * EC-300/AD-16 — errors for composition embeds (hard references) whose target isn't