canopycms 0.0.68-int.113 → 0.0.68-int.114

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 (170) hide show
  1. package/dist/ai/generate.d.ts +4 -4
  2. package/dist/ai/generate.js +75 -14
  3. package/dist/ai/types.d.ts +12 -0
  4. package/dist/api/branch.js +13 -13
  5. package/dist/auth/cache.d.ts +1 -1
  6. package/dist/auth/cache.js +1 -1
  7. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  8. package/dist/auth/file-based-auth-cache.js +11 -0
  9. package/dist/authorization/branch.d.ts +1 -1
  10. package/dist/authorization/branch.js +2 -1
  11. package/dist/cli/cli.js +160 -28
  12. package/dist/cli/generate-ai-content.js +156 -24
  13. package/dist/content-reader.js +2 -1
  14. package/dist/context.js +2 -1
  15. package/dist/editor/BranchManager.js +13 -17
  16. package/dist/editor/CommentsPanel.js +7 -6
  17. package/dist/editor/Editor.js +152 -30
  18. package/dist/editor/EditorAuthGate.js +2 -2
  19. package/dist/editor/EditorPanes.d.ts +10 -0
  20. package/dist/editor/EditorPanes.js +32 -8
  21. package/dist/editor/FormRenderer.d.ts +10 -0
  22. package/dist/editor/FormRenderer.js +92 -73
  23. package/dist/editor/PreviewFrame.d.ts +3 -1
  24. package/dist/editor/PreviewFrame.js +72 -9
  25. package/dist/editor/admin/SystemHealthPanel.js +27 -24
  26. package/dist/editor/branch-status.d.ts +16 -0
  27. package/dist/editor/branch-status.js +28 -0
  28. package/dist/editor/comments/BranchComments.js +1 -1
  29. package/dist/editor/comments/EntryComments.d.ts +3 -0
  30. package/dist/editor/comments/EntryComments.js +3 -3
  31. package/dist/editor/comments/FieldWrapper.d.ts +3 -0
  32. package/dist/editor/comments/FieldWrapper.js +2 -2
  33. package/dist/editor/comments/InlineCommentThread.js +2 -1
  34. package/dist/editor/comments/ThreadCarousel.js +2 -2
  35. package/dist/editor/components/ConfirmDeleteModal.js +1 -1
  36. package/dist/editor/components/EditorCrashScreen.js +1 -1
  37. package/dist/editor/components/EditorHeader.d.ts +2 -0
  38. package/dist/editor/components/EditorHeader.js +15 -36
  39. package/dist/editor/components/EditorSidebar.d.ts +5 -6
  40. package/dist/editor/components/EditorSidebar.js +2 -2
  41. package/dist/editor/components/EntryCreateModal.js +1 -1
  42. package/dist/editor/components/NoEditPermissionNotice.d.ts +3 -2
  43. package/dist/editor/components/NoEditPermissionNotice.js +3 -2
  44. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  45. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  46. package/dist/editor/components/RenameEntryModal.js +1 -1
  47. package/dist/editor/components/StagedChangesDrawer.js +3 -3
  48. package/dist/editor/components/UndoToastMessage.d.ts +14 -0
  49. package/dist/editor/components/UndoToastMessage.js +9 -0
  50. package/dist/editor/components/UserBadge.js +9 -5
  51. package/dist/editor/copy.d.ts +14 -0
  52. package/dist/editor/copy.js +14 -0
  53. package/dist/editor/fields/BlockField.d.ts +5 -0
  54. package/dist/editor/fields/BlockField.js +38 -32
  55. package/dist/editor/fields/CodeField.d.ts +2 -0
  56. package/dist/editor/fields/CodeField.js +5 -2
  57. package/dist/editor/fields/DateTimeField.d.ts +2 -0
  58. package/dist/editor/fields/DateTimeField.js +5 -2
  59. package/dist/editor/fields/FieldCrashFallback.d.ts +3 -3
  60. package/dist/editor/fields/FieldCrashFallback.js +4 -4
  61. package/dist/editor/fields/FieldLabel.d.ts +24 -0
  62. package/dist/editor/fields/FieldLabel.js +9 -0
  63. package/dist/editor/fields/ImageField.d.ts +4 -0
  64. package/dist/editor/fields/ImageField.js +17 -8
  65. package/dist/editor/fields/InlineGroupField.js +4 -3
  66. package/dist/editor/fields/MarkdownField.d.ts +6 -0
  67. package/dist/editor/fields/MarkdownField.js +41 -11
  68. package/dist/editor/fields/MdxImageDialog.js +1 -1
  69. package/dist/editor/fields/NumberField.d.ts +2 -0
  70. package/dist/editor/fields/NumberField.js +4 -2
  71. package/dist/editor/fields/NumberListField.d.ts +2 -0
  72. package/dist/editor/fields/NumberListField.js +4 -2
  73. package/dist/editor/fields/ObjectField.d.ts +1 -0
  74. package/dist/editor/fields/ObjectField.js +5 -4
  75. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  76. package/dist/editor/fields/ReferenceField.js +9 -3
  77. package/dist/editor/fields/SelectField.d.ts +2 -0
  78. package/dist/editor/fields/SelectField.js +8 -2
  79. package/dist/editor/fields/StringListField.d.ts +2 -0
  80. package/dist/editor/fields/StringListField.js +5 -2
  81. package/dist/editor/fields/TextField.d.ts +2 -0
  82. package/dist/editor/fields/TextField.js +5 -2
  83. package/dist/editor/fields/ToggleField.d.ts +2 -0
  84. package/dist/editor/fields/ToggleField.js +6 -3
  85. package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
  86. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  87. package/dist/editor/fields/list-item-keys.js +50 -0
  88. package/dist/editor/group-manager/GroupCard.js +1 -1
  89. package/dist/editor/group-manager/GroupForm.js +1 -1
  90. package/dist/editor/group-manager/InternalGroupsTab.js +1 -1
  91. package/dist/editor/group-manager/MemberList.js +1 -1
  92. package/dist/editor/group-manager/hooks/useGroupState.d.ts +1 -0
  93. package/dist/editor/group-manager/hooks/useGroupState.js +1 -1
  94. package/dist/editor/group-manager/index.js +26 -2
  95. package/dist/editor/hooks/useBranchActions.js +2 -2
  96. package/dist/editor/hooks/useBranchManager.js +36 -22
  97. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  98. package/dist/editor/hooks/useDraftManager.js +27 -5
  99. package/dist/editor/hooks/useEditorLayout.d.ts +45 -3
  100. package/dist/editor/hooks/useEditorLayout.js +97 -7
  101. package/dist/editor/hooks/useUserMetadata.js +12 -1
  102. package/dist/editor/media/AssetCard.js +1 -1
  103. package/dist/editor/media/CropStep.js +6 -1
  104. package/dist/editor/media/MediaLibraryBody.js +3 -3
  105. package/dist/editor/permission-manager/PermissionEditor.js +3 -3
  106. package/dist/editor/permission-manager/index.js +1 -1
  107. package/dist/editor/preview-bridge.js +2 -2
  108. package/dist/editor/schema-editor/CollectionEditor.js +1 -1
  109. package/dist/editor/schema-editor/EntryTypeEditor.js +1 -1
  110. package/dist/editor/theme.d.ts +1 -1
  111. package/dist/editor/theme.js +11 -11
  112. package/dist/editor/undo-removal.d.ts +44 -0
  113. package/dist/editor/undo-removal.js +129 -0
  114. package/dist/editor/user-display.d.ts +4 -0
  115. package/dist/editor/user-display.js +5 -0
  116. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  117. package/dist/editor/utils/confirm-modal.js +12 -0
  118. package/dist/entry-link-resolver.d.ts +11 -1
  119. package/dist/entry-link-resolver.js +65 -5
  120. package/dist/git-manager.d.ts +6 -6
  121. package/dist/git-manager.js +16 -15
  122. package/dist/github-service.d.ts +25 -1
  123. package/dist/github-service.js +65 -6
  124. package/dist/http/handler.js +2 -1
  125. package/dist/static/seo.d.ts +5 -5
  126. package/dist/static/seo.js +5 -5
  127. package/dist/system-users.d.ts +12 -0
  128. package/dist/system-users.js +12 -0
  129. package/dist/types.d.ts +9 -1
  130. package/dist/utils/error.js +4 -3
  131. package/dist/utils/escape-text.d.ts +7 -0
  132. package/dist/utils/escape-text.js +27 -0
  133. package/dist/version.d.ts +1 -1
  134. package/dist/version.js +1 -1
  135. package/dist/worker/branch-policy.d.ts +124 -0
  136. package/dist/worker/branch-policy.js +243 -0
  137. package/dist/worker/bundle-header.d.ts +117 -0
  138. package/dist/worker/bundle-header.js +253 -0
  139. package/dist/worker/cms-worker.d.ts +68 -17
  140. package/dist/worker/cms-worker.js +116 -33
  141. package/dist/worker/gateway-egress.d.ts +43 -0
  142. package/dist/worker/gateway-egress.js +209 -0
  143. package/dist/worker/gateway-instance.d.ts +42 -0
  144. package/dist/worker/gateway-instance.js +129 -0
  145. package/dist/worker/git-process.d.ts +81 -0
  146. package/dist/worker/git-process.js +234 -0
  147. package/dist/worker/git-sync.d.ts +8 -7
  148. package/dist/worker/git-sync.js +184 -74
  149. package/dist/worker/github-gateway-http.d.ts +62 -0
  150. package/dist/worker/github-gateway-http.js +977 -0
  151. package/dist/worker/github-gateway-wire.d.ts +589 -0
  152. package/dist/worker/github-gateway-wire.js +484 -0
  153. package/dist/worker/github-gateway.d.ts +152 -58
  154. package/dist/worker/github-gateway.js +485 -129
  155. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  156. package/dist/worker/github-mirror-upkeep.js +266 -0
  157. package/dist/worker/github-mirror.d.ts +271 -71
  158. package/dist/worker/github-mirror.js +755 -238
  159. package/dist/worker/github-transfer.d.ts +56 -0
  160. package/dist/worker/github-transfer.js +444 -0
  161. package/dist/worker/self-checks.d.ts +107 -0
  162. package/dist/worker/self-checks.js +358 -0
  163. package/dist/worker/shared-repo-git.d.ts +20 -7
  164. package/dist/worker/shared-repo-git.js +51 -22
  165. package/dist/worker/stale-mirror.d.ts +82 -0
  166. package/dist/worker/stale-mirror.js +130 -0
  167. package/dist/worker/task-runner.d.ts +2 -0
  168. package/dist/worker/task-runner.js +49 -20
  169. package/dist/worker/worker-context.d.ts +6 -0
  170. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useState, useRef } from 'react';
