canopycms 0.1.0 → 0.2.0-int.119
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 +144 -41
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +98 -37
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/finalize.js +14 -1
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +19 -4
- package/dist/assets/store-s3.js +56 -13
- package/dist/assets/types.d.ts +26 -2
- package/dist/cli/cli.js +112 -34
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +58 -36
- package/dist/editor/EntryNavigator.d.ts +8 -1
- package/dist/editor/EntryNavigator.js +31 -16
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +11 -2
- package/dist/editor/PreviewFrame.js +20 -4
- package/dist/editor/PreviewPane.d.ts +15 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.js +6 -2
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +29 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +20 -7
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +14 -7
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +16 -0
- package/dist/editor/preview-bridge.js +12 -3
- package/dist/editor/preview-path.d.ts +11 -4
- package/dist/editor/preview-path.js +62 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.js +2 -0
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/editor/Editor.js
CHANGED
|
@@ -23,7 +23,7 @@ import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
|
|
|
23
23
|
// Import directly from helpers to avoid server-only code in authorization barrel
|
|
24
24
|
import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
|
|
25
25
|
import { fieldBreadcrumb } from './field-breadcrumb.js';
|
|
26
|
-
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, } from './editor-utils.js';
|
|
26
|
+
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, entryTypeLine, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
|
|
27
27
|
import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
|
|
28
28
|
import { useBranchActions } from './hooks/useBranchActions.js';
|
|
29
29
|
import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
|
|
@@ -52,7 +52,7 @@ import { hideCollectionOutcomeUnknown } from './hooks/useSchemaManager.js';
|
|
|
52
52
|
import { AssetContextProvider, SiteMdxAllowContext, authenticatedAssetBase, useApiClient, } from './context/index.js';
|
|
53
53
|
import { EntryLinkContext } from './fields/entry-link/index.js';
|
|
54
54
|
import { MediaLibrary } from './media/MediaLibrary.js';
|
|
55
|
-
import { removalAnchors, restoreRemoval, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
|
|
55
|
+
import { removalAnchors, restoreRemoval, movedThreadsNote, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
|
|
56
56
|
import { formatCanopyPath } from './canopy-path.js';
|
|
57
57
|
import { itemKeysOnPath, resolveThreadPath, withItemKeys } from '../item-keys.js';
|
|
58
58
|
import { ENTRY_NOT_LOADED_COMMENT_MESSAGE, NEW_ITEM_COMMENT_MESSAGE, } from './comments/item-anchor-copy.js';
|
|
@@ -122,7 +122,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
122
122
|
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
|
123
123
|
// A rename or delete asked for from the top bar's entry menu, whose item is gone once it acts.
|
|
124
124
|
const [entryChangeFromMenu, setEntryChangeFromMenu] = useState(false);
|
|
125
|
-
const
|
|
125
|
+
const contentToggleRef = useRef(null);
|
|
126
126
|
// Draft compile/render error reported by the preview page (null = renders cleanly)
|
|
127
127
|
const [previewError, setPreviewError] = useState(null);
|
|
128
128
|
const apiClient = useApiClient();
|
|
@@ -140,12 +140,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
140
140
|
const commentUserId = userContext?.userId ?? currentUser;
|
|
141
141
|
// Below 64em (1024px) the editor starts stacked and the content panel never docks.
|
|
142
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({
|
|
143
|
+
const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, siteChrome, setSiteChrome, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
|
|
144
144
|
storageKey: editorLayoutStorageKey(basePath),
|
|
145
145
|
defaultLayout: wideWindow ? 'side' : 'stacked',
|
|
146
146
|
});
|
|
147
|
-
//
|
|
148
|
-
|
|
147
|
+
// A wide window docks the content panel: beside the panes side by side, and beside the form row
|
|
148
|
+
// only when stacked, so the preview keeps its full width. A narrow one lays it over the form.
|
|
149
|
+
const contentPanelDocked = wideWindow;
|
|
149
150
|
// Comments state (shared between useCommentSystem and useBranchManager)
|
|
150
151
|
const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
|
|
151
152
|
// 1. Branch manager (provides branchNameState, no dependencies)
|
|
@@ -188,6 +189,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
188
189
|
contentRoot,
|
|
189
190
|
basePath,
|
|
190
191
|
}),
|
|
192
|
+
resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
|
|
191
193
|
setBusy: setEntriesLoading,
|
|
192
194
|
});
|
|
193
195
|
// The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
|
|
@@ -204,8 +206,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
204
206
|
setNavigatorOpen(false);
|
|
205
207
|
};
|
|
206
208
|
// 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
|
|
208
|
-
//
|
|
209
|
+
// the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
|
|
210
|
+
// the open state it sets lands on them, not on the defaults they replace.
|
|
209
211
|
useEffect(() => {
|
|
210
212
|
if (layoutLoaded && contentPanelDocked && navigatorOpen) {
|
|
211
213
|
setContentPanelOpen(true);
|
|
@@ -305,7 +307,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
305
307
|
contentReadOnlyRef.current = contentReadOnly;
|
|
306
308
|
// Undo for a form Remove: a toast whose Undo puts the item back into the entry's draft as it
|
|
307
309
|
// is then, through a functional update, so edits made since are kept. It acts only on the
|
|
308
|
-
// branch and entry it was raised for, and its toast closes when either changes.
|
|
310
|
+
// branch and entry it was raised for, and its toast closes when either changes. An object's
|
|
311
|
+
// Clear offers its Undo in the form instead (FormRenderer), which a save ends via `savedValue`.
|
|
309
312
|
const loadedValuesRef = useRef(loadedValues);
|
|
310
313
|
loadedValuesRef.current = loadedValues;
|
|
311
314
|
const draftsRef = useRef(drafts);
|
|
@@ -332,7 +335,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
332
335
|
...removal,
|
|
333
336
|
anchors: displayValue ? removalAnchors(fields, displayValue, removal) : undefined,
|
|
334
337
|
};
|
|
335
|
-
const doing = removal.action === 'clear' ? 'clearing' : 'removing';
|
|
336
338
|
// Open threads keyed inside what was removed leave their fields for the entry's thread list.
|
|
337
339
|
const movedThreads = currentEntry
|
|
338
340
|
? openThreadsWithin(displayComments, currentEntry.path, removal.kind === 'list-item' ? [...removal.listPath, removal.index] : removal.path)
|
|
@@ -350,7 +352,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
350
352
|
undoToastsRef.current.delete(id);
|
|
351
353
|
if (!open) {
|
|
352
354
|
notifications.show({
|
|
353
|
-
message: `Too late to undo
|
|
355
|
+
message: `Too late to undo removing ${removal.label}.`,
|
|
354
356
|
color: 'yellow',
|
|
355
357
|
});
|
|
356
358
|
return;
|
|
@@ -363,7 +365,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
363
365
|
const now = draftsRef.current[contentId] ?? loadedValuesRef.current[contentId];
|
|
364
366
|
if (!now || !restoreRemoval(fields, now, anchored)) {
|
|
365
367
|
notifications.show({
|
|
366
|
-
message: `Couldn't undo
|
|
368
|
+
message: `Couldn't undo removing ${removal.label}: that part of the entry has changed. Add it again if you still need it.`,
|
|
367
369
|
color: 'yellow',
|
|
368
370
|
});
|
|
369
371
|
return;
|
|
@@ -385,9 +387,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
385
387
|
undoToastsRef.current.set(id, contentId);
|
|
386
388
|
const toast = {
|
|
387
389
|
id,
|
|
388
|
-
message: (_jsx(UndoToastMessage, { label: removal.label,
|
|
389
|
-
? `Its ${movedThreads === 1 ? 'comment thread' : `${movedThreads} comment threads`} moved to "Comment on this entry".`
|
|
390
|
-
: undefined, onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
|
|
390
|
+
message: (_jsx(UndoToastMessage, { label: removal.label, note: movedThreadsNote(movedThreads), onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
|
|
391
391
|
autoClose: getNotificationDuration(8000),
|
|
392
392
|
withCloseButton: true,
|
|
393
393
|
onClose: () => undoToastsRef.current.delete(id),
|
|
@@ -976,8 +976,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
976
976
|
list.push({
|
|
977
977
|
path: entry.path,
|
|
978
978
|
label: entry.label,
|
|
979
|
+
title: entry.title,
|
|
979
980
|
slug: entry.slug,
|
|
980
981
|
status: entryStatusLabel(activeCollections, entry),
|
|
982
|
+
missing: entry.status === 'missing' || undefined,
|
|
983
|
+
noPage: !entry.previewSrc || undefined,
|
|
981
984
|
contentId: entry.contentId,
|
|
982
985
|
readOnly: entry.canEdit === false || undefined,
|
|
983
986
|
conflictNotice: !!(entry.contentId && currentBranch?.conflictFiles?.includes(entry.contentId)),
|
|
@@ -1048,13 +1051,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1048
1051
|
const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
|
|
1049
1052
|
const openNavigator = () => {
|
|
1050
1053
|
navigatorOpenerRef.current = rememberOpener();
|
|
1051
|
-
// In the
|
|
1054
|
+
// In the commit that opens the overlay, so the form panel is no longer inert when the overlay
|
|
1055
|
+
// mounts, then or a frame later as its slide starts, and serves its focus request.
|
|
1052
1056
|
if (!contentPanelDocked)
|
|
1053
1057
|
setPreviewMaximized(false);
|
|
1054
1058
|
setContentPanelShown(true);
|
|
1055
1059
|
setContentPanelFocusRequest((request) => request + 1);
|
|
1056
1060
|
};
|
|
1057
|
-
// A collection crumb opens the panel as
|
|
1061
|
+
// A collection crumb opens the panel as the rail's Content button does, then its collection's row
|
|
1062
|
+
// takes focus.
|
|
1058
1063
|
const [collectionReveal, setCollectionReveal] = useState();
|
|
1059
1064
|
// Only ever rises: the navigator serves each seq once, and the request is cleared once served.
|
|
1060
1065
|
const revealSeqRef = useRef(0);
|
|
@@ -1084,9 +1089,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1084
1089
|
})
|
|
1085
1090
|
.catch(console.error);
|
|
1086
1091
|
};
|
|
1087
|
-
// The entry menu's trigger stands in for its item; once the entry is deleted,
|
|
1092
|
+
// The entry menu's trigger stands in for its item; once the entry is deleted, the rail's Content
|
|
1093
|
+
// button, where another entry is found.
|
|
1088
1094
|
const entryChangeReturnTarget = entryChangeFromMenu
|
|
1089
|
-
? () => navigatorToggleRef.current ??
|
|
1095
|
+
? () => navigatorToggleRef.current ?? contentToggleRef.current
|
|
1090
1096
|
: undefined;
|
|
1091
1097
|
const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
|
|
1092
1098
|
const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
|
|
@@ -1145,11 +1151,23 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1145
1151
|
entries: entriesState.map((e) => ({
|
|
1146
1152
|
contentId: e.contentId,
|
|
1147
1153
|
label: e.label,
|
|
1154
|
+
title: e.title,
|
|
1148
1155
|
slug: e.slug,
|
|
1149
1156
|
collectionPath: e.collectionPath,
|
|
1150
1157
|
collectionName: e.collectionName,
|
|
1151
1158
|
})),
|
|
1152
1159
|
}), [entriesState]);
|
|
1160
|
+
// Quick open names each entry's type and route under its label.
|
|
1161
|
+
const quickOpenEntries = useMemo(() => entriesState.map((e) => ({
|
|
1162
|
+
path: e.path,
|
|
1163
|
+
label: e.label,
|
|
1164
|
+
title: e.title,
|
|
1165
|
+
slug: e.slug,
|
|
1166
|
+
collectionPath: e.collectionPath,
|
|
1167
|
+
collectionName: e.collectionName,
|
|
1168
|
+
typeLabel: entryTypeLine(activeCollections, e),
|
|
1169
|
+
route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
|
|
1170
|
+
})), [entriesState, activeCollections, previewBaseByCollection, contentRoot]);
|
|
1153
1171
|
const CenteredMessage = ({ children }) => (_jsx("div", { style: {
|
|
1154
1172
|
display: 'flex',
|
|
1155
1173
|
alignItems: 'center',
|
|
@@ -1162,11 +1180,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1162
1180
|
fields: schema,
|
|
1163
1181
|
data: displayValue,
|
|
1164
1182
|
});
|
|
1165
|
-
// Only the default preview has the toolbar that maximizes and restores it.
|
|
1166
|
-
//
|
|
1167
|
-
// choice reapplies once a value arrives.
|
|
1183
|
+
// Only the default preview has the toolbar that maximizes and restores it. It stays maximized
|
|
1184
|
+
// while an entry loads. With no value after a failed load and no draft, the form shows instead,
|
|
1185
|
+
// and the choice reapplies once a value arrives.
|
|
1168
1186
|
const previewCanMaximize = !renderPreview && Boolean(currentEntry?.previewSrc) && !currentEntryUnavailable;
|
|
1169
|
-
|
|
1187
|
+
// The open entry's data is loading, not failed: the preview keeps its last page, dimmed.
|
|
1188
|
+
const previewPending = previewCanMaximize &&
|
|
1189
|
+
!previewFrameData &&
|
|
1190
|
+
loadFailedKey !== `${branchNameState}:${currentEntry?.contentId}`;
|
|
1191
|
+
const previewShownMaximized = previewMaximized && previewCanMaximize && (Boolean(previewFrameData) || previewPending);
|
|
1170
1192
|
useEffect(() => {
|
|
1171
1193
|
if (!previewCanMaximize)
|
|
1172
1194
|
setPreviewMaximized(false);
|
|
@@ -1176,8 +1198,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1176
1198
|
? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
|
|
1177
1199
|
: '';
|
|
1178
1200
|
// An unavailable entry previews nothing: the site cannot read it, and a stored draft would
|
|
1179
|
-
// otherwise mount the frame at a page that answers not-found.
|
|
1180
|
-
|
|
1201
|
+
// otherwise mount the frame at a page that answers not-found. The pane stays mounted while an
|
|
1202
|
+
// entry loads, so its toolbar never leaves on a switch.
|
|
1203
|
+
const defaultPreview = currentEntry?.previewSrc && (previewFrameData || previewPending) && !currentEntryUnavailable ? (_jsx(PreviewPane, { src: currentEntry.previewSrc, pending: previewPending, data: previewFrameData, isLoading: previewLoadingState, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, onMarks: onPreviewMarks, assetBase: authenticatedAssetBase(basePath), mount: { basePath, previewPrefix }, previewWidth: previewWidth, onPreviewWidthChange: setPreviewWidth, onHighlightToggle: () => {
|
|
1181
1204
|
setHighlightEnabled(!highlightEnabled);
|
|
1182
1205
|
clearPreviewMarks();
|
|
1183
1206
|
}, marksNothing: previewMarksNothing, inexactMarks: previewInexactMarks, showMarkDiagnostics: userIsAdmin, layout: layout, maximized: previewShownMaximized, onMaximizedChange: (maximized) => {
|
|
@@ -1185,7 +1208,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1185
1208
|
if (maximized && !contentPanelDocked)
|
|
1186
1209
|
setNavigatorOpen(false);
|
|
1187
1210
|
setPreviewMaximized(maximized);
|
|
1188
|
-
}, resizing: paneResizing, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
|
|
1211
|
+
}, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
|
|
1189
1212
|
? `${previewErrorField}: ${previewError.message}`
|
|
1190
1213
|
: previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
|
|
1191
1214
|
display: 'flex',
|
|
@@ -1237,8 +1260,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1237
1260
|
: entriesState.map((e) => ({
|
|
1238
1261
|
path: e.path,
|
|
1239
1262
|
label: e.label,
|
|
1263
|
+
title: e.title,
|
|
1240
1264
|
slug: e.slug,
|
|
1241
1265
|
status: entryStatusLabel(activeCollections, e),
|
|
1266
|
+
missing: e.status === 'missing' || undefined,
|
|
1267
|
+
noPage: !e.previewSrc || undefined,
|
|
1242
1268
|
readOnly: e.canEdit === false || undefined,
|
|
1243
1269
|
})), 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
1270
|
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,
|
|
@@ -1253,7 +1279,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1253
1279
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1254
1280
|
branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
|
|
1255
1281
|
// A failed fetch leaves the list empty too; the banner says that one.
|
|
1256
|
-
branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0),
|
|
1282
|
+
branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0), onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
|
|
1257
1283
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1258
1284
|
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1259
1285
|
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
@@ -1267,14 +1293,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1267
1293
|
if (branchNameState && !isSaveAllRunning()) {
|
|
1268
1294
|
handleWithdraw(branchNameState).catch(console.error);
|
|
1269
1295
|
}
|
|
1270
|
-
} }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, {
|
|
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() &&
|
|
1296
|
+
} }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelShown ? setContentPanelShown(false) : openNavigator(), contentToggleRef: contentToggleRef, onQuickOpen: openQuickOpen, quickOpenShortcut: quickOpenShortcut, quickOpenKeys: quickOpenKeys, branchesOpen: branchManagerOpen, onBranchesOpen: openBranchManager, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
|
|
1275
1297
|
branchNameState
|
|
1276
1298
|
? () => 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,
|
|
1299
|
+
: undefined, commentsOpen: commentsPanelOpen, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, mediaLibraryOpen: mediaLibraryOpen, onMediaLibraryOpen: () => setMediaLibraryOpen(true), mediaButtonRef: mediaButtonRef, layout: layout, onLayoutChange: setLayout, layoutBlocked: previewShownMaximized, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, settingsToggleRef: settingsToggleRef, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick }), panel: contentPanelDocked && layout === 'side' ? contentPanel : undefined, main: _jsx(Box, { h: "100%", children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
|
|
1278
1300
|
// Sizing the pane, by pointer or key, gives the page the pane's width.
|
|
1279
1301
|
onSeparatorDrag: (dragging) => {
|
|
1280
1302
|
setPaneResizing(dragging);
|
|
@@ -1294,7 +1316,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1294
1316
|
// A hidden draft's discard changes nothing on screen, so it says so.
|
|
1295
1317
|
onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
|
|
1296
1318
|
announce: true,
|
|
1297
|
-
}) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
|
|
1319
|
+
}) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
|
|
1298
1320
|
title: currentEntry.label,
|
|
1299
1321
|
typeLabel: entryTypeLabel(activeCollections, currentEntry),
|
|
1300
1322
|
},
|
|
@@ -1311,7 +1333,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1311
1333
|
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
1334
|
setLoadFailedKey(null);
|
|
1313
1335
|
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:
|
|
1336
|
+
}, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: quickOpenEntries,
|
|
1315
1337
|
// The hidden root's entries sit at the top of the tree, so they get no heading here either.
|
|
1316
1338
|
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
1339
|
try {
|
|
@@ -1343,7 +1365,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1343
1365
|
: undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onReloadEntryTypes: editingCollection ? readEntryTypes : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError, restored: restoredCollectionInput, onDiscardRestored: () => {
|
|
1344
1366
|
collectionDraftRef.current = null;
|
|
1345
1367
|
setRestoredCollectionInput(null);
|
|
1346
|
-
} })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
|
|
1368
|
+
} })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, nameSource: entryNameSource(activeCollections, renamingEntry, entryValue(renamingEntry)), hasPage: !!renamingEntry.previewSrc, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
|
|
1347
1369
|
// `renamingEntry` stays until the next rename, so the dialog plays its exit and
|
|
1348
1370
|
// returns focus.
|
|
1349
1371
|
onClose: () => {
|
|
@@ -5,9 +5,16 @@ type TreeController = ReturnType<typeof useTree>;
|
|
|
5
5
|
export interface EntryNavItem {
|
|
6
6
|
path: LogicalPath;
|
|
7
7
|
label: string;
|
|
8
|
+
/** The entry's title, matched by the filter where the label is a singleton's type label. */
|
|
9
|
+
title?: string;
|
|
8
10
|
/** Matched by the filter; the last segment of `path` when absent. */
|
|
9
11
|
slug?: string;
|
|
12
|
+
/** The row's second line: the entry's type, or "Missing" (`entryStatusLabel`). */
|
|
10
13
|
status?: string;
|
|
14
|
+
/** The entry's file is gone: its second line is error text. */
|
|
15
|
+
missing?: boolean;
|
|
16
|
+
/** The entry has no page on the site, so its menu changes its slug rather than its URL. */
|
|
17
|
+
noPage?: boolean;
|
|
11
18
|
collectionPath?: LogicalPath;
|
|
12
19
|
contentId?: ContentId;
|
|
13
20
|
/** True when this entry's file conflicted during rebase */
|
|
@@ -46,7 +53,7 @@ export interface EntryNavigatorProps {
|
|
|
46
53
|
onExpandedStateChange?: (state: Record<string, boolean>) => void;
|
|
47
54
|
/** Called when user requests to delete an entry */
|
|
48
55
|
onDeleteEntry?: (path: LogicalPath) => void;
|
|
49
|
-
/** Called when user
|
|
56
|
+
/** Called when the user asks to change an entry's URL (its slug) */
|
|
50
57
|
onRenameEntry?: (path: LogicalPath) => void;
|
|
51
58
|
/** Called when user reorders an entry within a collection */
|
|
52
59
|
onReorderEntry?: (collectionPath: LogicalPath, contentId: string, direction: 'up' | 'down') => void;
|
|
@@ -5,7 +5,9 @@ import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader,
|
|
|
5
5
|
import { IconArrowDown, IconAlertTriangle, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react';
|
|
6
6
|
import { calculatePathToEntry } from './editor-utils.js';
|
|
7
7
|
import { matchesEntryQuery } from './entry-search.js';
|
|
8
|
+
import { EDITOR_ACTIONS } from './copy.js';
|
|
8
9
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
10
|
+
import { UnsavedMark } from './components/UnsavedMark.js';
|
|
9
11
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
10
12
|
import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
|
|
11
13
|
const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
|
|
@@ -44,7 +46,8 @@ const filterTree = (nodes, query) => {
|
|
|
44
46
|
for (const node of nodes) {
|
|
45
47
|
const label = String(node.label ?? '');
|
|
46
48
|
if (node.nodeProps?.isEntry) {
|
|
47
|
-
|
|
49
|
+
const { slug, title } = node.nodeProps;
|
|
50
|
+
if (matchesEntryQuery({ label, title, slug }, query)) {
|
|
48
51
|
kept.push(node);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
@@ -71,14 +74,19 @@ const firstEntryPath = (nodes, query) => {
|
|
|
71
74
|
}
|
|
72
75
|
};
|
|
73
76
|
walk(nodes);
|
|
74
|
-
const match = entries.find((node) => matchesEntryQuery({
|
|
77
|
+
const match = entries.find((node) => matchesEntryQuery({
|
|
78
|
+
label: String(node.label ?? ''),
|
|
79
|
+
title: node.nodeProps?.title,
|
|
80
|
+
slug: node.nodeProps?.slug,
|
|
81
|
+
}, query));
|
|
75
82
|
return (match ?? entries[0])?.value;
|
|
76
83
|
};
|
|
77
84
|
/**
|
|
78
|
-
* A row
|
|
79
|
-
* text is cut: the width is measured when the pointer or focus enters the row, not on every
|
|
85
|
+
* A line of a row, truncated with an ellipsis. Its full text shows in a tooltip, and only while
|
|
86
|
+
* the text is cut: the width is measured when the pointer or focus enters the row, not on every
|
|
87
|
+
* render.
|
|
80
88
|
*/
|
|
81
|
-
const TruncatedLabel = ({ label, highlight, fw, }) => {
|
|
89
|
+
const TruncatedLabel = ({ label, highlight = '', fw, size = 'sm', c }) => {
|
|
82
90
|
const elementRef = useRef(null);
|
|
83
91
|
const [truncated, setTruncated] = useState(false);
|
|
84
92
|
const setElement = useCallback((node) => {
|
|
@@ -100,7 +108,7 @@ const TruncatedLabel = ({ label, highlight, fw, }) => {
|
|
|
100
108
|
row.removeEventListener('focusin', measure);
|
|
101
109
|
};
|
|
102
110
|
}, []);
|
|
103
|
-
return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size:
|
|
111
|
+
return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size: size, fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) : (_jsx(Text, { ref: setElement, size: size, fw: fw, c: c, truncate: "end", style: { minWidth: 0 }, children: label })) }));
|
|
104
112
|
};
|
|
105
113
|
/** The keys Mantine's tree acts on, matched as it matches them, by `code`. */
|
|
106
114
|
const TREE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']);
|
|
@@ -114,13 +122,7 @@ const stopTreeKeys = (event) => {
|
|
|
114
122
|
if (TREE_KEYS.has(event.nativeEvent.code))
|
|
115
123
|
event.stopPropagation();
|
|
116
124
|
};
|
|
117
|
-
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(
|
|
118
|
-
flexShrink: 0,
|
|
119
|
-
width: 8,
|
|
120
|
-
height: 8,
|
|
121
|
-
borderRadius: '50%',
|
|
122
|
-
backgroundColor: 'var(--mantine-color-brand-6)',
|
|
123
|
-
} }) }));
|
|
125
|
+
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
|
|
124
126
|
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
125
127
|
// Shadow the entry-mutation callbacks with undefined when readOnly, so every
|
|
126
128
|
// downstream reference (hasEntryMenu, canReorder, the menu items themselves)
|
|
@@ -147,6 +149,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
147
149
|
label: entry.label,
|
|
148
150
|
nodeProps: {
|
|
149
151
|
status: entry.status,
|
|
152
|
+
title: entry.title,
|
|
153
|
+
missing: entry.missing,
|
|
154
|
+
noPage: entry.noPage,
|
|
150
155
|
isEntry: true,
|
|
151
156
|
entryPath: entry.path,
|
|
152
157
|
slug: entry.slug ?? lastSegment(entry.path),
|
|
@@ -168,6 +173,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
168
173
|
label: entry?.label ?? col.label,
|
|
169
174
|
nodeProps: {
|
|
170
175
|
status: entry?.status,
|
|
176
|
+
title: entry?.title,
|
|
177
|
+
missing: entry?.missing,
|
|
178
|
+
noPage: entry?.noPage,
|
|
171
179
|
isEntry: true,
|
|
172
180
|
entryPath: entry?.path,
|
|
173
181
|
collectionPath: col.path,
|
|
@@ -275,6 +283,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
275
283
|
label: item.label,
|
|
276
284
|
nodeProps: {
|
|
277
285
|
status: item.status,
|
|
286
|
+
title: item.title,
|
|
287
|
+
missing: item.missing,
|
|
288
|
+
noPage: item.noPage,
|
|
278
289
|
isEntry: true,
|
|
279
290
|
entryPath: item.path,
|
|
280
291
|
slug: item.slug ?? lastSegment(item.path),
|
|
@@ -463,6 +474,8 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
463
474
|
const Chevron = ({ expanded, visible }) => (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", style: { flexShrink: 0, opacity: visible ? 0.9 : 0 }, children: expanded ? (_jsx("path", { d: "M7.41 8.59 12 13.17 16.59 8.59 18 10l-6 6-6-6z" })) : (_jsx("path", { d: "M10 6 8.59 7.41 13.17 12 8.59 16.59 10 18l6-6z" })) }));
|
|
464
475
|
const renderNode = ({ node, elementProps, hasChildren, expanded, level, }) => {
|
|
465
476
|
const status = node.nodeProps?.status;
|
|
477
|
+
const entryMissing = !!node.nodeProps?.missing;
|
|
478
|
+
const entryNoPage = !!node.nodeProps?.noPage;
|
|
466
479
|
// Collection-level mutation callbacks are nulled when readOnly, same as
|
|
467
480
|
// the entry-level ones above -- this removes the collection menu button
|
|
468
481
|
// entirely (hasCollectionMenu below) rather than leaving it enabled.
|
|
@@ -533,7 +546,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
533
546
|
flexShrink: 0,
|
|
534
547
|
alignItems: 'center',
|
|
535
548
|
justifyContent: 'center',
|
|
536
|
-
}, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }),
|
|
549
|
+
}, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsxs(Stack, { gap: 0, style: { flex: 1, minWidth: 0 }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [_jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), conflictNotice && (_jsx(Tooltip, { label: "This content was updated on the base branch \u2014 a reviewer will reconcile", withArrow: true, children: _jsx(Badge, { size: "xs", variant: "light", color: "orange", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: "Conflict" }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), status && (_jsx(Box, { "data-testid": "entry-nav-type", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: status, size: "xs",
|
|
550
|
+
// A missing file is an error, in error text, not a dimmed type.
|
|
551
|
+
c: entryMissing ? 'var(--canopy-danger-text)' : 'dimmed' }) }))] })] }), _jsxs(Group, { gap: 4, wrap: "nowrap", style: { flexShrink: 0 }, children: [hasCollectionMenu && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Collection actions", "data-testid": `collection-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: (event) => {
|
|
537
552
|
event.stopPropagation();
|
|
538
553
|
onAdd();
|
|
539
554
|
}, "data-testid": "add-entry-menu-item", children: "Add entry\u2026" })), canReorder && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
@@ -551,10 +566,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
551
566
|
}, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
|
|
552
567
|
event.stopPropagation();
|
|
553
568
|
onDelete();
|
|
554
|
-
}, children: "Delete collection" }))] })] })), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (
|
|
569
|
+
}, children: "Delete collection" }))] })] })), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
|
|
555
570
|
event.stopPropagation();
|
|
556
571
|
onRenameEntry(entryPath);
|
|
557
|
-
}, "data-testid": "rename-entry-menu-item", children: "
|
|
572
|
+
}, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
558
573
|
event.stopPropagation();
|
|
559
574
|
onReorderEntry(parentCollectionPath, contentId, 'up');
|
|
560
575
|
}, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
|
|
@@ -9,6 +9,7 @@ export interface OutlineNode {
|
|
|
9
9
|
children?: OutlineNode[];
|
|
10
10
|
}
|
|
11
11
|
export interface FormHeaderProps {
|
|
12
|
+
/** The entry's name, for assistive tech only: the top bar's breadcrumb shows it. */
|
|
12
13
|
title: string;
|
|
13
14
|
/** The entry type's label, shown as a badge. */
|
|
14
15
|
typeLabel?: string;
|
|
@@ -19,7 +20,7 @@ export interface FormHeaderProps {
|
|
|
19
20
|
onExpandAll?: (expanded: boolean) => void;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
|
-
* The form's sticky header:
|
|
23
|
+
* The form's sticky header: the entry's type, an outline of its fields to jump to, and
|
|
23
24
|
* Expand all / Collapse all for every list and block field in it.
|
|
24
25
|
*/
|
|
25
26
|
export declare const FormHeader: React.FC<FormHeaderProps>;
|
|
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
|
|
|
4
4
|
import { ActionIcon, Badge, Box, Button, Group, Popover, ScrollArea, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { useIsomorphicEffect } from '@mantine/hooks';
|
|
6
6
|
import { IconChevronsDown, IconChevronsUp, IconListTree } from '@tabler/icons-react';
|
|
7
|
+
import { PANE_HEADER_HEIGHT } from './pane-header.js';
|
|
7
8
|
/** The padding of the scroller the form sits in, which `shell/PaneLayout.tsx` sets; 0 where unset. */
|
|
8
9
|
const PANE_PADDING = 'var(--canopy-form-pane-padding, 0px)';
|
|
9
10
|
/** Below this header width "Outline" shows its icon alone, so the controls never clip. */
|
|
@@ -28,7 +29,7 @@ const OutlineList = ({ nodes, field, nested = false, onChoose }) => (_jsx("ul",
|
|
|
28
29
|
return (_jsxs("li", { children: [node.path === undefined ? (_jsx(Text, { size: "xs", fw: 600, c: "dimmed", px: "xs", pt: 6, pb: 2, children: node.label })) : (_jsx(UnstyledButton, { onClick: () => onChoose(node.path), "aria-label": field ? `${field} › ${name}` : undefined, display: "block", w: "100%", py: 4, px: "xs", style: { borderRadius: 'var(--mantine-radius-sm)' }, className: "canopy-outline-item", "data-testid": "form-outline-item", children: _jsxs(Text, { size: "sm", truncate: "end", fw: field ? undefined : 500, children: [node.label, node.secondary && (_jsxs(Text, { span: true, c: "dimmed", size: "sm", children: [' ', node.secondary] }))] }) })), node.children && node.children.length > 0 && (_jsx(OutlineList, { nodes: node.children, field: node.path === undefined ? undefined : node.label, nested: true, onChoose: onChoose }))] }, node.path ?? `group-${idx}`));
|
|
29
30
|
}) }));
|
|
30
31
|
/**
|
|
31
|
-
* The form's sticky header:
|
|
32
|
+
* The form's sticky header: the entry's type, an outline of its fields to jump to, and
|
|
32
33
|
* Expand all / Collapse all for every list and block field in it.
|
|
33
34
|
*/
|
|
34
35
|
export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }) => {
|
|
@@ -63,12 +64,15 @@ export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }
|
|
|
63
64
|
top: `calc(-1 * ${PANE_PADDING})`,
|
|
64
65
|
zIndex: 3,
|
|
65
66
|
margin: `calc(-1 * ${PANE_PADDING}) calc(-1 * ${PANE_PADDING}) 0`,
|
|
66
|
-
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
minHeight: PANE_HEADER_HEIGHT,
|
|
70
|
+
padding: `2px ${PANE_PADDING}`,
|
|
67
71
|
backgroundColor: 'var(--canopy-surface-muted)',
|
|
68
72
|
borderBottom: '1px solid var(--mantine-color-default-border)',
|
|
69
|
-
}, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap",
|
|
70
|
-
// Gives way before the
|
|
71
|
-
style: {
|
|
73
|
+
}, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx(VisuallyHidden, { "data-testid": "form-header-title", children: title }), typeLabel && (_jsx(Badge, { variant: "light", color: "gray", size: "sm", tt: "none",
|
|
74
|
+
// Gives way before the controls in a narrow pane.
|
|
75
|
+
style: { minWidth: 0 }, children: typeLabel })), _jsxs(Group, { gap: 4, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsxs(Popover, { opened: outlineOpen, onChange: setOutlineOpen, position: "bottom-end", width: 300, shadow: "md", withinPortal: true,
|
|
72
76
|
// Focus enters the list; Escape hands it back to "Outline" (`closeOnEscape`). A chosen
|
|
73
77
|
// field takes it instead, so closing returns it nowhere.
|
|
74
78
|
trapFocus: true, returnFocus: false, children: [_jsx(Tooltip, { label: "Outline", disabled: !compact, opened: compact ? undefined : false, children: _jsx(Popover.Target, { children: _jsx(Button, { variant: "subtle", color: "gray", c: "dimmed", size: "compact-sm", px: compact ? 6 : undefined, leftSection: _jsx(IconListTree, { size: 14, "aria-hidden": true }), styles: compact ? { section: { marginInlineEnd: 0 } } : undefined, onClick: () => setOutlineOpen((open) => !open), "data-testid": "form-outline-button", children: compact ? _jsx(VisuallyHidden, { children: "Outline" }) : 'Outline' }) }) }), _jsx(Popover.Dropdown, { p: 4, children: _jsx(ScrollArea.Autosize, { mah: 400, type: "auto", children: _jsx("nav", { "aria-label": "Outline", "data-testid": "form-outline", children: _jsx(OutlineList, { nodes: outline, onChoose: (path) => {
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import type { ContentFormat, EntrySchema, FieldConfig } from '../config/index.js';
|
|
3
3
|
import type { CommentThread } from '../comment-store.js';
|
|
4
4
|
import type { UserSearchResult } from '../auth/types.js';
|
|
5
|
-
import type
|
|
5
|
+
import { type FieldRemoval } from './undo-removal.js';
|
|
6
6
|
export type FormValue = Record<string, unknown>;
|
|
7
7
|
export interface CustomFieldRenderProps {
|
|
8
8
|
field: FieldConfig;
|
|
@@ -41,8 +41,13 @@ export interface FormRendererProps {
|
|
|
41
41
|
format?: ContentFormat;
|
|
42
42
|
/** Shows the value without accepting edits. Comments stay open. */
|
|
43
43
|
readOnly?: boolean;
|
|
44
|
-
/** Told of each list item, block or image Remove takes out,
|
|
44
|
+
/** Told of each list item, block or image Remove takes out, for Undo. */
|
|
45
45
|
onRemoved?: (removal: FieldRemoval) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The entry's saved copy, which `value` is while there is no draft. A new one (a save, a reload)
|
|
48
|
+
* or showing it unchanged (a discard) ends every object's Undo clear.
|
|
49
|
+
*/
|
|
50
|
+
savedValue?: FormValue;
|
|
46
51
|
/** Resolves comment authors to names in field and entry threads; without it they show as ids. */
|
|
47
52
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
48
53
|
/**
|