canopycms 0.1.0 → 0.2.0-int.121

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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -9,6 +9,8 @@ const noop = () => { };
9
9
  import { EntryNavigator } from './EntryNavigator.js';
10
10
  import { FormRenderer } from './FormRenderer.js';
11
11
  import { PreviewPane } from './PreviewPane.js';
12
+ import { displayPreviewPath, previewPageKey } from './preview-path.js';
13
+ import { showPreviewLinkOutcome } from './components/PreviewLinkToast.js';
12
14
  import { clientOperatingStrategy } from '../operating-mode/client.js';
13
15
  import { EditorShell } from './shell/EditorShell.js';
14
16
  import { PaneLayout } from './shell/PaneLayout.js';
@@ -23,7 +25,7 @@ import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
23
25
  // Import directly from helpers to avoid server-only code in authorization barrel
24
26
  import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
25
27
  import { fieldBreadcrumb } from './field-breadcrumb.js';
26
- import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, } from './editor-utils.js';
28
+ import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, entryTypeLine, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
27
29
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
28
30
  import { useBranchActions } from './hooks/useBranchActions.js';
29
31
  import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
@@ -31,7 +33,7 @@ import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
31
33
  import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
32
34
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
33
35
  import { usePreviewMarks } from './hooks/usePreviewMarks.js';
34
- import { BranchLockBanner, EditorHeader, EditorRail } from './components/index.js';
36
+ import { BranchLockBanner, describeBranchLock, EditorHeader, EditorRail } from './components/index.js';
35
37
  import { showSaveAllResult } from './components/SaveAllResult.js';
36
38
  import { RenameEntryModal } from './components/RenameEntryModal.js';
37
39
  import { QuickOpenPalette } from './components/QuickOpenPalette.js';
@@ -52,7 +54,7 @@ import { hideCollectionOutcomeUnknown } from './hooks/useSchemaManager.js';
52
54
  import { AssetContextProvider, SiteMdxAllowContext, authenticatedAssetBase, useApiClient, } from './context/index.js';
53
55
  import { EntryLinkContext } from './fields/entry-link/index.js';
54
56
  import { MediaLibrary } from './media/MediaLibrary.js';
55
- import { removalAnchors, restoreRemoval, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
57
+ import { removalAnchors, restoreRemoval, movedThreadsNote, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
56
58
  import { formatCanopyPath } from './canopy-path.js';
57
59
  import { itemKeysOnPath, resolveThreadPath, withItemKeys } from '../item-keys.js';
58
60
  import { ENTRY_NOT_LOADED_COMMENT_MESSAGE, NEW_ITEM_COMMENT_MESSAGE, } from './comments/item-anchor-copy.js';
@@ -122,7 +124,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
122
124
  const [deleteInProgress, setDeleteInProgress] = useState(false);
123
125
  // A rename or delete asked for from the top bar's entry menu, whose item is gone once it acts.
124
126
  const [entryChangeFromMenu, setEntryChangeFromMenu] = useState(false);
125
- const allContentCrumbRef = useRef(null);
127
+ const contentToggleRef = useRef(null);
126
128
  // Draft compile/render error reported by the preview page (null = renders cleanly)
127
129
  const [previewError, setPreviewError] = useState(null);
128
130
  const apiClient = useApiClient();
@@ -140,12 +142,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
140
142
  const commentUserId = userContext?.userId ?? currentUser;
141
143
  // Below 64em (1024px) the editor starts stacked and the content panel never docks.
142
144
  const wideWindow = useMediaQuery('(min-width: 64em)', true, { getInitialValueInEffect: false }) !== false;
143
- const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
145
+ const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, siteChrome, setSiteChrome, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
144
146
  storageKey: editorLayoutStorageKey(basePath),
145
147
  defaultLayout: wideWindow ? 'side' : 'stacked',
146
148
  });
147
- // In the stacked layout the content panel lays over the form only, so the preview stays in view.
148
- const contentPanelDocked = layout === 'side' && wideWindow;
149
+ // A wide window docks the content panel: beside the panes side by side, and beside the form row
150
+ // only when stacked, so the preview keeps its full width. A narrow one lays it over the form.
151
+ const contentPanelDocked = wideWindow;
149
152
  // Comments state (shared between useCommentSystem and useBranchManager)