4
- import { ActionIcon, Alert, Box, Drawer, Group, Menu, Paper, Text } from '@mantine/core';
4
+ import { ActionIcon, Alert, Box, Button, Drawer, Group, Menu, Paper, Skeleton, Stack, Text, } from '@mantine/core';
5
5
  import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } from '@tabler/icons-react';
6
6
  import { notifications } from '@mantine/notifications';
7
7
  const noop = () => { };
@@ -17,10 +17,11 @@ import { GroupManager } from './GroupManager.js';
17
17
  import { PermissionManager } from './PermissionManager.js';
18
18
  import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
19
19
  // Import directly from helpers to avoid server-only code in authorization barrel
20
- import { isAdmin } from '../authorization/helpers.js';
20
+ import { isAdmin, isPrivileged } from '../authorization/helpers.js';
21
21
  import { buildPreviewSrc, buildCollectionLabels, buildBreadcrumbSegments } from './editor-utils.js';
22
22
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
23
23
  import { useBranchActions } from './hooks/useBranchActions.js';
24
+ import { editorLayoutStorageKey } from './hooks/useEditorLayout.js';
24
25
  import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
25
26
  import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
26
27
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
@@ -31,6 +32,7 @@ import { EntryCreateModal } from './components/EntryCreateModal.js';
31
32
  import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
