canopycms 0.2.0-int.121 → 0.3.0
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.
- package/CHANGELOG.md +241 -78
- package/dist/api/branch.d.ts +2 -2
- package/dist/cli/cli.js +0 -18
- package/dist/cli/generate-ai-content.js +0 -6
- package/dist/cli/init.js +0 -6
- package/dist/dev-content-watcher.js +8 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +276 -130
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +278 -104
- package/dist/editor/branch-lock.d.ts +42 -0
- package/dist/editor/branch-lock.js +79 -0
- package/dist/editor/branch-status.d.ts +0 -6
- package/dist/editor/branch-status.js +0 -10
- package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
- package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
- package/dist/editor/components/BranchPill.d.ts +3 -18
- package/dist/editor/components/BranchPill.js +7 -10
- package/dist/editor/components/BranchRowLock.d.ts +3 -1
- package/dist/editor/components/BranchRowLock.js +4 -2
- package/dist/editor/components/EditorHeader.d.ts +24 -52
- package/dist/editor/components/EditorHeader.js +151 -71
- package/dist/editor/components/EditorRail.d.ts +8 -8
- package/dist/editor/components/EditorRail.js +7 -5
- package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
- package/dist/editor/components/EntryBreadcrumb.js +3 -3
- package/dist/editor/components/EntryHeader.d.ts +60 -0
- package/dist/editor/components/EntryHeader.js +45 -0
- package/dist/editor/components/EntrySaveButton.d.ts +24 -0
- package/dist/editor/components/EntrySaveButton.js +101 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
- package/dist/editor/components/NoEditPermissionNotice.js +1 -1
- package/dist/editor/components/RetryToastMessage.d.ts +3 -1
- package/dist/editor/components/RetryToastMessage.js +8 -4
- package/dist/editor/components/SaveAllButton.d.ts +25 -0
- package/dist/editor/components/SaveAllButton.js +81 -0
- package/dist/editor/components/UnsavedMark.d.ts +1 -1
- package/dist/editor/components/UnsavedMark.js +1 -1
- package/dist/editor/components/index.d.ts +1 -1
- package/dist/editor/components/index.js +1 -1
- package/dist/editor/copy.d.ts +0 -1
- package/dist/editor/copy.js +0 -1
- package/dist/editor/editor-utils.d.ts +2 -2
- package/dist/editor/editor-utils.js +2 -2
- package/dist/editor/entry-search.d.ts +10 -16
- package/dist/editor/entry-search.js +4 -24
- package/dist/editor/hooks/editor-history.d.ts +7 -0
- package/dist/editor/hooks/editor-history.js +80 -0
- package/dist/editor/hooks/useBranchActions.js +3 -1
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +18 -11
- package/dist/editor/hooks/useEntryManager.d.ts +12 -2
- package/dist/editor/hooks/useEntryManager.js +141 -20
- package/dist/editor/shell/ActivityRail.d.ts +4 -0
- package/dist/editor/shell/ActivityRail.js +13 -4
- package/dist/editor/shell/EditorShell.d.ts +6 -7
- package/dist/editor/shell/EditorShell.js +9 -13
- package/dist/editor/shell/PaneLayout.d.ts +1 -0
- package/dist/editor/shell/PaneLayout.js +23 -5
- package/dist/editor/shell/SidePanelHost.d.ts +11 -4
- package/dist/editor/shell/SidePanelHost.js +95 -7
- package/dist/editor/shell/TopBar.d.ts +4 -4
- package/dist/editor/shell/TopBar.js +6 -3
- package/dist/editor/theme.js +22 -1
- package/dist/editor/utils/focus-return.d.ts +10 -2
- package/dist/editor/utils/focus-return.js +40 -6
- package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
- package/dist/operating-mode/client-safe-strategy.js +1 -10
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/editor/components/BranchLockBanner.d.ts +0 -23
- package/dist/editor/components/BranchLockBanner.js +0 -66
- package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
- package/dist/editor/components/QuickOpenPalette.js +0 -71
- package/dist/editor/components/SaveSplitButton.d.ts +0 -31
- package/dist/editor/components/SaveSplitButton.js +0 -121
package/dist/editor/Editor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
|
4
|
-
import { ActionIcon, Alert, Box, Button, Menu, Paper, Skeleton, Stack, Text, Tooltip, } from '@mantine/core';
|
|
5
|
-
import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus,
|
|
6
|
-
import { useMediaQuery } from '@mantine/hooks';
|
|
4
|
+
import { ActionIcon, Alert, Box, Button, Center, Menu, Paper, Skeleton, Stack, Text, Tooltip, } from '@mantine/core';
|
|
5
|
+
import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } from '@tabler/icons-react';
|
|
6
|
+
import { useHotkeys, useMediaQuery } from '@mantine/hooks';
|
|
7
7
|
import { notifications } from '@mantine/notifications';
|
|
8
8
|
const noop = () => { };
|
|
9
9
|
import { EntryNavigator } from './EntryNavigator.js';
|
|
@@ -13,7 +13,7 @@ import { displayPreviewPath, previewPageKey } from './preview-path.js';
|
|
|
13
13
|
import { showPreviewLinkOutcome } from './components/PreviewLinkToast.js';
|
|
14
14
|
import { clientOperatingStrategy } from '../operating-mode/client.js';
|
|
15
15
|
import { EditorShell } from './shell/EditorShell.js';
|
|
16
|
-
import { PaneLayout } from './shell/PaneLayout.js';
|
|
16
|
+
import { FORM_PANE_ID, PaneLayout } from './shell/PaneLayout.js';
|
|
17
17
|
import { SidePanelHost } from './shell/SidePanelHost.js';
|
|
18
18
|
import { CanopyCMSProvider } from './theme.js';
|
|
19
19
|
import { BranchManager } from './BranchManager.js';
|
|
@@ -25,7 +25,7 @@ import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
|
|
|
25
25
|
// Import directly from helpers to avoid server-only code in authorization barrel
|
|
26
26
|
import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
|
|
27
27
|
import { fieldBreadcrumb } from './field-breadcrumb.js';
|
|
28
|
-
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel,
|
|
28
|
+
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
|
|
29
29
|
import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
|
|
30
30
|
import { useBranchActions } from './hooks/useBranchActions.js';
|
|
31
31
|
import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
|
|
@@ -33,17 +33,17 @@ import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
|
|
|
33
33
|
import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
|
|
34
34
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
35
35
|
import { usePreviewMarks } from './hooks/usePreviewMarks.js';
|
|
36
|
-
import {
|
|
36
|
+
import { branchBarStatus, describeBranchLock } from './branch-lock.js';
|
|
37
|
+
import { EditorHeader, EditorRail, EntryHeader } from './components/index.js';
|
|
37
38
|
import { showSaveAllResult } from './components/SaveAllResult.js';
|
|
38
39
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
39
|
-
import {
|
|
40
|
-
import { rememberOpener, returnTarget } from './utils/focus-return.js';
|
|
40
|
+
import { focusIsIdle, rememberOpener, returnTarget } from './utils/focus-return.js';
|
|
41
41
|
import { modKeyLabel } from './platform.js';
|
|
42
|
-
import { EDITOR_ACTIONS } from './copy.js';
|
|
43
42
|
import { EntryCreateModal } from './components/EntryCreateModal.js';
|
|
44
43
|
import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
|
|
45
44
|
import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
|
|
46
45
|
import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
|
|
46
|
+
import { BranchNotAvailableNotice } from './components/BranchNotAvailableNotice.js';
|
|
47
47
|
import { ReadOnlyDraftNotice } from './components/ReadOnlyDraftNotice.js';
|
|
48
48
|
import { UnavailableEntryNotice } from './components/UnavailableEntryNotice.js';
|
|
49
49
|
import { unavailableSchemaRefs } from './unavailable-entry-type.js';
|
|
@@ -62,6 +62,7 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
|
|
|
62
62
|
import { UndoToastMessage } from './components/UndoToastMessage.js';
|
|
63
63
|
import { getNotificationDuration } from './utils/env.js';
|
|
64
64
|
import { MayHaveLandedError } from './unanswered.js';
|
|
65
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
65
66
|
// Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
|
|
66
67
|
// while no entry is open.
|
|
67
68
|
const EMPTY_SCHEMA = [];
|
|
@@ -80,6 +81,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
80
81
|
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
|
81
82
|
const [permissionManagerOpen, setPermissionManagerOpen] = useState(false);
|
|
82
83
|
const [branchManagerOpen, setBranchManagerOpen] = useState(false);
|
|
84
|
+
// Each opening remounts Branches; one from New branch… starts on its new-branch form.
|
|
85
|
+
const [branchManagerOpening, setBranchManagerOpening] = useState({ seq: 0, create: false });
|
|
86
|
+
const openBranchesDrawer = (create) => {
|
|
87
|
+
branchOpenerRef.current = rememberOpener();
|
|
88
|
+
setBranchManagerOpening(({ seq }) => ({ seq: seq + 1, create }));
|
|
89
|
+
setBranchManagerOpen(true);
|
|
90
|
+
};
|
|
83
91
|
const [mediaLibraryOpen, setMediaLibraryOpen] = useState(false);
|
|
84
92
|
const [systemHealthOpen, setSystemHealthOpen] = useState(false);
|
|
85
93
|
const [collectionEditorOpen, setCollectionEditorOpen] = useState(false);
|
|
@@ -95,8 +103,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
95
103
|
const [restoredCollectionInput, setRestoredCollectionInput] = useState(null);
|
|
96
104
|
// Each open or close of the collection dialog moves it on, so a slow open answers only if current.
|
|
97
105
|
const collectionOpeningRef = useRef(0);
|
|
98
|
-
const [quickOpenOpen, setQuickOpenOpen] = useState(false);
|
|
99
|
-
const [quickOpenFromOverlay, setQuickOpenFromOverlay] = useState(false);
|
|
100
106
|
const navigatorToggleRef = useRef(null);
|
|
101
107
|
const mediaButtonRef = useRef(null);
|
|
102
108
|
const branchToggleRef = useRef(null);
|
|
@@ -105,10 +111,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
105
111
|
const branchOpenerRef = useRef(null);
|
|
106
112
|
// Null when the panel opened from a menu item, which unmounts on click, or with focus on the page.
|
|
107
113
|
const navigatorOpenerRef = useRef(null);
|
|
108
|
-
const
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
quickOpenOpenRef.current = quickOpenOpen;
|
|
111
|
-
}, [quickOpenOpen]);
|
|
114
|
+
const entryFilterRef = useRef(null);
|
|
112
115
|
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
|
113
116
|
const [renamingEntry, setRenamingEntry] = useState(null);
|
|
114
117
|
const [renameModalError, setRenameModalError] = useState(null);
|
|
@@ -157,7 +160,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
157
160
|
labels: [],
|
|
158
161
|
}));
|
|
159
162
|
const applySubmitStampsRef = useRef(() => { });
|
|
160
|
-
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
163
|
+
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
161
164
|
initialBranch: branchName,
|
|
162
165
|
operatingMode,
|
|
163
166
|
setBusy: setBranchesLoading,
|
|
@@ -179,9 +182,23 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
179
182
|
// loading state during that window anyway.
|
|
180
183
|
const branchContentLocked = currentBranch?.writeBlocked ?? true;
|
|
181
184
|
const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
const
|
|
185
|
+
// No lock reason is true yet while the list is unanswered; once it has failed, or answered
|
|
186
|
+
// without this branch, the lock says which.
|
|
187
|
+
const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
|
|
188
|
+
const branchMissing = !currentBranch && branchesLoaded;
|
|
189
|
+
// The bar's lock-state row, with the inputs EditorHeader gets, for the states that must agree.
|
|
190
|
+
const branchBarState = branchBarStatus({
|
|
191
|
+
branchStatus: currentBranch?.status,
|
|
192
|
+
branchReadOnly: currentBranch?.readOnly,
|
|
193
|
+
branchWriteBlocked: branchContentLocked,
|
|
194
|
+
branchPending,
|
|
195
|
+
branchMissing,
|
|
196
|
+
isProtected: currentBranch?.isProtected,
|
|
197
|
+
}).value;
|
|
198
|
+
// With no branch named, the bar shows no row, so neither do the empty states.
|
|
199
|
+
const branchNotAvailable = !!branchNameState && branchBarState === 'not-available';
|
|
200
|
+
// Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
|
|
201
|
+
const switchBranchRef = useRef(async () => { });
|
|
185
202
|
// 2. Entry manager (depends on branchNameState, owns selectedPath)
|
|
186
203
|
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({
|
|
187
204
|
initialEntries: entries,
|
|
@@ -197,7 +214,18 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
197
214
|
}),
|
|
198
215
|
resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
|
|
199
216
|
setBusy: setEntriesLoading,
|
|
217
|
+
branchBarState,
|
|
218
|
+
switchBranch: (branch) => switchBranchRef.current(branch),
|
|
219
|
+
// To the entry menu, which names the entry opened; a dialog keeps focus.
|
|
220
|
+
onHistoryRestore: () => requestAnimationFrame(() => {
|
|
221
|
+
if (!document.activeElement?.closest('[role="dialog"]')) {
|
|
222
|
+
navigatorToggleRef.current?.focus({ preventScroll: true });
|
|
223
|
+
}
|
|
224
|
+
}),
|
|
200
225
|
});
|
|
226
|
+
// The "Not available" empty states wait for the load to answer with nothing: content that does
|
|
227
|
+
// load, for a branch the list lags in granting, shows as usual.
|
|
228
|
+
const branchShowsNotAvailable = branchNotAvailable && !entriesInitializing && entriesState.length === 0;
|
|
201
229
|
// The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
|
|
202
230
|
// overlay's. Anything that focuses something in the form reveals it first. Opening an entry
|
|
203
231
|
// closes the overlay only, so the preview stays maximized across entries (see
|
|
@@ -296,6 +324,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
296
324
|
onBranchCreated: addCreatedBranch,
|
|
297
325
|
userId: userContext?.userId,
|
|
298
326
|
});
|
|
327
|
+
switchBranchRef.current = async (branch) => {
|
|
328
|
+
const listed = (name) => name === branch || name === sanitizeBranchName(branch);
|
|
329
|
+
if (branchSummaries.length > 0 && !branchSummaries.some((b) => listed(b.name))) {
|
|
330
|
+
notifications.show({
|
|
331
|
+
message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchNameState}”.`,
|
|
332
|
+
color: 'yellow',
|
|
333
|
+
});
|
|
334
|
+
throw new Error(`Branch ${branch} is not listed`);
|
|
335
|
+
}
|
|
336
|
+
await handleBranchChange(branch);
|
|
337
|
+
};
|
|
299
338
|
// 5. Comment system (depends on branchNameState)
|
|
300
339
|
const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
|
|
301
340
|
branchName: branchNameState,
|
|
@@ -304,11 +343,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
304
343
|
currentUser: commentUserId,
|
|
305
344
|
canResolveComments: canResolveAnyComment,
|
|
306
345
|
setSelectedPath,
|
|
307
|
-
setBranchManagerOpen: (open) =>
|
|
308
|
-
if (open)
|
|
309
|
-
branchOpenerRef.current = rememberOpener();
|
|
310
|
-
setBranchManagerOpen(open);
|
|
311
|
-
},
|
|
346
|
+
setBranchManagerOpen: (open) => open ? openBranchesDrawer(false) : setBranchManagerOpen(false),
|
|
312
347
|
onCommentsChange: setCommentsForBranchSummaries,
|
|
313
348
|
onRevealForm: revealForm,
|
|
314
349
|
});
|
|
@@ -885,8 +920,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
885
920
|
setDeletingEntryReferencedBy(result.referencedBy);
|
|
886
921
|
return;
|
|
887
922
|
}
|
|
888
|
-
if (result.ok
|
|
889
|
-
closeEntry();
|
|
923
|
+
if (result.ok)
|
|
924
|
+
closeEntry(deletingEntryPath, branchNameState);
|
|
890
925
|
closeDeleteEntryModal();
|
|
891
926
|
}
|
|
892
927
|
finally {
|
|
@@ -1005,6 +1040,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1005
1040
|
title: entry.title,
|
|
1006
1041
|
slug: entry.slug,
|
|
1007
1042
|
status: entryStatusLabel(activeCollections, entry),
|
|
1043
|
+
route: buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
|
|
1008
1044
|
missing: entry.status === 'missing' || undefined,
|
|
1009
1045
|
noPage: !entry.previewSrc || undefined,
|
|
1010
1046
|
contentId: entry.contentId,
|
|
@@ -1057,16 +1093,29 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1057
1093
|
onCreateEntry,
|
|
1058
1094
|
handleCreateEntry,
|
|
1059
1095
|
contentRoot,
|
|
1096
|
+
previewBaseByCollection,
|
|
1060
1097
|
currentBranch,
|
|
1061
1098
|
schemaUnavailableKeys,
|
|
1062
1099
|
userIsAdmin,
|
|
1063
1100
|
]);
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1101
|
+
const findShortcut = modKeyLabel() === '⌘' ? { label: '⌘P', keys: 'Meta+P' } : { label: 'Ctrl+P', keys: 'Control+P' };
|
|
1102
|
+
// Mod+P opens a closed docked panel only until Enter, Escape from inside it or focus leaves it;
|
|
1103
|
+
// the stored preference stays closed. A row menu, the Content button or any other ask opens it
|
|
1104
|
+
// for good.
|
|
1105
|
+
const [contentPanelTransient, setContentPanelTransient] = useState(false);
|
|
1106
|
+
if (contentPanelTransient && !contentPanelDocked)
|
|
1107
|
+
setContentPanelTransient(false);
|
|
1066
1108
|
// Docked, the content panel's open state is the stored preference; as an overlay it is
|
|
1067
1109
|
// `navigatorOpen`, which starts open when the URL asks for no entry.
|
|
1068
|
-
const contentPanelShown = contentPanelDocked
|
|
1069
|
-
|
|
1110
|
+
const contentPanelShown = contentPanelDocked
|
|
1111
|
+
? contentPanelOpen || contentPanelTransient
|
|
1112
|
+
: navigatorOpen;
|
|
1113
|
+
const setContentPanelShown = (open) => {
|
|
1114
|
+
if (!contentPanelDocked)
|
|
1115
|
+
return setNavigatorOpen(open);
|
|
1116
|
+
setContentPanelOpen(open);
|
|
1117
|
+
setContentPanelTransient(false);
|
|
1118
|
+
};
|
|
1070
1119
|
// The overlay lives in the form pane, under a maximized preview.
|
|
1071
1120
|
const contentOverlayShown = !contentPanelDocked && contentPanelShown;
|
|
1072
1121
|
useEffect(() => {
|
|
@@ -1075,30 +1124,78 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1075
1124
|
}, [contentOverlayShown]);
|
|
1076
1125
|
// Bumped on every ask for the panel, so one already open still takes focus.
|
|
1077
1126
|
const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
|
|
1078
|
-
const openNavigator = () => {
|
|
1079
|
-
|
|
1127
|
+
const openNavigator = ({ transient = false } = {}) => {
|
|
1128
|
+
// Focus already in the panel isn't an opener: the panel's own close would hand focus back to it.
|
|
1129
|
+
// Nor is the page, once the panel is shown: focus dropped there from where it came.
|
|
1130
|
+
const opener = rememberOpener();
|
|
1131
|
+
if (!document.getElementById(CONTENT_PANEL_ID)?.contains(document.activeElement) &&
|
|
1132
|
+
(opener || !contentPanelShown)) {
|
|
1133
|
+
navigatorOpenerRef.current = opener;
|
|
1134
|
+
}
|
|
1080
1135
|
// In the commit that opens the overlay, so the form panel is no longer inert when the overlay
|
|
1081
1136
|
// mounts, then or a frame later as its slide starts, and serves its focus request.
|
|
1082
1137
|
if (!contentPanelDocked)
|
|
1083
1138
|
setPreviewMaximized(false);
|
|
1084
|
-
|
|
1139
|
+
// A pending Enter focus nulls the panel's return of focus, and only Enter's close may lose it.
|
|
1140
|
+
setFormFocusPath(null);
|
|
1141
|
+
if (transient && contentPanelDocked && !contentPanelOpen)
|
|
1142
|
+
setContentPanelTransient(true);
|
|
1143
|
+
else
|
|
1144
|
+
setContentPanelShown(true);
|
|
1085
1145
|
setContentPanelFocusRequest((request) => request + 1);
|
|
1086
1146
|
};
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1147
|
+
const keepContentPanelOpen = () => {
|
|
1148
|
+
if (contentPanelTransient)
|
|
1149
|
+
setContentPanelShown(true);
|
|
1150
|
+
};
|
|
1151
|
+
const [formFocusPath, setFormFocusPath] = useState(null);
|
|
1152
|
+
const formFocusRestoredRef = useRef(false);
|
|
1153
|
+
// `[]` (tagsToIgnore) and `true` (contenteditable) let it fire from fields and the MDX editor.
|
|
1154
|
+
useHotkeys([
|
|
1155
|
+
[
|
|
1156
|
+
'mod+P',
|
|
1157
|
+
(event) => {
|
|
1158
|
+
// Mantine's `mod` is Ctrl or ⌘ everywhere; on macOS Ctrl+P moves the caret up a line.
|
|
1159
|
+
if (modKeyLabel() === '⌘' && !event.metaKey)
|
|
1160
|
+
return;
|
|
1161
|
+
// Keeps the browser's print dialog shut, even when the panel can't open.
|
|
1162
|
+
event.preventDefault();
|
|
1163
|
+
// Opened under a modal or drawer, it would take the keyboard from the one in use.
|
|
1164
|
+
if (document.querySelector('[role="dialog"][aria-modal="true"]'))
|
|
1165
|
+
return;
|
|
1166
|
+
setChangedOnly(false);
|
|
1167
|
+
const field = entryFilterRef.current;
|
|
1168
|
+
if (field && document.activeElement === field)
|
|
1169
|
+
field.select();
|
|
1170
|
+
else
|
|
1171
|
+
openNavigator({ transient: true });
|
|
1172
|
+
},
|
|
1173
|
+
{ preventDefault: false },
|
|
1174
|
+
],
|
|
1175
|
+
], [], true);
|
|
1176
|
+
// A collection crumb, or the entry menu's "Show in content panel", opens the panel as the rail's
|
|
1177
|
+
// Content button does, then the row takes focus.
|
|
1178
|
+
const [contentReveal, setContentReveal] = useState();
|
|
1090
1179
|
// Only ever rises: the navigator serves each seq once, and the request is cleared once served.
|
|
1091
1180
|
const revealSeqRef = useRef(0);
|
|
1092
|
-
const
|
|
1181
|
+
const revealInContentPanel = (reveal) => {
|
|
1093
1182
|
openNavigator();
|
|
1094
1183
|
revealSeqRef.current += 1;
|
|
1095
|
-
|
|
1184
|
+
setContentReveal({ ...reveal, seq: revealSeqRef.current });
|
|
1096
1185
|
};
|
|
1097
1186
|
// An ask the panel closed on before serving it must not fire when the panel next opens.
|
|
1098
1187
|
useEffect(() => {
|
|
1099
1188
|
if (!contentPanelShown)
|
|
1100
|
-
|
|
1189
|
+
setContentReveal(undefined);
|
|
1101
1190
|
}, [contentPanelShown]);
|
|
1191
|
+
// The content panel's All / Changed filter. It stays as chosen, emptied by a save or not, except
|
|
1192
|
+
// that Mod+P finds among all entries and another branch has its own unsaved set: both reset it.
|
|
1193
|
+
const [changedOnly, setChangedOnly] = useState(false);
|
|
1194
|
+
useEffect(() => setChangedOnly(false), [branchNameState]);
|
|
1195
|
+
const showUnsaved = () => {
|
|
1196
|
+
setChangedOnly(true);
|
|
1197
|
+
revealInContentPanel({ kind: 'changed' });
|
|
1198
|
+
};
|
|
1102
1199
|
const runSaveAll = () => {
|
|
1103
1200
|
void handleSaveAll()
|
|
1104
1201
|
.then((result) => {
|
|
@@ -1123,18 +1220,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1123
1220
|
const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
|
|
1124
1221
|
const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
|
|
1125
1222
|
const settingsReturnTarget = () => settingsToggleRef.current;
|
|
1126
|
-
//
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
1133
|
-
if (!contentPanelDocked)
|
|
1134
|
-
setNavigatorOpen(false);
|
|
1135
|
-
setQuickOpenOpen(true);
|
|
1136
|
-
};
|
|
1137
|
-
const dirtyPaths = useMemo(() => new Set(editedFiles.map((file) => file.path)), [editedFiles]);
|
|
1223
|
+
// Keyed by its members, so the same set keeps its identity as drafts change on every keystroke.
|
|
1224
|
+
const dirtyKey = editedFiles
|
|
1225
|
+
.map((file) => file.path)
|
|
1226
|
+
.sort()
|
|
1227
|
+
.join('\n');
|
|
1228
|
+
const dirtyPaths = useMemo(() => new Set(dirtyKey ? dirtyKey.split('\n') : []), [dirtyKey]);
|
|
1138
1229
|
// Slugs of the entries beside the one being renamed, for the duplicate check.
|
|
1139
1230
|
const renameExistingSlugs = useMemo(() => new Set(entriesState
|
|
1140
1231
|
.filter((e) => e.collectionPath === renamingEntry?.collectionPath &&
|
|
@@ -1183,17 +1274,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1183
1274
|
collectionName: e.collectionName,
|
|
1184
1275
|
})),
|
|
1185
1276
|
}), [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]);
|
|
1197
1277
|
const CenteredMessage = ({ children }) => (_jsx("div", { style: {
|
|
1198
1278
|
display: 'flex',
|
|
1199
1279
|
alignItems: 'center',
|
|
@@ -1219,6 +1299,54 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1219
1299
|
if (!previewCanMaximize)
|
|
1220
1300
|
setPreviewMaximized(false);
|
|
1221
1301
|
}, [previewCanMaximize]);
|
|
1302
|
+
// Enter in the transient panel focuses the opened entry's first field once it renders, else the
|
|
1303
|
+
// form pane; focus the user moved by then stays. A tree click, preview link or Back moves none.
|
|
1304
|
+
useEffect(() => {
|
|
1305
|
+
if (!formFocusPath)
|
|
1306
|
+
return;
|
|
1307
|
+
if (selectedPath !== formFocusPath)
|
|
1308
|
+
return setFormFocusPath(null);
|
|
1309
|
+
if (currentEntry?.path !== formFocusPath)
|
|
1310
|
+
return;
|
|
1311
|
+
const formShown = schema.length > 0 && !!displayValue;
|
|
1312
|
+
const fieldless = currentEntryUnavailable ||
|
|
1313
|
+
currentEntry.canEdit === false ||
|
|
1314
|
+
schema.length === 0 ||
|
|
1315
|
+
loadFailedKey === `${branchNameState}:${currentEntry.contentId}`;
|
|
1316
|
+
if (!formShown && !fieldless)
|
|
1317
|
+
return;
|
|
1318
|
+
// Restoring the preview hands focus to its Maximize button; that isn't the user moving on.
|
|
1319
|
+
const focusedByRestore = formFocusRestoredRef.current ? document.activeElement : null;
|
|
1320
|
+
formFocusRestoredRef.current = false;
|
|
1321
|
+
const frame = requestAnimationFrame(() => {
|
|
1322
|
+
if (!focusIsIdle() && document.activeElement !== focusedByRestore) {
|
|
1323
|
+
return setFormFocusPath(null);
|
|
1324
|
+
}
|
|
1325
|
+
// The form is in view first; the next run focuses it.
|
|
1326
|
+
if (previewShownMaximized) {
|
|
1327
|
+
formFocusRestoredRef.current = true;
|
|
1328
|
+
return setPreviewMaximized(false);
|
|
1329
|
+
}
|
|
1330
|
+
setFormFocusPath(null);
|
|
1331
|
+
const pane = document.getElementById(FORM_PANE_ID);
|
|
1332
|
+
const field = formShown ? pane?.querySelector('[data-canopy-field]') : null;
|
|
1333
|
+
if (field)
|
|
1334
|
+
focusFieldTarget(field);
|
|
1335
|
+
else
|
|
1336
|
+
pane?.focus({ preventScroll: true });
|
|
1337
|
+
});
|
|
1338
|
+
return () => cancelAnimationFrame(frame);
|
|
1339
|
+
}, [
|
|
1340
|
+
formFocusPath,
|
|
1341
|
+
selectedPath,
|
|
1342
|
+
currentEntry,
|
|
1343
|
+
currentEntryUnavailable,
|
|
1344
|
+
schema.length,
|
|
1345
|
+
displayValue,
|
|
1346
|
+
loadFailedKey,
|
|
1347
|
+
branchNameState,
|
|
1348
|
+
previewShownMaximized,
|
|
1349
|
+
]);
|
|
1222
1350
|
const [paneResizing, setPaneResizing] = useState(false);
|
|
1223
1351
|
const previewErrorField = previewError?.fieldPath
|
|
1224
1352
|
? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
|
|
@@ -1240,13 +1368,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1240
1368
|
display: 'flex',
|
|
1241
1369
|
alignItems: 'center',
|
|
1242
1370
|
justifyContent: 'center',
|
|
1243
|
-
}, children: _jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
|
|
1371
|
+
}, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
|
|
1244
1372
|
? 'Setting up your branch workspace…'
|
|
1245
1373
|
: entriesInitializing
|
|
1246
1374
|
? 'Loading content…'
|
|
1247
1375
|
: currentEntry && !currentEntry.previewSrc
|
|
1248
1376
|
? 'No preview for this entry.'
|
|
1249
|
-
: 'Select an item to start editing.' }) }));
|
|
1377
|
+
: 'Select an item to start editing.' })) }));
|
|
1250
1378
|
const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
|
|
1251
1379
|
// The browser tab names the open entry and the site.
|
|
1252
1380
|
const tabTitle = currentEntry
|
|
@@ -1266,9 +1394,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1266
1394
|
document.title = tabTitle;
|
|
1267
1395
|
writtenTitleRef.current = tabTitle;
|
|
1268
1396
|
}, [tabTitle]);
|
|
1269
|
-
const openBranchManager = () => {
|
|
1270
|
-
|
|
1271
|
-
setBranchManagerOpen(true);
|
|
1397
|
+
const openBranchManager = (options) => {
|
|
1398
|
+
openBranchesDrawer(options?.create === true);
|
|
1272
1399
|
// Always retry, not only when branchless. Opening the manager is
|
|
1273
1400
|
// the one in-app retry for a failed branches fetch, and the fetch
|
|
1274
1401
|
// is terminal on its own: SWRProvider sets shouldRetryOnError and
|
|
@@ -1276,12 +1403,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1276
1403
|
// is 0 without data. Reloading is idempotent.
|
|
1277
1404
|
loadBranches().catch(console.error);
|
|
1278
1405
|
};
|
|
1279
|
-
const contentPanel = (_jsx(SidePanelHost, { id: CONTENT_PANEL_ID, title: "Content", mode: contentPanelDocked ? 'docked' : 'overlay', opened: contentPanelShown, width: contentPanelWidth, minWidth: CONTENT_PANEL_WIDTH_MIN, maxWidth: CONTENT_PANEL_WIDTH_MAX, onWidthChange: setContentPanelWidth, onClose: () => setContentPanelShown(false), focusRequest: contentPanelFocusRequest,
|
|
1280
|
-
//
|
|
1281
|
-
returnFocusTo: () => (
|
|
1406
|
+
const contentPanel = (_jsx(SidePanelHost, { id: CONTENT_PANEL_ID, title: "Content", mode: contentPanelDocked ? 'docked' : 'overlay', opened: contentPanelShown, width: contentPanelWidth, minWidth: CONTENT_PANEL_WIDTH_MIN, maxWidth: CONTENT_PANEL_WIDTH_MAX, onWidthChange: setContentPanelWidth, onClose: () => setContentPanelShown(false), focusRequest: contentPanelFocusRequest, closeOnEscape: contentPanelTransient,
|
|
1407
|
+
// An entry opened by Enter takes focus in its form instead.
|
|
1408
|
+
returnFocusTo: () => (formFocusPath ? null : navigatorReturnTarget()), onFocusLeave: contentPanelTransient
|
|
1409
|
+
? (target) => {
|
|
1410
|
+
if (!contentToggleRef.current?.contains(target))
|
|
1411
|
+
setContentPanelTransient(false);
|
|
1412
|
+
}
|
|
1413
|
+
: undefined, "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
|
|
1282
1414
|
navCollections.length > 0 &&
|
|
1283
1415
|
!branchContentLocked &&
|
|
1284
|
-
(navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end",
|
|
1416
|
+
(navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end",
|
|
1417
|
+
// Its items open dialogs outside the panel, which would close a transient one.
|
|
1418
|
+
onOpen: keepContentPanelOpen, children: [_jsx(Tooltip, { label: "Content actions", withinPortal: true, 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\u2026" })), navCollections[0].onAddSubCollection && (_jsxs(_Fragment, { children: [navCollections[0].onAdd && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" }), _jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: () => navCollections[0].onAddSubCollection?.(), children: "Add collection\u2026" })] }))] })] })), _jsx(Tooltip, { label: "Collapse all folders", withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleCollapseAll, "aria-label": "Collapse all folders", children: _jsx(IconChevronUp, { size: 16 }) }) }), _jsx(Tooltip, { label: "Expand all folders", withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleExpandAll, "aria-label": "Expand all folders", children: _jsx(IconChevronDown, { size: 16 }) }) })] }), children: _jsx(Box, { px: "md", children: _jsx(EntryNavigator, { collections: navCollections, items: navCollections
|
|
1285
1419
|
? undefined
|
|
1286
1420
|
: entriesState.map((e) => ({
|
|
1287
1421
|
path: e.path,
|
|
@@ -1289,85 +1423,97 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1289
1423
|
title: e.title,
|
|
1290
1424
|
slug: e.slug,
|
|
1291
1425
|
status: entryStatusLabel(activeCollections, e),
|
|
1426
|
+
route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
|
|
1292
1427
|
missing: e.status === 'missing' || undefined,
|
|
1293
1428
|
noPage: !e.previewSrc || undefined,
|
|
1294
1429
|
readOnly: e.canEdit === false || 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:
|
|
1430
|
+
})), 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: branchPending
|
|
1296
1431
|
? undefined
|
|
1297
1432
|
: describeBranchLock({
|
|
1298
1433
|
branchStatus: currentBranch?.status,
|
|
1299
1434
|
branchReadOnly: currentBranch?.readOnly,
|
|
1300
1435
|
branchWriteBlocked: branchContentLocked,
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1436
|
+
branchMissing,
|
|
1437
|
+
}), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), changedOnly: changedOnly, onChangedOnlyChange: setChangedOnly, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
|
|
1438
|
+
? (path) => {
|
|
1439
|
+
openEntry(path);
|
|
1440
|
+
setContentPanelTransient(false);
|
|
1441
|
+
setFormFocusPath(path);
|
|
1442
|
+
}
|
|
1443
|
+
: undefined, onFilterEscape: contentPanelTransient ? () => setContentPanelTransient(false) : undefined, onRowMenuOpen: keepContentPanelOpen }) }) }));
|
|
1444
|
+
return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
|
|
1305
1445
|
// Fail-closed for the same reason branchContentLocked above is:
|
|
1306
|
-
// isProtected/writeBlocked gate real actions in EditorHeader
|
|
1307
|
-
// Submit, disabling Save), so a missing wire value must read as
|
|
1446
|
+
// isProtected/writeBlocked gate real actions in EditorHeader and EntryHeader
|
|
1447
|
+
// (hiding Submit, disabling Save and Save all), so a missing wire value must read as
|
|
1308
1448
|
// "protected"/"blocked", not "unprotected"/"unblocked".
|
|
1309
1449
|
// branchWriteBlocked reuses branchContentLocked itself rather than
|
|
1310
1450
|
// re-deriving `?? true` a second time here, so there is exactly one
|
|
1311
1451
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1312
|
-
branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
|
|
1313
|
-
// A failed fetch leaves the list empty too; the banner says that one.
|
|
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: () => {
|
|
1452
|
+
branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
|
|
1315
1453
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1316
|
-
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager,
|
|
1317
|
-
|
|
1318
|
-
|
|
1454
|
+
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
|
|
1455
|
+
// On any entry: EntryHeader's `saving` is the open entry's alone.
|
|
1456
|
+
singleSaving: singleSaving !== null,
|
|
1319
1457
|
// Save all finds each draft's entry in the list, so with none listed it would save nothing.
|
|
1320
|
-
entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: () => {
|
|
1458
|
+
entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: (returnFocusTo) => {
|
|
1321
1459
|
if (branchNameState && !isSaveAllRunning()) {
|
|
1322
|
-
handleSubmit(branchNameState).catch(console.error);
|
|
1460
|
+
handleSubmit(branchNameState, returnFocusTo).catch(console.error);
|
|
1323
1461
|
}
|
|
1324
|
-
}, onWithdraw: () => {
|
|
1462
|
+
}, onWithdraw: (returnFocusTo) => {
|
|
1325
1463
|
if (branchNameState && !isSaveAllRunning()) {
|
|
1326
|
-
handleWithdraw(branchNameState).catch(console.error);
|
|
1464
|
+
handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
|
|
1327
1465
|
}
|
|
1328
|
-
} }),
|
|
1466
|
+
} }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelTransient
|
|
1467
|
+
? keepContentPanelOpen()
|
|
1468
|
+
: contentPanelShown
|
|
1469
|
+
? setContentPanelShown(false)
|
|
1470
|
+
: openNavigator(), contentToggleRef: contentToggleRef, contentShortcut: findShortcut, unsavedCount: modifiedCount, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
|
|
1329
1471
|
branchNameState
|
|
1330
1472
|
? () => setCommentsPanelOpen(true)
|
|
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:
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1473
|
+
: 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: _jsxs(Box, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(EntryHeader, { currentEntry: currentEntry,
|
|
1474
|
+
// True exactly while the form below shows "Setting up…" or "Loading content…".
|
|
1475
|
+
entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
|
|
1476
|
+
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1477
|
+
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1478
|
+
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
1479
|
+
lastSaved.contentId === currentEntry?.contentId }), _jsx(Box, { style: { flex: '1 1 0%', minHeight: 0 }, children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
|
|
1480
|
+
// Sizing the pane, by pointer or key, gives the page the pane's width.
|
|
1481
|
+
onSeparatorDrag: (dragging) => {
|
|
1482
|
+
setPaneResizing(dragging);
|
|
1483
|
+
if (dragging)
|
|
1484
|
+
setPreviewWidth('fit');
|
|
1485
|
+
}, arrangement: layout, sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: (arrangement, percent) => {
|
|
1486
|
+
setSplitPercent(arrangement, percent);
|
|
1487
|
+
setPreviewWidth('fit');
|
|
1488
|
+
}, preview: renderPreview && currentEntry && !currentEntryUnavailable
|
|
1489
|
+
? renderPreview(currentEntry, displayValue)
|
|
1490
|
+
: defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
|
|
1491
|
+
? 'Setting up your branch workspace…'
|
|
1492
|
+
: entriesInitializing
|
|
1493
|
+
? 'Loading content…'
|
|
1494
|
+
: 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryLabel: currentEntry.label })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
|
|
1495
|
+
currentBranch?.status === 'archived',
|
|
1496
|
+
// A hidden draft's discard changes nothing on screen, so it says so.
|
|
1497
|
+
onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
|
|
1498
|
+
announce: true,
|
|
1499
|
+
}) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
|
|
1500
|
+
title: currentEntry.label,
|
|
1501
|
+
typeLabel: entryTypeLabel(activeCollections, currentEntry),
|
|
1502
|
+
},
|
|
1503
|
+
// The one writer of drafts. Read-only content gets none, whatever a
|
|
1504
|
+
// field emits (MDXEditor's mount-time normalisation included).
|
|
1505
|
+
onChange: (next) => {
|
|
1506
|
+
const contentId = currentEntry?.contentId;
|
|
1507
|
+
if (contentId && !contentReadOnlyRef.current) {
|
|
1508
|
+
// Every list item keeps a key, whatever field made it.
|
|
1509
|
+
const keyed = withItemKeys(schema, next, 'random');
|
|
1510
|
+
setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
|
|
1511
|
+
}
|
|
1512
|
+
}, customRenderers: customRenderers, branch: branchNameState, comments: displayComments, currentEntryPath: selectedPath, currentUserId: commentUserId, canResolve: canResolveAnyComment, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
|
|
1513
|
+
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: () => {
|
|
1514
|
+
setLoadFailedKey(null);
|
|
1515
|
+
setLoadAttempt((attempt) => attempt + 1);
|
|
1516
|
+
}, children: "Retry" }) })) }) })] }), overlays: _jsxs(_Fragment, { children: [_jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { startCreating: branchManagerOpening.create, branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress ? 'Wait for Save all to finish.' : undefined, onSelect: async (name) => {
|
|
1371
1517
|
try {
|
|
1372
1518
|
await handleBranchChange(name);
|
|
1373
1519
|
setBranchManagerOpen(false);
|
|
@@ -1390,7 +1536,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1390
1536
|
handleRequestChanges(name).catch((err) => console.error(err));
|
|
1391
1537
|
}, onDelete: (name) => {
|
|
1392
1538
|
handleDelete(name).catch((err) => console.error(err));
|
|
1393
|
-
}, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }) }), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchNameState, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, 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), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
|
|
1539
|
+
}, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchNameState, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, 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), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
|
|
1394
1540
|
? (path, entryType) => addEntryType(path, entryType)
|
|
1395
1541
|
: undefined, onUpdateEntryType: editingCollection
|
|
1396
1542
|
? (path, name, updates) => updateEntryType(path, name, updates)
|