150
153
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
151
154
  // 1. Branch manager (provides branchNameState, no dependencies)
@@ -154,7 +157,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
154
157
  labels: [],
155
158
  }));
156
159
  const applySubmitStampsRef = useRef(() => { });
157
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
160
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
158
161
  initialBranch: branchName,
159
162
  operatingMode,
160
163
  setBusy: setBranchesLoading,
@@ -175,6 +178,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
175
178
  // of unlocked that invites a click the server rejects -- and the pane shows a
176
179
  // loading state during that window anyway.
177
180
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
181
+ const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
182
+ // A row's lock reason on an unanswered branch would be "details could not be loaded", which is
183
+ // not yet true; once the fetch has failed it is, and the lock says so.
184
+ const branchRowsPending = branchLoading && !branchesLoadFailed;
178
185
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
179
186
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
180
187
  initialEntries: entries,
@@ -188,6 +195,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
188
195
  contentRoot,
189
196
  basePath,
190
197
  }),
198
+ resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
191
199
  setBusy: setEntriesLoading,
192
200
  });
193
201
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
@@ -203,9 +211,29 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
203
211
  setSelectedPath(path);
204
212
  setNavigatorOpen(false);
205
213
  };
214
+ // A preview link opens its entry, or a page no entry has in a new tab. The opener is cut by hand,
215
+ // since `noopener` would hide whether the browser blocked the tab.
216
+ const openPreviewLink = (href) => {
217
+ const mount = { basePath, previewPrefix };
218
+ const base = window.location.href;
219
+ const page = previewPageKey(href, mount, base);
220
+ const entry = page === undefined
221
+ ? undefined
222
+ : entriesState.find((e) => e.previewSrc && previewPageKey(e.previewSrc, mount, base) === page);
223
+ if (entry) {
224
+ openEntry(entry.path);
225
+ return;
226
+ }
227
+ const opened = window.open(href, '_blank');
228
+ if (opened)
229
+ opened.opener = null;
230
+ const url = new URL(href);
231
+ const shown = url.origin === window.location.origin ? `${url.pathname}${url.search}` : href;
232
+ showPreviewLinkOutcome(href, displayPreviewPath(shown, mount), Boolean(opened));
233
+ };
206
234
  // An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