32
33
  import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
33
34
  import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
35
+ import { ReadOnlyDraftNotice } from './components/ReadOnlyDraftNotice.js';
34
36
  import { UnavailableEntryNotice } from './components/UnavailableEntryNotice.js';
35
37
  import { unavailableSchemaRefs } from './unavailable-entry-type.js';
36
38
  import { EditorCrashBoundary } from './components/EditorCrashScreen.js';
@@ -38,6 +40,9 @@ import { CollectionEditor } from './schema-editor/index.js';
38
40
  import { AssetContextProvider, SiteMdxAllowContext, authenticatedAssetBase, useApiClient, } from './context/index.js';
39
41
  import { EntryLinkContext } from './fields/entry-link/index.js';
40
42
  import { MediaLibrary } from './media/MediaLibrary.js';
43
+ import { removalAnchors, restoreRemoval } from './undo-removal.js';
44
+ import { UndoToastMessage } from './components/UndoToastMessage.js';
45
+ import { getNotificationDuration } from './utils/env.js';
41
46
  // Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
42
47
  // while no entry is open.
43
48
  const EMPTY_SCHEMA = [];
@@ -76,11 +81,14 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
76
81
  const [previewError, setPreviewError] = useState(null);
77
82
  const apiClient = useApiClient();
78
83
  const { userContext } = useUserContext();
79
- // The System Health panel is admin-only -- gate both the sidebar
80
- // menu item and the modal mount on it, same pattern BranchManager.tsx uses
81
- // for its own admin-only actions.
82
- const showSystemHealth = isAdmin(userContext?.groups);
83
- const { layout, setLayout, highlightEnabled, setHighlightEnabled, headerRef, headerHeight } = useEditorLayout();
84
+ // Admin surfaces (permissions, groups, system health, collection management and entry order,
85
+ // whose API routes all take the `admin` guard) render only for admins: each menu item, action
86
+ // and drawer mount is gated here. Until whoami answers, the user counts as no admin.
87
+ const userIsAdmin = isAdmin(userContext?.groups);
88
+ // Name lookups take the `privileged` guard (admins and reviewers); anyone else sees ids, and
89
+ // no lookup is sent that would only be refused.
90
+ const canResolveUserNames = isPrivileged(userContext?.groups);
91
+ const { layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, headerRef, headerHeight, } = useEditorLayout({ storageKey: editorLayoutStorageKey(basePath) });
84
92
  // Comments state (shared between useCommentSystem and useBranchManager)
85
93
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
86
94
  // 1. Branch manager (provides branchNameState, no dependencies)
@@ -136,7 +144,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
136
144
  setPreviewError(null);
137
145
  }, [currentEntry?.contentId]);
138
146
  // 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
139
- const { setDrafts, loadedValues, setLoadedValues, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, } = useDraftManager({
147
+ const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, draftStorageFailed, } = useDraftManager({
140
148
  branchName: branchNameState,
141
149
  selectedPath,
142
150
  currentEntry,
@@ -150,6 +158,8 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
150
158
  onSaved: () => {
151
159
  void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
152
160
  },
161
+ // An Undo built on a discarded draft would put back an item the server's copy already has.
162
+ onDraftsReset: (contentId) => dismissUndoToasts(contentId),
153
163
  });
154
164
  // 4. Branch actions (depends on resolveUnsaved, setBranchName)
155
165
  const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
@@ -180,6 +190,92 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
180
190
  branchName: branchNameState,
181
191
  onSchemaChange: () => refreshEntries(branchNameState),
182
192
  });
