canopycms 0.1.0-int.115 → 0.1.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 +267 -231
- package/README.md +0 -1
- package/dist/ai/references.js +3 -1
- package/dist/api/assets.js +2 -2
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch.js +1 -1
- package/dist/api/content.js +1 -1
- package/dist/api/entries-constants.d.ts +1 -1
- package/dist/api/entries-constants.js +1 -1
- package/dist/api/entries.d.ts +2 -2
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/cli/cli.js +16 -14
- package/dist/cli/generate-ai-content.js +7 -5
- package/dist/cli/init.js +4 -4
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/config/schemas/config.d.ts +0 -5
- package/dist/config/schemas/config.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.js +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-store.d.ts +1 -1
- package/dist/content-store.js +1 -1
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +5 -4
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.js +3 -6
- package/dist/editor/Editor.d.ts +0 -2
- package/dist/editor/Editor.js +329 -181
- package/dist/editor/EditorAuthGate.js +8 -3
- package/dist/editor/EntryNavigator.d.ts +10 -0
- package/dist/editor/EntryNavigator.js +51 -2
- package/dist/editor/FormHeader.js +1 -1
- package/dist/editor/PreviewFrame.d.ts +5 -1
- package/dist/editor/PreviewFrame.js +3 -2
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/comments/form-comments.js +2 -1
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/EditorHeader.d.ts +50 -108
- package/dist/editor/components/EditorHeader.js +117 -138
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
- package/dist/editor/components/QuickOpenPalette.js +3 -2
- package/dist/editor/components/RenameEntryModal.d.ts +3 -1
- package/dist/editor/components/RenameEntryModal.js +3 -2
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/editor-utils.d.ts +11 -6
- package/dist/editor/editor-utils.js +8 -17
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
- package/dist/editor/hooks/useCommentSystem.js +3 -0
- package/dist/editor/hooks/useDraftManager.d.ts +24 -1
- package/dist/editor/hooks/useDraftManager.js +297 -16
- package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
- package/dist/editor/hooks/useEditorLayout.js +13 -25
- package/dist/editor/hooks/useEntryManager.d.ts +4 -0
- package/dist/editor/hooks/useEntryManager.js +30 -7
- package/dist/editor/media/MediaLibrary.d.ts +1 -1
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/theme.js +18 -3
- package/dist/git-manager.d.ts +1 -1
- package/dist/git-manager.js +2 -2
- package/dist/operating-mode/types.d.ts +1 -1
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/resolver.d.ts +1 -1
- package/dist/schema/resolver.js +1 -1
- package/dist/schema/schema-store.d.ts +1 -1
- package/dist/schema/schema-store.js +2 -2
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.js +1 -1
- package/dist/utils/content-write-lock.d.ts +1 -1
- package/dist/utils/content-write-lock.js +1 -1
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.js +27 -5
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -1
- package/dist/worker/cms-worker.js +2 -2
- package/dist/worker/github-auth.d.ts +1 -1
- package/dist/worker/github-auth.js +1 -1
- package/dist/worker/github-gateway-wire.d.ts +2 -1
- package/dist/worker/github-gateway-wire.js +7 -3
- package/dist/worker/github-gateway.js +1 -1
- package/dist/worker/github-mirror.d.ts +2 -2
- package/dist/worker/github-mirror.js +2 -2
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/self-checks.d.ts +1 -1
- package/dist/worker/shared-repo-git.js +1 -1
- package/package.json +5 -4
- package/dist/editor/EditorPanes.d.ts +0 -20
- package/dist/editor/EditorPanes.js +0 -124
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -70
- package/dist/editor/components/EditorSidebar.js +0 -50
package/dist/editor/Editor.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
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,
|
|
4
|
+
import { ActionIcon, Alert, Box, Button, Menu, Paper, Skeleton, Stack, Text, Tooltip, } from '@mantine/core';
|
|
5
5
|
import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, IconSearch, } from '@tabler/icons-react';
|
|
6
|
+
import { useMediaQuery } from '@mantine/hooks';
|
|
6
7
|
import { notifications } from '@mantine/notifications';
|
|
7
8
|
const noop = () => { };
|
|
8
9
|
import { EntryNavigator } from './EntryNavigator.js';
|
|
9
10
|
import { FormRenderer } from './FormRenderer.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { PreviewPane } from './PreviewPane.js';
|
|
12
|
+
import { clientOperatingStrategy } from '../operating-mode/client.js';
|
|
13
|
+
import { EditorShell } from './shell/EditorShell.js';
|
|
14
|
+
import { PaneLayout } from './shell/PaneLayout.js';
|
|
15
|
+
import { SidePanelHost } from './shell/SidePanelHost.js';
|
|
12
16
|
import { CanopyCMSProvider } from './theme.js';
|
|
13
17
|
import { BranchManager } from './BranchManager.js';
|
|
14
18
|
import { BranchesDrawer } from './components/BranchesDrawer.js';
|
|
@@ -22,16 +26,18 @@ import { fieldBreadcrumb } from './field-breadcrumb.js';
|
|
|
22
26
|
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, } from './editor-utils.js';
|
|
23
27
|
import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
|
|
24
28
|
import { useBranchActions } from './hooks/useBranchActions.js';
|
|
25
|
-
import { editorLayoutStorageKey } from './hooks/useEditorLayout.js';
|
|
29
|
+
import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
|
|
26
30
|
import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
|
|
27
31
|
import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
|
|
28
32
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
29
33
|
import { usePreviewMarks } from './hooks/usePreviewMarks.js';
|
|
30
|
-
import {
|
|
34
|
+
import { BranchLockBanner, EditorHeader, EditorRail } from './components/index.js';
|
|
35
|
+
import { showSaveAllResult } from './components/SaveAllResult.js';
|
|
31
36
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
37
|
+
import { QuickOpenPalette } from './components/QuickOpenPalette.js';
|
|
38
|
+
import { rememberOpener, returnTarget } from './utils/focus-return.js';
|
|
34
39
|
import { modKeyLabel } from './platform.js';
|
|
40
|
+
import { EDITOR_ACTIONS } from './copy.js';
|
|
35
41
|
import { EntryCreateModal } from './components/EntryCreateModal.js';
|
|
36
42
|
import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
|
|
37
43
|
import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
|
|
@@ -57,13 +63,14 @@ import { MayHaveLandedError } from './unanswered.js';
|
|
|
57
63
|
// Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
|
|
58
64
|
// while no entry is open.
|
|
59
65
|
const EMPTY_SCHEMA = [];
|
|
66
|
+
const CONTENT_PANEL_ID = 'canopy-content-panel';
|
|
60
67
|
const EMPTY_VALUE = {};
|
|
61
68
|
/**
|
|
62
69
|
* High-level editor wrapper that wires entry navigation, form rendering,
|
|
63
70
|
* saving/loading, and preview rendering using entry definitions.
|
|
64
71
|
*/
|
|
65
72
|
export const Editor = (props) => (_jsx(EditorCrashBoundary, { themeOptions: props.themeOptions, children: _jsx(EditorContent, { ...props }) }));
|
|
66
|
-
const EditorContent = ({ entries, title,
|
|
73
|
+
const EditorContent = ({ entries, title, siteTitle = title, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, previewPrefix, mdxAllow, currentUser = 'current-user', canResolveComments = true, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
|
|
67
74
|
const [branchesLoading, setBranchesLoading] = useState(false);
|
|
68
75
|
const [entriesLoading, setEntriesLoading] = useState(false);
|
|
69
76
|
const [commentsLoading] = useState(false);
|
|
@@ -87,13 +94,14 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
87
94
|
// Each open or close of the collection dialog moves it on, so a slow open answers only if current.
|
|
88
95
|
const collectionOpeningRef = useRef(0);
|
|
89
96
|
const [quickOpenOpen, setQuickOpenOpen] = useState(false);
|
|
90
|
-
const [
|
|
97
|
+
const [quickOpenFromOverlay, setQuickOpenFromOverlay] = useState(false);
|
|
91
98
|
const navigatorToggleRef = useRef(null);
|
|
99
|
+
const mediaButtonRef = useRef(null);
|
|
92
100
|
const branchToggleRef = useRef(null);
|
|
93
101
|
const settingsToggleRef = useRef(null);
|
|
94
102
|
// Null when the branches drawer opened from a menu item; see `rememberOpener`.
|
|
95
103
|
const branchOpenerRef = useRef(null);
|
|
96
|
-
// Null when the
|
|
104
|
+
// Null when the panel opened from a menu item, which unmounts on click, or with focus on the page.
|
|
97
105
|
const navigatorOpenerRef = useRef(null);
|
|
98
106
|
const quickOpenOpenRef = useRef(false);
|
|
99
107
|
useEffect(() => {
|
|
@@ -107,9 +115,14 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
107
115
|
const [deletingCollectionPath, setDeletingCollectionPath] = useState(null);
|
|
108
116
|
const [deleteEntryModalOpen, setDeleteEntryModalOpen] = useState(false);
|
|
109
117
|
const [deletingEntryPath, setDeletingEntryPath] = useState(null);
|
|
118
|
+
// Named at open: a delete that lands takes the entry out of the list while the dialog fades.
|
|
119
|
+
const [deletingEntryLabel, setDeletingEntryLabel] = useState();
|
|
110
120
|
// Set once the server refuses a delete because other entries reference the entry.
|
|
111
121
|
const [deletingEntryReferencedBy, setDeletingEntryReferencedBy] = useState(null);
|
|
112
122
|
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
|
123
|
+
// A rename or delete asked for from the top bar's entry menu, whose item is gone once it acts.
|
|
124
|
+
const [entryChangeFromMenu, setEntryChangeFromMenu] = useState(false);
|
|
125
|
+
const allContentCrumbRef = useRef(null);
|
|
113
126
|
// Draft compile/render error reported by the preview page (null = renders cleanly)
|
|
114
127
|
const [previewError, setPreviewError] = useState(null);
|
|
115
128
|
const apiClient = useApiClient();
|
|
@@ -125,7 +138,14 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
125
138
|
const canResolveAnyComment = canResolveComments && isReviewer(userContext?.groups);
|
|
126
139
|
// The signed-in user, so "started by me" matches the thread authors the server records.
|
|
127
140
|
const commentUserId = userContext?.userId ?? currentUser;
|
|
128
|
-
|
|
141
|
+
// Below 64em (1024px) the editor starts stacked and the content panel never docks.
|
|
142
|
+
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({
|
|
144
|
+
storageKey: editorLayoutStorageKey(basePath),
|
|
145
|
+
defaultLayout: wideWindow ? 'side' : 'stacked',
|
|
146
|
+
});
|
|
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;
|
|
129
149
|
// Comments state (shared between useCommentSystem and useBranchManager)
|
|
130
150
|
const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
|
|
131
151
|
// 1. Branch manager (provides branchNameState, no dependencies)
|
|
@@ -156,7 +176,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
156
176
|
// loading state during that window anyway.
|
|
157
177
|
const branchContentLocked = currentBranch?.writeBlocked ?? true;
|
|
158
178
|
// 2. Entry manager (depends on branchNameState, owns selectedPath)
|
|
159
|
-
const { selectedPath, setSelectedPath, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
|
|
179
|
+
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({
|
|
160
180
|
initialEntries: entries,
|
|
161
181
|
initialSelectedId,
|
|
162
182
|
branchName: branchNameState,
|
|
@@ -170,6 +190,28 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
170
190
|
}),
|
|
171
191
|
setBusy: setEntriesLoading,
|
|
172
192
|
});
|
|
193
|
+
// The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
|
|
194
|
+
// overlay's. Anything that focuses something in the form reveals it first. Opening an entry
|
|
195
|
+
// closes the overlay only, so the preview stays maximized across entries (see
|
|
196
|
+
// `previewShownMaximized` for the loads in between).
|
|
197
|
+
const [previewMaximized, setPreviewMaximized] = useState(false);
|
|
198
|
+
const revealForm = () => {
|
|
199
|
+
setNavigatorOpen(false);
|
|
200
|
+
setPreviewMaximized(false);
|
|
201
|
+
};
|
|
202
|
+
const openEntry = (path) => {
|
|
203
|
+
setSelectedPath(path);
|
|
204
|
+
setNavigatorOpen(false);
|
|
205
|
+
};
|
|
206
|
+
// 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.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (layoutLoaded && contentPanelDocked && navigatorOpen) {
|
|
211
|
+
setContentPanelOpen(true);
|
|
212
|
+
setNavigatorOpen(false);
|
|
213
|
+
}
|
|
214
|
+
}, [layoutLoaded, contentPanelDocked, navigatorOpen, setContentPanelOpen, setNavigatorOpen]);
|
|
173
215
|
// Keeps the entry-type list referentially stable while the create modal shows
|
|
174
216
|
// the same collection: a new array on every render would churn Mantine's
|
|
175
217
|
// Select `data` identity and give EntryCreateModal's form-seeding effect a
|
|
@@ -193,7 +235,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
193
235
|
setPreviewError(null);
|
|
194
236
|
}, [currentEntry?.contentId]);
|
|
195
237
|
// 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
|
|
196
|
-
const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
|
|
238
|
+
const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
|
|
197
239
|
branchName: branchNameState,
|
|
198
240
|
selectedPath,
|
|
199
241
|
currentEntry,
|
|
@@ -207,6 +249,8 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
207
249
|
followMovedEntry,
|
|
208
250
|
recreateEntry,
|
|
209
251
|
setBusy: setEntriesLoading,
|
|
252
|
+
// The errors are in the form.
|
|
253
|
+
onSaveBlocked: () => setPreviewMaximized(false),
|
|
210
254
|
onSaved: () => {
|
|
211
255
|
void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
|
|
212
256
|
},
|
|
@@ -238,6 +282,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
238
282
|
setBranchManagerOpen(open);
|
|
239
283
|
},
|
|
240
284
|
onCommentsChange: setCommentsForBranchSummaries,
|
|
285
|
+
onRevealForm: revealForm,
|
|
241
286
|
});
|
|
242
287
|
const { groupsData, groupsLoading, handleSaveGroups, handleSearchUsers, handleGetUserMetadata, handleSearchExternalGroups, } = useGroupManager({ isOpen: groupManagerOpen });
|
|
243
288
|
// The comments and branches responses name their people, without email; a privileged lookup
|
|
@@ -330,6 +375,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
330
375
|
return restored ? { ...prev, [contentId]: restored } : prev;
|
|
331
376
|
});
|
|
332
377
|
// The toast's Undo button unmounts with it, so focus goes to the restored row once rendered.
|
|
378
|
+
revealForm();
|
|
333
379
|
requestAnimationFrame(() => {
|
|
334
380
|
const target = findFieldTarget(restoredPath);
|
|
335
381
|
if (target)
|
|
@@ -790,18 +836,18 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
790
836
|
setDeleteInProgress(false);
|
|
791
837
|
}
|
|
792
838
|
};
|
|
793
|
-
const handleDeleteEntry = (entryPath) => {
|
|
839
|
+
const handleDeleteEntry = (entryPath, fromMenu = false) => {
|
|
840
|
+
setEntryChangeFromMenu(fromMenu);
|
|
794
841
|
setDeletingEntryPath(entryPath);
|
|
842
|
+
setDeletingEntryLabel(entriesState.find((e) => e.path === entryPath)?.label);
|
|
795
843
|
setDeletingEntryReferencedBy(null);
|
|
796
844
|
setDeleteEntryModalOpen(true);
|
|
797
845
|
};
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
setDeletingEntryPath(null);
|
|
801
|
-
setDeletingEntryReferencedBy(null);
|
|
802
|
-
};
|
|
846
|
+
// What the dialog names stays until the next delete, so its exit shows no change of text.
|
|
847
|
+
const closeDeleteEntryModal = () => setDeleteEntryModalOpen(false);
|
|
803
848
|
const confirmDeleteEntry = async () => {
|
|
804
|
-
|
|
849
|
+
// Its button stays clickable while the closed dialog fades; that click deletes nothing.
|
|
850
|
+
if (!deleteEntryModalOpen || !deletingEntryPath)
|
|
805
851
|
return;
|
|
806
852
|
setDeleteInProgress(true);
|
|
807
853
|
try {
|
|
@@ -813,35 +859,33 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
813
859
|
setDeletingEntryReferencedBy(result.referencedBy);
|
|
814
860
|
return;
|
|
815
861
|
}
|
|
816
|
-
if (result.ok && selectedPath === deletingEntryPath)
|
|
817
|
-
|
|
818
|
-
}
|
|
862
|
+
if (result.ok && selectedPath === deletingEntryPath)
|
|
863
|
+
closeEntry();
|
|
819
864
|
closeDeleteEntryModal();
|
|
820
865
|
}
|
|
821
866
|
finally {
|
|
822
867
|
setDeleteInProgress(false);
|
|
823
868
|
}
|
|
824
869
|
};
|
|
825
|
-
const handleRenameEntry = (entryPath) => {
|
|
826
|
-
// Find the entry being renamed
|
|
870
|
+
const handleRenameEntry = (entryPath, fromMenu = false) => {
|
|
827
871
|
const entry = entriesState.find((e) => e.path === entryPath);
|
|
828
872
|
if (!entry) {
|
|
829
873
|
notifications.show({ message: 'Entry not found', color: 'red' });
|
|
830
874
|
return;
|
|
831
875
|
}
|
|
876
|
+
setEntryChangeFromMenu(fromMenu);
|
|
832
877
|
setRenamingEntry(entry);
|
|
833
878
|
setRenameModalError(null);
|
|
834
879
|
setRenameModalOpen(true);
|
|
835
880
|
};
|
|
836
881
|
const handleRenameSubmit = async (newSlug) => {
|
|
837
|
-
if (!renamingEntry)
|
|
882
|
+
if (!renameModalOpen || !renamingEntry)
|
|
838
883
|
return;
|
|
839
884
|
setRenameModalSaving(true);
|
|
840
885
|
setRenameModalError(null);
|
|
841
886
|
try {
|
|
842
887
|
await renameEntry(renamingEntry.path, newSlug);
|
|
843
888
|
setRenameModalOpen(false);
|
|
844
|
-
setRenamingEntry(null);
|
|
845
889
|
}
|
|
846
890
|
catch (err) {
|
|
847
891
|
setRenameModalError({
|
|
@@ -989,25 +1033,83 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
989
1033
|
userIsAdmin,
|
|
990
1034
|
]);
|
|
991
1035
|
const quickOpenShortcut = modKeyLabel() === '⌘' ? '⌘P' : 'Ctrl+P';
|
|
1036
|
+
const quickOpenKeys = modKeyLabel() === '⌘' ? 'Meta+P' : 'Control+P';
|
|
1037
|
+
// Docked, the content panel's open state is the stored preference; as an overlay it is
|
|
1038
|
+
// `navigatorOpen`, which starts open when the URL asks for no entry.
|
|
1039
|
+
const contentPanelShown = contentPanelDocked ? contentPanelOpen : navigatorOpen;
|
|
1040
|
+
const setContentPanelShown = (open) => contentPanelDocked ? setContentPanelOpen(open) : setNavigatorOpen(open);
|
|
1041
|
+
// The overlay lives in the form pane, under a maximized preview.
|
|
1042
|
+
const contentOverlayShown = !contentPanelDocked && contentPanelShown;
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
if (contentOverlayShown)
|
|
1045
|
+
setPreviewMaximized(false);
|
|
1046
|
+
}, [contentOverlayShown]);
|
|
1047
|
+
// Bumped on every ask for the panel, so one already open still takes focus.
|
|
1048
|
+
const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
|
|
992
1049
|
const openNavigator = () => {
|
|
993
1050
|
navigatorOpenerRef.current = rememberOpener();
|
|
994
|
-
|
|
1051
|
+
// In the same commit as the overlay mounts, so its focus request meets no inert form panel.
|
|
1052
|
+
if (!contentPanelDocked)
|
|
1053
|
+
setPreviewMaximized(false);
|
|
1054
|
+
setContentPanelShown(true);
|
|
1055
|
+
setContentPanelFocusRequest((request) => request + 1);
|
|
1056
|
+
};
|
|
1057
|
+
// A collection crumb opens the panel as "All content" does, then its collection's row takes focus.
|
|
1058
|
+
const [collectionReveal, setCollectionReveal] = useState();
|
|
1059
|
+
// Only ever rises: the navigator serves each seq once, and the request is cleared once served.
|
|
1060
|
+
const revealSeqRef = useRef(0);
|
|
1061
|
+
const revealCollection = (path) => {
|
|
1062
|
+
openNavigator();
|
|
1063
|
+
revealSeqRef.current += 1;
|
|
1064
|
+
setCollectionReveal({ path, seq: revealSeqRef.current });
|
|
995
1065
|
};
|
|
1066
|
+
// An ask the panel closed on before serving it must not fire when the panel next opens.
|
|
1067
|
+
useEffect(() => {
|
|
1068
|
+
if (!contentPanelShown)
|
|
1069
|
+
setCollectionReveal(undefined);
|
|
1070
|
+
}, [contentPanelShown]);
|
|
1071
|
+
const runSaveAll = () => {
|
|
1072
|
+
void handleSaveAll()
|
|
1073
|
+
.then((result) => {
|
|
1074
|
+
if (!result)
|
|
1075
|
+
return;
|
|
1076
|
+
showSaveAllResult(result, {
|
|
1077
|
+
// Focus goes to the entry's menu, where its reason's "Refresh from server" is.
|
|
1078
|
+
onOpenEntry: (path) => {
|
|
1079
|
+
openEntry(path);
|
|
1080
|
+
requestAnimationFrame(() => navigatorToggleRef.current?.focus());
|
|
1081
|
+
},
|
|
1082
|
+
onRetry: runSaveAll,
|
|
1083
|
+
});
|
|
1084
|
+
})
|
|
1085
|
+
.catch(console.error);
|
|
1086
|
+
};
|
|
1087
|
+
// The entry menu's trigger stands in for its item; once the entry is deleted, "All content".
|
|
1088
|
+
const entryChangeReturnTarget = entryChangeFromMenu
|
|
1089
|
+
? () => navigatorToggleRef.current ?? allContentCrumbRef.current
|
|
1090
|
+
: undefined;
|
|
996
1091
|
const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
|
|
997
1092
|
const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
|
|
998
1093
|
const settingsReturnTarget = () => settingsToggleRef.current;
|
|
1094
|
+
// The palette replaces the overlay panel; a docked one stays. Opened from inside the overlay,
|
|
1095
|
+
// whose tree then unmounts, it returns focus to the panel's opener.
|
|
999
1096
|
const openQuickOpen = () => {
|
|
1000
|
-
if (!quickOpenOpen)
|
|
1001
|
-
|
|
1002
|
-
|
|
1097
|
+
if (!quickOpenOpen) {
|
|
1098
|
+
const panel = document.getElementById(CONTENT_PANEL_ID);
|
|
1099
|
+
setQuickOpenFromOverlay(!contentPanelDocked && navigatorOpen && !!panel?.contains(document.activeElement));
|
|
1100
|
+
}
|
|
1101
|
+
if (!contentPanelDocked)
|
|
1102
|
+
setNavigatorOpen(false);
|
|
1003
1103
|
setQuickOpenOpen(true);
|
|
1004
1104
|
};
|
|
1005
1105
|
const dirtyPaths = useMemo(() => new Set(editedFiles.map((file) => file.path)), [editedFiles]);
|
|
1006
1106
|
// Slugs of the entries beside the one being renamed, for the duplicate check.
|
|
1007
1107
|
const renameExistingSlugs = useMemo(() => new Set(entriesState
|
|
1008
|
-
.filter((e) => e.collectionPath === renamingEntry?.collectionPath &&
|
|
1108
|
+
.filter((e) => e.collectionPath === renamingEntry?.collectionPath &&
|
|
1109
|
+
e.contentId !== renamingEntry?.contentId &&
|
|
1110
|
+
e.slug)
|
|
1009
1111
|
.map((e) => e.slug)), [entriesState, renamingEntry]);
|
|
1010
|
-
// Tree expansion state - persists across
|
|
1112
|
+
// Tree expansion state - persists across panel close/open
|
|
1011
1113
|
const treeExpandedStateRef = useRef({});
|
|
1012
1114
|
const treeControllerRef = useRef(null);
|
|
1013
1115
|
const handleTreeControllerReady = (controller) => {
|
|
@@ -1060,15 +1162,32 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
1060
1162
|
fields: schema,
|
|
1061
1163
|
data: displayValue,
|
|
1062
1164
|
});
|
|
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.
|
|
1168
|
+
const previewCanMaximize = !renderPreview && Boolean(currentEntry?.previewSrc) && !currentEntryUnavailable;
|
|
1169
|
+
const previewShownMaximized = previewMaximized && previewCanMaximize && Boolean(previewFrameData);
|
|
1170
|
+
useEffect(() => {
|
|
1171
|
+
if (!previewCanMaximize)
|
|
1172
|
+
setPreviewMaximized(false);
|
|
1173
|
+
}, [previewCanMaximize]);
|
|
1174
|
+
const [paneResizing, setPaneResizing] = useState(false);
|
|
1175
|
+
const previewErrorField = previewError?.fieldPath
|
|
1176
|
+
? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
|
|
1177
|
+
: '';
|
|
1063
1178
|
// An unavailable entry previews nothing: the site cannot read it, and a stored draft would
|
|
1064
1179
|
// otherwise mount the frame at a page that answers not-found.
|
|
1065
|
-
const defaultPreview = currentEntry?.previewSrc && previewFrameData && !currentEntryUnavailable ? (
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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: () => {
|
|
1181
|
+
setHighlightEnabled(!highlightEnabled);
|
|
1182
|
+
clearPreviewMarks();
|
|
1183
|
+
}, marksNothing: previewMarksNothing, inexactMarks: previewInexactMarks, showMarkDiagnostics: userIsAdmin, layout: layout, maximized: previewShownMaximized, onMaximizedChange: (maximized) => {
|
|
1184
|
+
// A docked panel stays beside a maximized preview; an overlay one would sit inert under it.
|
|
1185
|
+
if (maximized && !contentPanelDocked)
|
|
1186
|
+
setNavigatorOpen(false);
|
|
1187
|
+
setPreviewMaximized(maximized);
|
|
1188
|
+
}, resizing: paneResizing, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
|
|
1189
|
+
? `${previewErrorField}: ${previewError.message}`
|
|
1190
|
+
: previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
|
|
1072
1191
|
display: 'flex',
|
|
1073
1192
|
alignItems: 'center',
|
|
1074
1193
|
justifyContent: 'center',
|
|
@@ -1079,146 +1198,175 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
1079
1198
|
: currentEntry && !currentEntry.previewSrc
|
|
1080
1199
|
? 'No preview for this entry.'
|
|
1081
1200
|
: 'Select an item to start editing.' }) }));
|
|
1082
|
-
const sidebarWidth = 64;
|
|
1083
|
-
const footerHeight = 40;
|
|
1084
|
-
const headerTitle = currentEntry?.label ?? currentEntry?.slug ?? title;
|
|
1085
1201
|
const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
//
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1202
|
+
// The browser tab names the open entry and the site.
|
|
1203
|
+
const tabTitle = currentEntry
|
|
1204
|
+
? `${currentEntry.label ?? currentEntry.slug} · ${siteTitle}`
|
|
1205
|
+
: siteTitle;
|
|
1206
|
+
// The host page's title comes back on unmount only if the tab still shows the editor's: a route
|
|
1207
|
+
// navigated to may have set its own by then.
|
|
1208
|
+
const writtenTitleRef = useRef(null);
|
|
1209
|
+
useEffect(() => {
|
|
1210
|
+
const hostTitle = document.title;
|
|
1211
|
+
return () => {
|
|
1212
|
+
if (document.title === writtenTitleRef.current)
|
|
1213
|
+
document.title = hostTitle;
|
|
1214
|
+
};
|
|
1215
|
+
}, []);
|
|
1216
|
+
useEffect(() => {
|
|
1217
|
+
document.title = tabTitle;
|
|
1218
|
+
writtenTitleRef.current = tabTitle;
|
|
1219
|
+
}, [tabTitle]);
|
|
1220
|
+
const openBranchManager = () => {
|
|
1221
|
+
branchOpenerRef.current = rememberOpener();
|
|
1222
|
+
setBranchManagerOpen(true);
|
|
1223
|
+
// Always retry, not only when branchless. Opening the manager is
|
|
1224
|
+
// the one in-app retry for a failed branches fetch, and the fetch
|
|
1225
|
+
// is terminal on its own: SWRProvider sets shouldRetryOnError and
|
|
1226
|
+
// revalidateOnFocus false, and useBranchesData's refreshInterval
|
|
1227
|
+
// is 0 without data. Reloading is idempotent.
|
|
1228
|
+
loadBranches().catch(console.error);
|
|
1229
|
+
};
|
|
1230
|
+
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,
|
|
1231
|
+
// Quick open takes focus as it replaces the overlay.
|
|
1232
|
+
returnFocusTo: () => (quickOpenOpen ? null : navigatorReturnTarget()), "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
|
|
1233
|
+
navCollections.length > 0 &&
|
|
1234
|
+
!branchContentLocked &&
|
|
1235
|
+
(navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", 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: `${EDITOR_ACTIONS.openEntry} (${quickOpenShortcut})`, withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: openQuickOpen, "aria-label": EDITOR_ACTIONS.openEntry, "aria-keyshortcuts": quickOpenKeys, "data-testid": "quick-open-button", children: _jsx(IconSearch, { size: 16 }) }) }), _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
|
|
1236
|
+
? undefined
|
|
1237
|
+
: entriesState.map((e) => ({
|
|
1238
|
+
path: e.path,
|
|
1239
|
+
label: e.label,
|
|
1240
|
+
slug: e.slug,
|
|
1241
|
+
status: entryStatusLabel(activeCollections, e),
|
|
1242
|
+
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) }) }) }));
|
|
1244
|
+
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
|
+
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1246
|
+
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
|
|
1247
|
+
// Fail-closed for the same reason branchContentLocked above is:
|
|
1248
|
+
// isProtected/writeBlocked gate real actions in EditorHeader (hiding
|
|
1249
|
+
// Submit, disabling Save), so a missing wire value must read as
|
|
1250
|
+
// "protected"/"blocked", not "unprotected"/"unblocked".
|
|
1251
|
+
// branchWriteBlocked reuses branchContentLocked itself rather than
|
|
1252
|
+
// re-deriving `?? true` a second time here, so there is exactly one
|
|
1253
|
+
// fail-closed computation of "are writes blocked" in this component.
|
|
1254
|
+
branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
|
|
1255
|
+
// 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: () => {
|
|
1257
|
+
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1258
|
+
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1259
|
+
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
1260
|
+
lastSaved.contentId === currentEntry?.contentId,
|
|
1261
|
+
// Save all finds each draft's entry in the list, so with none listed it would save nothing.
|
|
1262
|
+
entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: () => {
|
|
1263
|
+
if (branchNameState && !isSaveAllRunning()) {
|
|
1264
|
+
handleSubmit(branchNameState).catch(console.error);
|
|
1265
|
+
}
|
|
1266
|
+
}, onWithdraw: () => {
|
|
1267
|
+
if (branchNameState && !isSaveAllRunning()) {
|
|
1268
|
+
handleWithdraw(branchNameState).catch(console.error);
|
|
1269
|
+
}
|
|
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() &&
|
|
1275
|
+
branchNameState
|
|
1276
|
+
? () => 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,
|
|
1278
|
+
// Sizing the pane, by pointer or key, gives the page the pane's width.
|
|
1279
|
+
onSeparatorDrag: (dragging) => {
|
|
1280
|
+
setPaneResizing(dragging);
|
|
1281
|
+
if (dragging)
|
|
1282
|
+
setPreviewWidth('fit');
|
|
1283
|
+
}, arrangement: layout, sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: (arrangement, percent) => {
|
|
1284
|
+
setSplitPercent(arrangement, percent);
|
|
1285
|
+
setPreviewWidth('fit');
|
|
1286
|
+
}, preview: renderPreview && currentEntry && !currentEntryUnavailable
|
|
1287
|
+
? renderPreview(currentEntry, displayValue)
|
|
1288
|
+
: defaultPreview, form: !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
|
|
1289
|
+
? 'Setting up your branch workspace…'
|
|
1290
|
+
: entriesInitializing
|
|
1291
|
+
? 'Loading content…'
|
|
1292
|
+
: '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) ||
|
|
1293
|
+
currentBranch?.status === 'archived',
|
|
1294
|
+
// A hidden draft's discard changes nothing on screen, so it says so.
|
|
1295
|
+
onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
|
|
1296
|
+
announce: true,
|
|
1297
|
+
}) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
|
|
1298
|
+
title: currentEntry.label,
|
|
1299
|
+
typeLabel: entryTypeLabel(activeCollections, currentEntry),
|
|
1300
|
+
},
|
|
1301
|
+
// The one writer of drafts. Read-only content gets none, whatever a
|
|
1302
|
+
// field emits (MDXEditor's mount-time normalisation included).
|
|
1303
|
+
onChange: (next) => {
|
|
1304
|
+
const contentId = currentEntry?.contentId;
|
|
1305
|
+
if (contentId && !contentReadOnlyRef.current) {
|
|
1306
|
+
// Every list item keeps a key, whatever field made it.
|
|
1307
|
+
const keyed = withItemKeys(schema, next, 'random');
|
|
1308
|
+
setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
|
|
1309
|
+
}
|
|
1310
|
+
}, 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 &&
|
|
1311
|
+
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
|
+
setLoadFailedKey(null);
|
|
1313
|
+
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,
|
|
1315
|
+
// The hidden root's entries sit at the top of the tree, so they get no heading here either.
|
|
1316
|
+
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
|
+
try {
|
|
1318
|
+
await handleBranchChange(name);
|
|
1319
|
+
setBranchManagerOpen(false);
|
|
1320
|
+
}
|
|
1321
|
+
catch (err) {
|
|
1322
|
+
console.error('Branch change failed or was cancelled:', err);
|
|
1323
|
+
// Don't close branch manager if there was an error or user cancelled
|
|
1324
|
+
}
|
|
1325
|
+
}, onBeforeCreate: confirmCreateBranch, onCreate: handleCreateBranch,
|
|
1326
|
+
// A Save all mid-run on the open branch would be submitted partway through.
|
|
1327
|
+
onSubmit: (name) => {
|
|
1328
|
+
if (name === branchNameState && isSaveAllRunning())
|
|
1329
|
+
return;
|
|
1330
|
+
handleSubmit(name).catch((err) => console.error(err));
|
|
1331
|
+
}, onWithdraw: (name) => {
|
|
1332
|
+
if (name === branchNameState && isSaveAllRunning())
|
|
1333
|
+
return;
|
|
1334
|
+
handleWithdraw(name).catch((err) => console.error(err));
|
|
1335
|
+
}, onRequestChanges: (name) => {
|
|
1336
|
+
handleRequestChanges(name).catch((err) => console.error(err));
|
|
1337
|
+
}, onDelete: (name) => {
|
|
1338
|
+
handleDelete(name).catch((err) => console.error(err));
|
|
1339
|
+
}, 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
|
|
1340
|
+
? (path, entryType) => addEntryType(path, entryType)
|
|
1341
|
+
: undefined, onUpdateEntryType: editingCollection
|
|
1342
|
+
? (path, name, updates) => updateEntryType(path, name, updates)
|
|
1343
|
+
: undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onReloadEntryTypes: editingCollection ? readEntryTypes : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError, restored: restoredCollectionInput, onDiscardRestored: () => {
|
|
1344
|
+
collectionDraftRef.current = null;
|
|
1345
|
+
setRestoredCollectionInput(null);
|
|
1346
|
+
} })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
|
|
1347
|
+
// `renamingEntry` stays until the next rename, so the dialog plays its exit and
|
|
1348
|
+
// returns focus.
|
|
1349
|
+
onClose: () => {
|
|
1350
|
+
setRenameModalOpen(false);
|
|
1351
|
+
setRenameModalError(null);
|
|
1352
|
+
}, isSaving: renameModalSaving, returnFocusTo: entryChangeReturnTarget, error: renameModalError?.mayHaveLanded ? null : renameModalError?.message, warning: renameModalError?.mayHaveLanded ? renameModalError.message : null, existingSlugs: renameExistingSlugs, previewPath: (slug) => previewPathFor({
|
|
1353
|
+
collectionPath: renamingEntry.collectionPath ?? '',
|
|
1354
|
+
collectionName: renamingEntry.collectionName,
|
|
1355
|
+
slug,
|
|
1356
|
+
entryType: renamingEntry.entryType,
|
|
1357
|
+
}) })), createModalCollection && (_jsx(EntryCreateModal, { isOpen: createModalOpen, collectionLabel: createModalCollection.label || createModalCollection.name, entryTypes: createModalEntryTypes, onCreate: handleCreateModalSubmit, onClose: closeCreateModal, isCreating: createModalCreating, error: createModalError, existingSlugs: createModalExistingSlugs, previewPath: (slug, entryTypeName) => previewPathFor({
|
|
1358
|
+
collectionPath: createModalCollection.path,
|
|
1359
|
+
collectionName: createModalCollection.name,
|
|
1360
|
+
slug,
|
|
1361
|
+
entryType: entryTypeName,
|
|
1362
|
+
}) })), _jsx(ConfirmDeleteModal, { isOpen: deleteCollectionModalOpen, title: "Delete Collection", message: "Are you sure you want to delete this collection? This cannot be undone.", confirmLabel: "Delete Collection", onConfirm: confirmDeleteCollection, onClose: () => {
|
|
1363
|
+
setDeleteCollectionModalOpen(false);
|
|
1364
|
+
setDeletingCollectionPath(null);
|
|
1365
|
+
}, loading: deleteInProgress }), _jsx(ConfirmDeleteModal, { isOpen: deleteEntryModalOpen, title: "Delete entry", message: deletingEntryReferencedBy
|
|
1366
|
+
? referencedDeleteMessage(deletingEntryReferencedBy)
|
|
1367
|
+
: `Delete ${deletingEntryLabel ? `"${deletingEntryLabel}"` : 'this entry'}? This cannot be undone.`, confirmLabel: deletingEntryReferencedBy ? 'Delete anyway' : 'Delete entry', onConfirm: confirmDeleteEntry, onClose: closeDeleteEntryModal, loading: deleteInProgress, returnFocusTo: entryChangeReturnTarget, children: deletingEntryReferencedBy && (_jsx(ReferencedByList, { referencedBy: deletingEntryReferencedBy, onOpenEntry: (entryPath) => {
|
|
1368
|
+
closeDeleteEntryModal();
|
|
1369
|
+
openEntry(entryPath);
|
|
1370
|
+
} })) })] }) }) }) }));
|
|
1223
1371
|
};
|
|
1224
1372
|
export default Editor;
|