207
- // the overlay's, so a docked panel never resurfaces as one. Waits for the stored layout, whose
208
- // arrival could otherwise dock an overlay the stacked layout keeps.
235
+ // the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
236
+ // the open state it sets lands on them, not on the defaults they replace.
209
237
  useEffect(() => {
210
238
  if (layoutLoaded && contentPanelDocked && navigatorOpen) {
211
239
  setContentPanelOpen(true);
@@ -305,7 +333,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
305
333
  contentReadOnlyRef.current = contentReadOnly;
306
334
  // Undo for a form Remove: a toast whose Undo puts the item back into the entry's draft as it
307
335
  // is then, through a functional update, so edits made since are kept. It acts only on the
308
- // branch and entry it was raised for, and its toast closes when either changes.
336
+ // branch and entry it was raised for, and its toast closes when either changes. An object's
337
+ // Clear offers its Undo in the form instead (FormRenderer), which a save ends via `savedValue`.
309
338
  const loadedValuesRef = useRef(loadedValues);
310
339
  loadedValuesRef.current = loadedValues;
311
340
  const draftsRef = useRef(drafts);
@@ -332,7 +361,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
332
361
  ...removal,
333
362
  anchors: displayValue ? removalAnchors(fields, displayValue, removal) : undefined,
334
363
  };
335
- const doing = removal.action === 'clear' ? 'clearing' : 'removing';
336
364
  // Open threads keyed inside what was removed leave their fields for the entry's thread list.
337
365
  const movedThreads = currentEntry
338
366
  ? openThreadsWithin(displayComments, currentEntry.path, removal.kind === 'list-item' ? [...removal.listPath, removal.index] : removal.path)
@@ -350,7 +378,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
350
378
  undoToastsRef.current.delete(id);
351
379
  if (!open) {
352
380
  notifications.show({
353
- message: `Too late to undo ${doing} ${removal.label}.`,
381
+ message: `Too late to undo removing ${removal.label}.`,
354
382
  color: 'yellow',
355
383
  });
356
384
  return;
@@ -363,7 +391,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
363
391
  const now = draftsRef.current[contentId] ?? loadedValuesRef.current[contentId];
364
392
  if (!now || !restoreRemoval(fields, now, anchored)) {
365
393
  notifications.show({
366
- message: `Couldn't undo ${doing} ${removal.label}: that part of the entry has changed. ${removal.action === 'clear' ? 'Fill it in again' : 'Add it again'} if you still need it.`,
394
+ message: `Couldn't undo removing ${removal.label}: that part of the entry has changed. Add it again if you still need it.`,
367
395
  color: 'yellow',
368
396
  });
369
397
  return;
@@ -385,9 +413,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
385
413
  undoToastsRef.current.set(id, contentId);
386
414
  const toast = {
387
415
  id,
388
- message: (_jsx(UndoToastMessage, { label: removal.label, action: removal.action, note: movedThreads > 0
389
- ? `Its ${movedThreads === 1 ? 'comment thread' : `${movedThreads} comment threads`} moved to "Comment on this entry".`
390
- : undefined, onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
416
+ message: (_jsx(UndoToastMessage, { label: removal.label, note: movedThreadsNote(movedThreads), onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
391
417
  autoClose: getNotificationDuration(8000),
392
418
  withCloseButton: true,
393
419
  onClose: () => undoToastsRef.current.delete(id),
@@ -976,8 +1002,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
976
1002
  list.push({
977
1003
  path: entry.path,
978
1004
  label: entry.label,
1005
+ title: entry.title,
979
1006
  slug: entry.slug,
980
1007
  status: entryStatusLabel(activeCollections, entry),
1008
+ missing: entry.status === 'missing' || undefined,
1009
+ noPage: !entry.previewSrc || undefined,
981
1010
  contentId: entry.contentId,
982
1011
  readOnly: entry.canEdit === false || undefined,
983
1012
  conflictNotice: !!(entry.contentId && currentBranch?.conflictFiles?.includes(entry.contentId)),
@@ -1048,13 +1077,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1048
1077
  const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
1049
1078
  const openNavigator = () => {
1050
1079
  navigatorOpenerRef.current = rememberOpener();
1051
- // In the same commit as the overlay mounts, so its focus request meets no inert form panel.
1080
+ // In the commit that opens the overlay, so the form panel is no longer inert when the overlay
1081
+ // mounts, then or a frame later as its slide starts, and serves its focus request.
1052
1082
  if (!contentPanelDocked)
1053
1083
  setPreviewMaximized(false);
1054
1084
  setContentPanelShown(true);
1055
1085
  setContentPanelFocusRequest((request) => request + 1);
1056
1086
  };
1057
- // A collection crumb opens the panel as "All content" does, then its collection's row takes focus.
1087
+ // A collection crumb opens the panel as the rail's Content button does, then its collection's row
1088
+ // takes focus.
1058
1089
  const [collectionReveal, setCollectionReveal] = useState();
1059
1090
  // Only ever rises: the navigator serves each seq once, and the request is cleared once served.
1060
1091
  const revealSeqRef = useRef(0);
@@ -1084,9 +1115,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1084
1115
  })
1085
1116
  .catch(console.error);
1086
1117
  };
1087
- // The entry menu's trigger stands in for its item; once the entry is deleted, "All content".
1118
+ // The entry menu's trigger stands in for its item; once the entry is deleted, the rail's Content
1119
+ // button, where another entry is found.
1088
1120
  const entryChangeReturnTarget = entryChangeFromMenu
1089
- ? () => navigatorToggleRef.current ?? allContentCrumbRef.current
1121
+ ? () => navigatorToggleRef.current ?? contentToggleRef.current
1090
1122
  : undefined;
1091
1123
  const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
1092
1124
  const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
@@ -1145,11 +1177,23 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1145
1177
  entries: entriesState.map((e) => ({
1146
1178
  contentId: e.contentId,
1147
1179
  label: e.label,
1180
+ title: e.title,
1148
1181
  slug: e.slug,
1149
1182
  collectionPath: e.collectionPath,
1150
1183
  collectionName: e.collectionName,
1151
1184
  })),
1152
1185
  }), [entriesState]);
1186
+ // Quick open names each entry's type and route under its label.
1187
+ const quickOpenEntries = useMemo(() => entriesState.map((e) => ({
1188
+ path: e.path,
1189
+ label: e.label,
1190
+ title: e.title,
1191
+ slug: e.slug,
1192
+ collectionPath: e.collectionPath,
1193
+ collectionName: e.collectionName,
1194
+ typeLabel: entryTypeLine(activeCollections, e),
1195
+ route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1196
+ })), [entriesState, activeCollections, previewBaseByCollection, contentRoot]);
1153
1197
  const CenteredMessage = ({ children }) => (_jsx("div", { style: {
1154
1198
  display: 'flex',
1155
1199
  alignItems: 'center',
@@ -1162,11 +1206,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1162
1206
  fields: schema,
1163
1207
  data: displayValue,
1164
1208
  });
1165
- // Only the default preview has the toolbar that maximizes and restores it. With no value to
1166
- // preview (an entry loading, or failed to load, with no draft) the form shows instead, and the
1167
- // choice reapplies once a value arrives.
1209
+ // Only the default preview has the toolbar that maximizes and restores it. It stays maximized
1210
+ // while an entry loads. With no value after a failed load and no draft, the form shows instead,
1211
+ // and the choice reapplies once a value arrives.
1168
1212
  const previewCanMaximize = !renderPreview && Boolean(currentEntry?.previewSrc) && !currentEntryUnavailable;
1169
- const previewShownMaximized = previewMaximized && previewCanMaximize && Boolean(previewFrameData);
1213
+ // The open entry's data is loading, not failed: the preview keeps its last page, dimmed.
1214
+ const previewPending = previewCanMaximize &&
1215
+ !previewFrameData &&
1216
+ loadFailedKey !== `${branchNameState}:${currentEntry?.contentId}`;
1217
+ const previewShownMaximized = previewMaximized && previewCanMaximize && (Boolean(previewFrameData) || previewPending);
1170
1218
  useEffect(() => {
1171
1219
  if (!previewCanMaximize)
1172
1220
  setPreviewMaximized(false);
@@ -1176,8 +1224,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1176
1224
  ? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
1177
1225
  : '';
1178
1226
  // An unavailable entry previews nothing: the site cannot read it, and a stored draft would
1179
- // otherwise mount the frame at a page that answers not-found.
1180
- const defaultPreview = currentEntry?.previewSrc && previewFrameData && !currentEntryUnavailable ? (_jsx(PreviewPane, { src: currentEntry.previewSrc, data: previewFrameData, isLoading: previewLoadingState, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, onMarks: onPreviewMarks, assetBase: authenticatedAssetBase(basePath), mount: { basePath, previewPrefix }, previewWidth: previewWidth, onPreviewWidthChange: setPreviewWidth, onHighlightToggle: () => {
1227
+ // otherwise mount the frame at a page that answers not-found. The pane stays mounted while an
1228
+ // entry loads, so its toolbar never leaves on a switch.
1229
+ const defaultPreview = currentEntry?.previewSrc && (previewFrameData || previewPending) && !currentEntryUnavailable ? (_jsx(PreviewPane, { src: currentEntry.previewSrc, pending: previewPending, data: previewFrameData, isLoading: previewLoadingState, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, onMarks: onPreviewMarks, assetBase: authenticatedAssetBase(basePath), mount: { basePath, previewPrefix }, previewWidth: previewWidth, onPreviewWidthChange: setPreviewWidth, onHighlightToggle: () => {
1181
1230
  setHighlightEnabled(!highlightEnabled);
1182
1231
  clearPreviewMarks();
1183
1232
  }, marksNothing: previewMarksNothing, inexactMarks: previewInexactMarks, showMarkDiagnostics: userIsAdmin, layout: layout, maximized: previewShownMaximized, onMaximizedChange: (maximized) => {
@@ -1185,7 +1234,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1185
1234
  if (maximized && !contentPanelDocked)
1186
1235
  setNavigatorOpen(false);
1187
1236
  setPreviewMaximized(maximized);
1188
- }, resizing: paneResizing, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1237
+ }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, onNavigate: openPreviewLink, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1189
1238
  ? `${previewErrorField}: ${previewError.message}`
1190
1239
  : previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
1191
1240
  display: 'flex',
@@ -1237,10 +1286,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1237
1286
  : entriesState.map((e) => ({
1238
1287
  path: e.path,
1239
1288
  label: e.label,
1289
+ title: e.title,
1240
1290
  slug: e.slug,
1241
1291
  status: entryStatusLabel(activeCollections, e),
1292
+ missing: e.status === 'missing' || undefined,
1293
+ noPage: !e.previewSrc || undefined,
1242
1294
  readOnly: e.canEdit === false || undefined,
1243
- })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1295
+ })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, readOnlyReason: branchRowsPending
1296
+ ? undefined
1297
+ : describeBranchLock({
1298
+ branchStatus: currentBranch?.status,
1299
+ branchReadOnly: currentBranch?.readOnly,
1300
+ branchWriteBlocked: branchContentLocked,
1301
+ }), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1244
1302
  return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount,
1245
1303
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1246
1304
  hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
@@ -1253,7 +1311,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1253
1311
  // fail-closed computation of "are writes blocked" in this component.
1254
1312
  branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1255
1313
  // A failed fetch leaves the list empty too; the banner says that one.
1256
- branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0), onNavigatorOpen: openNavigator, onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), allContentRef: allContentCrumbRef, entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1314
+ branchLoading: branchLoading, onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1257
1315
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1258
1316
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1259
1317
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -1267,14 +1325,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1267
1325
  if (branchNameState && !isSaveAllRunning()) {
1268
1326
  handleWithdraw(branchNameState).catch(console.error);
1269
1327
  }
1270
- } }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, { siteTitle: siteTitle, onHomeOpen: () => {
1271
- const first = entriesState[0];
1272
- if (first)
1273
- openEntry(first.path);
1274
- }, contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelShown ? setContentPanelShown(false) : openNavigator(), onQuickOpen: openQuickOpen, quickOpenShortcut: quickOpenShortcut, quickOpenKeys: quickOpenKeys, branchesOpen: branchManagerOpen, onBranchesOpen: openBranchManager, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
1328
+ } }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelShown ? setContentPanelShown(false) : openNavigator(), contentToggleRef: contentToggleRef, onQuickOpen: openQuickOpen, quickOpenShortcut: quickOpenShortcut, quickOpenKeys: quickOpenKeys, branchesOpen: branchManagerOpen, onBranchesOpen: openBranchManager, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
1275
1329
  branchNameState
1276
1330
  ? () => setCommentsPanelOpen(true)
1277
- : undefined, commentsOpen: commentsPanelOpen, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, mediaLibraryOpen: mediaLibraryOpen, onMediaLibraryOpen: () => setMediaLibraryOpen(true), mediaButtonRef: mediaButtonRef, layout: layout, onLayoutChange: setLayout, layoutBlocked: previewShownMaximized, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, settingsToggleRef: settingsToggleRef, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick }), panel: contentPanelDocked ? contentPanel : undefined, main: _jsx(Box, { h: "100%", children: _jsx(PaneLayout, { formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1331
+ : undefined, commentsOpen: commentsPanelOpen, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, mediaLibraryOpen: mediaLibraryOpen, onMediaLibraryOpen: () => setMediaLibraryOpen(true), mediaButtonRef: mediaButtonRef, layout: layout, onLayoutChange: setLayout, layoutBlocked: previewShownMaximized, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, settingsToggleRef: settingsToggleRef, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick }), panel: contentPanelDocked && layout === 'side' ? contentPanel : undefined, main: _jsx(Box, { h: "100%", children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1278
1332
  // Sizing the pane, by pointer or key, gives the page the pane's width.
1279
1333
  onSeparatorDrag: (dragging) => {
1280
1334
  setPaneResizing(dragging);
@@ -1294,7 +1348,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1294
1348
  // A hidden draft's discard changes nothing on screen, so it says so.
1295
1349
  onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1296
1350
  announce: true,
1297
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1351
+ }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1298
1352
  title: currentEntry.label,
1299
1353
  typeLabel: entryTypeLabel(activeCollections, currentEntry),
1300
1354
  },
@@ -1311,7 +1365,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1311
1365
  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: () => {
1312
1366
  setLoadFailedKey(null);
1313
1367
  setLoadAttempt((attempt) => attempt + 1);
1314
- }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: entriesState,
1368
+ }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: quickOpenEntries,
1315
1369
  // The hidden root's entries sit at the top of the tree, so they get no heading here either.
1316
1370
  collectionLabel: (path) => path === hiddenRootPath ? '' : collectionLabels.get(path), onSelect: setSelectedPath }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress ? 'Wait for Save all to finish.' : undefined, onSelect: async (name) => {
1317
1371
  try {
@@ -1343,7 +1397,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1343
1397
  : undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onReloadEntryTypes: editingCollection ? readEntryTypes : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError, restored: restoredCollectionInput, onDiscardRestored: () => {
1344
1398
  collectionDraftRef.current = null;
1345
1399
  setRestoredCollectionInput(null);
1346
- } })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
1400
+ } })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, nameSource: entryNameSource(activeCollections, renamingEntry, entryValue(renamingEntry)), hasPage: !!renamingEntry.previewSrc, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
1347
1401
  // `renamingEntry` stays until the next rename, so the dialog plays its exit and
1348
1402
  // returns focus.
1349
1403
  onClose: () => {
@@ -5,9 +5,16 @@ type TreeController = ReturnType<typeof useTree>;
5
5
  export interface EntryNavItem {
6
6
  path: LogicalPath;
7
7
  label: string;
8
+ /** The entry's title, matched by the filter where the label is a singleton's type label. */
9
+ title?: string;
8
10
  /** Matched by the filter; the last segment of `path` when absent. */
9
11
  slug?: string;
12
+ /** The row's second line: the entry's type, or "Missing" (`entryStatusLabel`). */
10
13
  status?: string;
14
+ /** The entry's file is gone: its second line is error text. */
15
+ missing?: boolean;
16
+ /** The entry has no page on the site, so its menu changes its slug rather than its URL. */
17
+ noPage?: boolean;
11
18
  collectionPath?: LogicalPath;
12
19
  contentId?: ContentId;
13
20
  /** True when this entry's file conflicted during rebase */
@@ -46,7 +53,7 @@ export interface EntryNavigatorProps {
46
53
  onExpandedStateChange?: (state: Record<string, boolean>) => void;
47
54
  /** Called when user requests to delete an entry */
48
55
  onDeleteEntry?: (path: LogicalPath) => void;
49
- /** Called when user requests to rename an entry */
56
+ /** Called when the user asks to change an entry's URL (its slug) */
50
57
  onRenameEntry?: (path: LogicalPath) => void;
51
58
  /** Called when user reorders an entry within a collection */
52
59
  onReorderEntry?: (collectionPath: LogicalPath, contentId: string, direction: 'up' | 'down') => void;
@@ -55,12 +62,17 @@ export interface EntryNavigatorProps {
55
62
  /** When true and the tree is empty, show a loading indicator instead of "No content" */
56
63
  loading?: boolean;
57
64
  /**
58
- * When true, hides all entry/collection mutation affordances (create,
59
- * rename, delete, reorder, add/edit collection) -- selection/navigation
60
- * stays enabled. Set on a read-only protected base branch, where writes
61
- * would only 403 after the fact. Default false.
65
+ * When true, replaces each row's mutation menu (create, rename, delete, reorder, add/edit
66
+ * collection) with a lock -- selection/navigation stays enabled. Set on a branch that takes no
67
+ * writes, where they would only 403 after the fact. An entry the user may not edit keeps its own
68
+ * lock and gets none here. Default false.
62
69
  */
63
70
  readOnly?: boolean;
71
+ /**
72
+ * Why the branch takes no writes (`describeBranchLock`), the tooltip of each row's lock. While
73
+ * readOnly without one, the lock's slot stays empty.
74
+ */
75
+ readOnlyReason?: string;
64
76
  /** Entry paths with unsaved changes; each shows a dot, and so does every collection holding one. */
65
77
  dirtyPaths?: ReadonlySet<string>;
66
78
  /**