193
+ // Read-only content shows the saved value, never a draft; a restored draft is kept, unseen,
194
+ // until it is discarded or the branch unlocks.
195
+ const contentReadOnly = branchContentLocked || currentEntry?.canEdit === false;
196
+ const displayValue = contentReadOnly ? loadedValue : effectiveValue;
197
+ // Read when an edit lands, which an upload or other async field work can do renders later.
198
+ const contentReadOnlyRef = useRef(contentReadOnly);
199
+ contentReadOnlyRef.current = contentReadOnly;
200
+ // Undo for a form Remove: a toast whose Undo puts the item back into the entry's draft as it
201
+ // is then, through a functional update, so edits made since are kept. It acts only on the
202
+ // branch and entry it was raised for, and its toast closes when either changes.
203
+ const loadedValuesRef = useRef(loadedValues);
204
+ loadedValuesRef.current = loadedValues;
205
+ const draftsRef = useRef(drafts);
206
+ draftsRef.current = drafts;
207
+ // Open Undo toasts, by id, with the entry each belongs to.
208
+ const undoToastsRef = useRef(new Map());
209
+ const nextUndoToastRef = useRef(0);
210
+ const dismissUndoToasts = (contentId) => {
211
+ undoToastsRef.current.forEach((entry, id) => {
212
+ if (contentId !== undefined && entry !== contentId)
213
+ return;
214
+ notifications.hide(id);
215
+ undoToastsRef.current.delete(id);
216
+ });
217
+ };
218
+ const handleFieldRemoved = (removal) => {
219
+ const contentId = currentEntry?.contentId;
220
+ if (!contentId || contentReadOnlyRef.current)
221
+ return;
222
+ const branch = branchNameState;
223
+ const fields = schema;
224
+ // Taken from the value this render shows, which the removal has not reached yet.
225
+ const anchored = {
226
+ ...removal,
227
+ anchors: displayValue ? removalAnchors(fields, displayValue, removal) : undefined,
228
+ };
229
+ nextUndoToastRef.current += 1;
230
+ const id = `canopy-undo-${nextUndoToastRef.current}`;
231
+ let used = false;
232
+ const undo = () => {
233
+ // A closing toast stays clickable while it animates out: act once, and only while open.
234
+ if (used)
235
+ return;
236
+ used = true;
237
+ const open = undoToastsRef.current.has(id);
238
+ notifications.hide(id);
239
+ undoToastsRef.current.delete(id);
240
+ if (!open) {
241
+ notifications.show({
242
+ message: `Too late to undo removing ${removal.label}.`,
243
+ color: 'yellow',
244
+ });
245
+ return;
246
+ }
247
+ if (currentBranchRef.current !== branch ||
248
+ currentContentIdRef.current !== contentId ||
249
+ contentReadOnlyRef.current) {
250
+ return;
251
+ }
252
+ const now = draftsRef.current[contentId] ?? loadedValuesRef.current[contentId];
253
+ if (!now || !restoreRemoval(fields, now, anchored)) {
254
+ notifications.show({
255
+ message: `Couldn't undo removing ${removal.label}: that part of the entry has changed. Add it again if you still need it.`,
256
+ color: 'yellow',
257
+ });
258
+ return;
259
+ }
260
+ setDrafts((prev) => {
261
+ const base = prev[contentId] ?? loadedValuesRef.current[contentId];
262
+ const restored = base ? restoreRemoval(fields, base, anchored) : undefined;
263
+ return restored ? { ...prev, [contentId]: restored } : prev;
264
+ });
265
+ };
266
+ undoToastsRef.current.set(id, contentId);
267
+ const toast = {
268
+ id,
269
+ message: (_jsx(UndoToastMessage, { label: removal.label, onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
270
+ autoClose: getNotificationDuration(8000),
271
+ withCloseButton: true,
272
+ onClose: () => undoToastsRef.current.delete(id),
273
+ };
274
+ notifications.show(toast);
275
+ };
276
+ useEffect(() => () => dismissUndoToasts(), [currentEntry?.contentId, branchNameState]);
277
+ // Only once the branch has answered, so the fail-closed lock while it loads raises no notice.
278
+ const hiddenDraftNotice = contentReadOnly && currentBranch !== undefined && !!currentEntry && isSelectedDirty();
183
279
  const collectionLabels = useMemo(() => buildCollectionLabels(activeCollections), [activeCollections]);
184
280
  const schema = currentEntry?.schema ?? EMPTY_SCHEMA;
185
281
  // Gated on `loadedValues[contentId]` (not `drafts[contentId]`) so a
@@ -198,6 +294,18 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
198
294
  // that same id. Qualifying the key lets both loads coexist; the branch check
199
295
  // at settle time decides which one is allowed to write.
200
296
  const loadingEntryIdsRef = useRef(new Set());
297
+ // The entry (keyed like `loadingEntryIdsRef`) whose last read failed, and Retry's bump that
298
+ // runs the load effect below again.
299
+ const [loadFailedKey, setLoadFailedKey] = useState(null);
300
+ const [loadAttempt, setLoadAttempt] = useState(0);
301
+ // Reset during render, not in an effect, so returning to a failed entry never paints its Alert
302
+ // for the frame before its load starts again.
303
+ const selectionKey = `${branchNameState}:${currentEntry?.contentId ?? ''}`;
304
+ const [loadFailedSelection, setLoadFailedSelection] = useState(selectionKey);
305
+ if (loadFailedSelection !== selectionKey) {
306
+ setLoadFailedSelection(selectionKey);
307
+ setLoadFailedKey(null);
308
+ }
201
309
  // Entries (keyed like `loadingEntryIdsRef`) whose read the API refused as SCHEMA_UNAVAILABLE.
202
310
  // The ref stops the load effect retrying them; the state re-renders the notice.
203
311
  const schemaUnavailableRef = useRef(new Set());
@@ -261,6 +369,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
261
369
  return;
262
370
  loadingEntryIdsRef.current.add(inFlightKey);
263
371
  setEntriesLoading(true);
372
+ setLoadFailedKey((failed) => (failed === inFlightKey ? null : failed));
264
373
  try {
265
374
  const loaded = await loadEntry(currentEntry);
266
375
  // A branch switch during the await makes this response another
@@ -307,10 +416,11 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
307
416
  // different branch).
308
417
  if (currentBranchRef.current === requestBranch && currentContentIdRef.current === contentId) {
309
418
  notifications.show({ message: 'Failed to load entry', color: 'red' });
419
+ setLoadFailedKey(inFlightKey);
310
420
  }
311
421
  });
312
422
  // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters, run only on entry/path/branch/loadedValues change
313
- }, [currentEntry, loadedValues, selectedPath, branchNameState]);
423
+ }, [currentEntry, loadedValues, selectedPath, branchNameState, loadAttempt]);
314
424
  const handleOpenCollectionEditor = async (collection, parentPath) => {
315
425
  if (collection) {
316
426
  try {
@@ -601,11 +711,15 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
601
711
  onAdd: canAddEntry
602
712
  ? () => (onCreateEntry ? onCreateEntry(node.path) : handleCreateEntry(node.path))
603
713
  : undefined,
604
- onEdit: node.type === 'collection' ? () => handleOpenCollectionEditor(node) : undefined,
605
- onAddSubCollection: node.type === 'collection'
714
+ onEdit: userIsAdmin && node.type === 'collection'
715
+ ? () => handleOpenCollectionEditor(node)
716
+ : undefined,
717
+ onAddSubCollection: userIsAdmin && node.type === 'collection'
606
718
  ? () => handleOpenCollectionEditor(null, node.path)
607
719
  : undefined,
608
- onDelete: node.type === 'collection' ? () => handleDeleteCollection(node.path) : undefined,
720
+ onDelete: userIsAdmin && node.type === 'collection'
721
+ ? () => handleDeleteCollection(node.path)
722
+ : undefined,
609
723
  };
610
724
  };
611
725
  return activeCollections.map((node) => build(node));
@@ -618,6 +732,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
618
732
  contentRoot,
619
733
  currentBranch,
620
734
  schemaUnavailableKeys,
735
+ userIsAdmin,
621
736
  ]);
622
737
  // Tree expansion state - persists across drawer close/open
623
738
  const treeExpandedStateRef = useRef({});
@@ -644,12 +759,12 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
644
759
  // Resolved here, outside the form and its per-field crash boundaries, and during render, so
645
760
  // the preview never receives a bare reference id: a reference is its target or `null`.
646
761
  const { resolvedValue: previewValue, loadingState: previewLoadingState } = useReferenceResolution({
647
- value: effectiveValue ?? EMPTY_VALUE,
762
+ value: displayValue ?? EMPTY_VALUE,
648
763
  fields: schema,
649
764
  branch: branchNameState,
650
765
  entryKey: currentEntry?.contentId,
651
766
  });
652
- const previewFrameData = useMemo(() => (effectiveValue ? resolveEntryLinks(previewValue) : effectiveValue), [previewValue, effectiveValue, resolveEntryLinks]);
767
+ const previewFrameData = useMemo(() => (displayValue ? resolveEntryLinks(previewValue) : displayValue), [previewValue, displayValue, resolveEntryLinks]);
653
768
  // Entry link context for the InsertEntryLink toolbar button
654
769
  const entryLinkContextValue = useMemo(() => ({
655
770
  entries: entriesState.map((e) => ({
@@ -670,7 +785,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
670
785
  src: currentEntry?.previewSrc,
671
786
  highlightEnabled,
672
787
  fields: schema,
673
- data: effectiveValue,
788
+ data: displayValue,
674
789
  });
675
790
  // An unavailable entry previews nothing: the site cannot read it, and a stored draft would
676
791
  // otherwise mount the frame at a page that answers not-found.
@@ -697,7 +812,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
697
812
  const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
698
813
  return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsxs(Box, { bg: "gray.0", style: { minHeight: '100vh', width: '100%' }, children: [_jsx(EditorHeader, { ref: headerRef, siteTitle: siteTitle, siteSubtitle: siteSubtitle, headerTitle: headerTitle, currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, comments: comments,
699
814
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
700
- hasUnsavedChanges: !currentEntryUnavailable && isSelectedDirty(), userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
815
+ hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
701
816
  // Fail-closed for the same reason branchContentLocked above is:
702
817
  // isProtected/writeBlocked gate real actions in EditorHeader (hiding
703
818
  // Submit, disabling Save), so a missing wire value must read as
@@ -729,25 +844,32 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
729
844
  height: `calc(100vh - ${headerHeight + footerHeight}px)`,
730
845
  minHeight: 0,
731
846
  position: 'relative',
732
- }, children: [_jsx(Box, { style: { flex: 1, minHeight: 0, height: '100%', width: '100%' }, children: _jsx(EditorPanes, { layout: layout, onLayoutChange: (next) => setLayout(next), preview: renderPreview && currentEntry && !currentEntryUnavailable
733
- ? renderPreview(currentEntry, effectiveValue)
847
+ }, children: [_jsx(Box, { style: { flex: 1, minHeight: 0, height: '100%', width: '100%' }, children: _jsx(EditorPanes, { layout: layout, onLayoutChange: (next) => setLayout(next), sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: setSplitPercent, preview: renderPreview && currentEntry && !currentEntryUnavailable
848
+ ? renderPreview(currentEntry, displayValue)
734
849
  : defaultPreview, form: !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
735
850
  ? 'Setting up your branch workspace…'
736
851
  : entriesInitializing
737
852
  ? 'Loading content…'
738
- : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
739
- const contentId = currentEntry?.contentId;
740
- if (contentId) {
741
- setDrafts((prev) => ({ ...prev, [contentId]: next }));
742
- }
743
- }, customRenderers: customRenderers, branch: branchNameState, comments: comments, currentEntryPath: selectedPath, currentUserId: currentUser, canResolve: canResolveComments, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
744
- currentBranch?.conflictFiles?.includes(currentEntry.contentId)) }) }) })) : (_jsx(CenteredMessage, { children: "No fields to edit." })) }) }), _jsx(EditorSidebar, { layout: layout, highlightEnabled: highlightEnabled, previewMarksNothing: previewMarksNothing, previewInexactMarks: previewInexactMarks, sidebarWidth: sidebarWidth, headerHeight: headerHeight, footerHeight: footerHeight, onLayoutChange: setLayout, onHighlightToggle: () => {
853
+ : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsxs(SiteMdxAllowContext.Provider, { value: mdxAllow, children: [hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
854
+ currentBranch?.status === 'archived', onDiscard: handleDiscardFileDraft })), _jsx(FormRenderer, { fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined,
855
+ // The one writer of drafts. Read-only content gets none, whatever a
856
+ // field emits (MDXEditor's mount-time normalisation included).
857
+ onChange: (next) => {
858
+ const contentId = currentEntry?.contentId;
859
+ if (contentId && !contentReadOnlyRef.current) {
860
+ setDrafts((prev) => ({ ...prev, [contentId]: next }));
861
+ }
862
+ }, customRenderers: customRenderers, branch: branchNameState, comments: comments, currentEntryPath: selectedPath, currentUserId: currentUser, canResolve: canResolveComments, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
863
+ currentBranch?.conflictFiles?.includes(currentEntry.contentId)) })] }) })) : schema.length === 0 ? (_jsx(CenteredMessage, { children: "No fields to edit." })) : loadFailedKey !== `${branchNameState}:${currentEntry.contentId}` ? (_jsx(Stack, { gap: "lg", p: "md", "data-testid": "entry-loading-skeleton", children: [0, 1, 2].map((row) => (_jsxs(Stack, { gap: 6, children: [_jsx(Skeleton, { h: 12, w: 120 }), _jsx(Skeleton, { h: 36 })] }, row))) })) : (_jsx(Alert, { color: "red", variant: "light", m: "md", title: "This entry couldn't be loaded.", "data-testid": "entry-load-failed", children: _jsx(Button, { variant: "default", onClick: () => {
864
+ setLoadFailedKey(null);
865
+ setLoadAttempt((attempt) => attempt + 1);
866
+ }, children: "Retry" }) })) }) }), _jsx(EditorSidebar, { layout: layout, highlightEnabled: highlightEnabled, previewMarksNothing: previewMarksNothing, previewInexactMarks: previewInexactMarks, sidebarWidth: sidebarWidth, headerHeight: headerHeight, footerHeight: footerHeight, onLayoutChange: setLayout, onHighlightToggle: () => {
745
867
  setHighlightEnabled(!highlightEnabled);
746
868
  clearPreviewMarks();
747
- }, onPermissionManagerOpen: () => setPermissionManagerOpen(true), onGroupManagerOpen: () => setGroupManagerOpen(true), onMediaLibraryOpen: () => setMediaLibraryOpen(true), onSystemHealthOpen: showSystemHealth ? () => setSystemHealthOpen(true) : undefined, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick })] }) }), _jsx(EditorFooter, {}), _jsxs(Drawer.Root, { opened: navigatorOpen, onClose: () => setNavigatorOpen(false), position: "left", size: 360, children: [_jsx(Drawer.Overlay, { blur: 2 }), _jsxs(Drawer.Content, { children: [_jsxs(Drawer.Header, { children: [_jsx(Drawer.Title, { children: "Content" }), _jsxs(Group, { gap: "xs", children: [navCollections &&
869
+ }, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onMediaLibraryOpen: () => setMediaLibraryOpen(true), onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick })] }) }), _jsx(EditorFooter, {}), _jsxs(Drawer.Root, { opened: navigatorOpen, onClose: () => setNavigatorOpen(false), position: "left", size: 360, children: [_jsx(Drawer.Overlay, { blur: 2 }), _jsxs(Drawer.Content, { children: [_jsxs(Drawer.Header, { children: [_jsx(Drawer.Title, { children: "Content" }), _jsxs(Group, { gap: "xs", children: [navCollections &&
748
870
  navCollections.length > 0 &&
749
871
  !branchContentLocked &&
750
- (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "sm", "aria-label": "Content actions", children: _jsx(IconDots, { size: 16 }) }) }), _jsxs(Menu.Dropdown, { children: [navCollections[0].onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: () => navCollections[0].onAdd?.(), children: "Add Entry" })), navCollections[0].onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: () => navCollections[0].onAddSubCollection?.(), children: "Add Collection" }))] })] })), _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "sm", onClick: handleCollapseAll, title: "Collapse all folders", "aria-label": "Collapse all folders", children: _jsx(IconChevronUp, { size: 16 }) }), _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "sm", onClick: handleExpandAll, title: "Expand all folders", "aria-label": "Expand all folders", children: _jsx(IconChevronDown, { size: 16 }) }), _jsx(Drawer.CloseButton, {})] })] }), _jsx(Drawer.Body, { p: 0, children: _jsx(Box, { px: "md", children: _jsx(EntryNavigator, { collections: navCollections, items: navCollections
872
+ (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": "Content actions", children: _jsx(IconDots, { size: 16 }) }) }), _jsxs(Menu.Dropdown, { children: [navCollections[0].onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: () => navCollections[0].onAdd?.(), children: "Add Entry" })), navCollections[0].onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: () => navCollections[0].onAddSubCollection?.(), children: "Add Collection" }))] })] })), _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleCollapseAll, title: "Collapse all folders", "aria-label": "Collapse all folders", children: _jsx(IconChevronUp, { size: 16 }) }), _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleExpandAll, title: "Expand all folders", "aria-label": "Expand all folders", children: _jsx(IconChevronDown, { size: 16 }) }), _jsx(Drawer.CloseButton, {})] })] }), _jsx(Drawer.Body, { p: 0, children: _jsx(Box, { px: "md", children: _jsx(EntryNavigator, { collections: navCollections, items: navCollections
751
873
  ? undefined
752
874
  : entriesState.map((e) => ({
753
875
  path: e.path,
@@ -756,7 +878,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
756
878
  })), selectedPath: selectedPath, onSelect: (id) => {
757
879
  setSelectedPath(id);
758
880
  setNavigatorOpen(false);
759
- }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: handleReorderEntry, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
881
+ }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
760
882
  try {
761
883
  await handleBranchChange(name);
762
884
  setBranchManagerOpen(false);
@@ -773,9 +895,9 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
773
895
  handleRequestChanges(name).catch((err) => console.error(err));
774
896
  }, onDelete: (name) => {
775
897
  handleDelete(name).catch((err) => console.error(err));
776
- }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: currentUser, canResolve: canResolveComments, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: handleGetUserMetadata }) }), commentsPanelOpen && branchNameState && (_jsx(CommentsPanel, { branchName: branchNameState, comments: comments, canResolve: true, onAddComment: handleAddComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: handleJumpToField, onGetUserMetadata: handleGetUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch })), _jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), internalGroups: groupsData, loading: groupsLoading, canEdit: true, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups }), _jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: true, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups }), showSystemHealth && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false) })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage" }), _jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection ? (path, entryType) => addEntryType(path, entryType) : undefined, onUpdateEntryType: editingCollection
898
+ }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: currentUser, canResolve: canResolveComments, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined }) }), commentsPanelOpen && branchNameState && (_jsx(CommentsPanel, { branchName: branchNameState, comments: comments, canResolve: true, onAddComment: handleAddComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: handleJumpToField, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch })), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false) })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage" }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection ? (path, entryType) => addEntryType(path, entryType) : undefined, onUpdateEntryType: editingCollection
777
899
  ? (path, name, updates) => updateEntryType(path, name, updates)
778
- : undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError }), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit, onClose: () => {
900
+ : undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit, onClose: () => {
779
901
  setRenameModalOpen(false);
780
902
  setRenamingEntry(null);
781
903
  setRenameModalError(null);
@@ -159,9 +159,9 @@ function RevalidateOnReauth({ reauthCount }) {
159
159
  return null;
160
160
  }
161
161
  function RetryNotice(props) {
162
- return (_jsx(Alert, { color: "red", title: props.title, maw: 480, "data-testid": "canopy-auth-error", children: _jsxs(Stack, { gap: "xs", align: "flex-start", children: [_jsx(Text, { size: "sm", children: props.error }), _jsx(Button, { size: "xs", variant: "light", onClick: props.onRetry, children: "Retry" })] }) }));
162
+ return (_jsx(Alert, { color: "red", title: props.title, maw: 480, "data-testid": "canopy-auth-error", children: _jsxs(Stack, { gap: "xs", align: "flex-start", children: [_jsx(Text, { size: "sm", children: props.error }), _jsx(Button, { variant: "light", onClick: props.onRetry, children: "Retry" })] }) }));
163
163
  }
164
164
  /** Re-checks rather than reloading: in the overlay, a reload would discard the unsaved edits. */
165
165
  function DefaultSignedOutNotice({ onSignedIn, sessionRejected }) {
166
- return (_jsx(Paper, { withBorder: true, p: "xl", radius: "md", maw: 420, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Title, { order: 3, children: "Sign in required" }), _jsx(Text, { size: "sm", children: "You are not signed in to the CMS. Sign in through this site's auth provider, then continue." }), sessionRejected && (_jsx(Text, { size: "sm", c: "red", children: "Still not signed in." })), _jsx(Button, { onClick: onSignedIn, children: "Continue" })] }) }));
166
+ return (_jsx(Paper, { withBorder: true, p: "xl", maw: 420, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Title, { order: 3, children: "Sign in required" }), _jsx(Text, { size: "sm", children: "You are not signed in to the CMS. Sign in through this site's auth provider, then continue." }), sessionRejected && (_jsx(Text, { size: "sm", c: "red", children: "Still not signed in." })), _jsx(Button, { size: "sm", onClick: onSignedIn, children: "Continue" })] }) }));
167
167
  }
@@ -1,8 +1,18 @@
1
1
  import React from 'react';
2
2
  export type PaneLayout = 'side' | 'stacked';
3
+ export declare const SPLIT_PERCENT_MIN = 15;
4
+ export declare const SPLIT_PERCENT_MAX = 85;
5
+ export declare const DEFAULT_SIDE_SPLIT_PERCENT = 52;
6
+ export declare const DEFAULT_STACKED_SPLIT_PERCENT = 58;
3
7
  export interface EditorPanesProps {
4
8
  layout?: PaneLayout;
5
9
  onLayoutChange?: (layout: PaneLayout) => void;
10
+ /** Primary-pane size (percent) in the side layout; follows this prop when it changes. */
11
+ sideSplitPercent?: number;
12
+ /** Primary-pane size (percent) in the stacked layout; follows this prop when it changes. */
13
+ stackedSplitPercent?: number;
14
+ /** Called once per drag, when it ends, with the clamped percent; live dragging never calls it. */
15
+ onSplitPercentChange?: (layout: PaneLayout, percent: number) => void;
6
16
  preview?: React.ReactNode;
7
17
  form?: React.ReactNode;
8
18
  }
@@ -3,17 +3,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useMemo, useRef, useState, useEffect } from 'react';
4
4
  import { Box, Paper, rem } from '@mantine/core';
5
5
  import SplitPane from 'react-split-pane';
6
- export const EditorPanes = ({ layout: layoutProp = 'side', onLayoutChange: _, preview, form, }) => {
6
+ export const SPLIT_PERCENT_MIN = 15;
7
+ export const SPLIT_PERCENT_MAX = 85;
8
+ export const DEFAULT_SIDE_SPLIT_PERCENT = 52;
9
+ export const DEFAULT_STACKED_SPLIT_PERCENT = 58;
10
+ export const EditorPanes = ({ layout: layoutProp = 'side', onLayoutChange: _, sideSplitPercent = DEFAULT_SIDE_SPLIT_PERCENT, stackedSplitPercent = DEFAULT_STACKED_SPLIT_PERCENT, onSplitPercentChange, preview, form, }) => {
7
11
  const TypedSplitPane = SplitPane;
8
12
  const splitContainerRef = useRef(null);
9
13
  const [layout, setLayout] = useState(layoutProp);
10
- const [sidePrimarySize, setSidePrimarySize] = useState(52);
11
- const [stackedPrimarySize, setStackedPrimarySize] = useState(58);
14
+ // Live drag state: the props change only when a drag ends or stored prefs load, so dragging
15
+ // never writes storage.
16
+ const [sidePrimarySize, setSidePrimarySize] = useState(sideSplitPercent);
17
+ const [stackedPrimarySize, setStackedPrimarySize] = useState(stackedSplitPercent);
12
18
  // Turn off iframe/pane pointer events while dragging so the gutter keeps receiving mouse events.
13
19
  const [isDragging, setIsDragging] = useState(false);
20
+ // The last percent a drag produced. `onDragFinished` cannot supply it: with `size` controlled,
21
+ // react-split-pane hands back that prop's string ("37%"), a click with no movement included.
22
+ const dragPercentRef = useRef(undefined);
14
23
  useEffect(() => {
15
24
  setLayout(layoutProp);
16
25
  }, [layoutProp]);
26
+ useEffect(() => {
27
+ setSidePrimarySize(sideSplitPercent);
28
+ }, [sideSplitPercent]);
29
+ useEffect(() => {
30
+ setStackedPrimarySize(stackedSplitPercent);
31
+ }, [stackedSplitPercent]);
17
32
  const direction = layout === 'side' ? 'vertical' : 'horizontal';
18
33
  const primarySize = useMemo(() => (layout === 'side' ? sidePrimarySize : stackedPrimarySize), [layout, sidePrimarySize, stackedPrimarySize]);
19
34
  const resizerStyle = useMemo(() => ({
@@ -36,15 +51,16 @@ export const EditorPanes = ({ layout: layoutProp = 'side', onLayoutChange: _, pr
36
51
  ? (splitContainerRef.current?.clientWidth ?? 0)
37
52
  : (splitContainerRef.current?.clientHeight ?? 0);
38
53
  // Guard: ensure valid dimensions before calculating
39
- if (!total || total <= 0 || nextPixels <= 0)
40
- return;
41
- const percent = Math.min(85, Math.max(15, (nextPixels / total) * 100));
54
+ if (!total || total <= 0 || !Number.isFinite(nextPixels) || nextPixels <= 0)
55
+ return undefined;
56
+ const percent = Math.min(SPLIT_PERCENT_MAX, Math.max(SPLIT_PERCENT_MIN, (nextPixels / total) * 100));
42
57
  if (nextLayout === 'side') {
43
58
  setSidePrimarySize(percent);
44
59
  }
45
60
  else {
46
61
  setStackedPrimarySize(percent);
47
62
  }
63
+ return percent;
48
64
  };
49
65
  return (_jsx(Box, { h: "100%", style: { minHeight: '70vh' }, children: _jsx(Paper, { radius: 0, shadow: "xs", withBorder: true, style: {
50
66
  display: 'flex',
@@ -53,8 +69,16 @@ export const EditorPanes = ({ layout: layoutProp = 'side', onLayoutChange: _, pr
53
69
  overflow: 'hidden',
54
70
  flexDirection: layout === 'side' ? 'row' : 'column',
55
71
  width: '100%',
56
- }, children: _jsx(Box, { ref: splitContainerRef, style: { display: 'flex', flex: 1, minHeight: 0, width: '100%' }, children: _jsxs(TypedSplitPane, { split: direction, minSize: 120, size: `${primarySize}%`, allowResize: true, onChange: (next) => updateSizeFromPixels(next, layout), onDragStarted: () => setIsDragging(true), onDragFinished: (next) => {
57
- updateSizeFromPixels(next, layout);
72
+ }, children: _jsx(Box, { ref: splitContainerRef, style: { display: 'flex', flex: 1, minHeight: 0, width: '100%' }, children: _jsxs(TypedSplitPane, { split: direction, minSize: 120, size: `${primarySize}%`, allowResize: true, onChange: (next) => {
73
+ dragPercentRef.current = updateSizeFromPixels(next, layout) ?? dragPercentRef.current;
74
+ }, onDragStarted: () => {
75
+ dragPercentRef.current = undefined;
76
+ setIsDragging(true);
77
+ }, onDragFinished: () => {
78
+ const percent = dragPercentRef.current;
79
+ dragPercentRef.current = undefined;
80
+ if (percent !== undefined)
81
+ onSplitPercentChange?.(layout, percent);
58
82
  setIsDragging(false);
59
83
  }, style: {
60
84
  position: 'relative',
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import type { ContentFormat, EntrySchema, FieldConfig } from '../config/index.js';
3
3
  import type { CommentThread } from '../comment-store.js';
4
+ import type { UserSearchResult } from '../auth/types.js';
5
+ import type { FieldRemoval } from './undo-removal.js';
4
6
  export type FormValue = Record<string, unknown>;
5
7
  export interface CustomFieldRenderProps {
6
8
  field: FieldConfig;
@@ -8,6 +10,8 @@ export interface CustomFieldRenderProps {
8
10
  onChange: (v: unknown) => void;
9
11
  path: Array<string | number>;
10
12
  id: string;
13
+ /** The branch is locked (protected, or in review): show the value; `onChange` is ignored. */
14
+ readOnly: boolean;
11
15
  }
12
16
  export type CustomFieldRenderers = Record<string, (props: CustomFieldRenderProps) => React.ReactNode>;
13
17
  export interface FormRendererProps {
@@ -35,5 +39,11 @@ export interface FormRendererProps {
35
39
  fieldErrors?: Record<string, string>;
36
40
  /** The entry's format, which decides how its body field is checked. */
37
41
  format?: ContentFormat;
42
+ /** Shows the value without accepting edits. Comments stay open. */
43
+ readOnly?: boolean;
44
+ /** Told of each list item, block or image that Remove takes out, so the caller can offer Undo. */
45
+ onRemoved?: (removal: FieldRemoval) => void;
46
+ /** Resolves comment authors to names in field and entry threads; without it they show as ids. */
47
+ onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
38
48
  }
39
49
  export declare const FormRenderer: React.FC<FormRendererProps>;