canopycms 0.2.0 → 0.3.1
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 +213 -41
- package/dist/api/branch.d.ts +2 -2
- package/dist/api/branch.js +4 -31
- package/dist/authorization/branch.js +3 -4
- package/dist/cli/cli.js +0 -18
- package/dist/cli/generate-ai-content.js +0 -6
- package/dist/cli/init.js +0 -6
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +253 -128
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +275 -103
- package/dist/editor/branch-lock.d.ts +42 -0
- package/dist/editor/branch-lock.js +79 -0
- package/dist/editor/branch-status.d.ts +0 -6
- package/dist/editor/branch-status.js +0 -10
- package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
- package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
- package/dist/editor/components/BranchPill.d.ts +3 -18
- package/dist/editor/components/BranchPill.js +7 -10
- package/dist/editor/components/EditorHeader.d.ts +24 -52
- package/dist/editor/components/EditorHeader.js +151 -71
- package/dist/editor/components/EditorRail.d.ts +8 -8
- package/dist/editor/components/EditorRail.js +7 -5
- package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
- package/dist/editor/components/EntryBreadcrumb.js +3 -3
- package/dist/editor/components/EntryHeader.d.ts +60 -0
- package/dist/editor/components/EntryHeader.js +45 -0
- package/dist/editor/components/EntrySaveButton.d.ts +24 -0
- package/dist/editor/components/EntrySaveButton.js +101 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
- package/dist/editor/components/NoEditPermissionNotice.js +1 -1
- package/dist/editor/components/RetryToastMessage.d.ts +3 -1
- package/dist/editor/components/RetryToastMessage.js +8 -4
- package/dist/editor/components/SaveAllButton.d.ts +25 -0
- package/dist/editor/components/SaveAllButton.js +81 -0
- package/dist/editor/components/UnsavedMark.d.ts +1 -1
- package/dist/editor/components/UnsavedMark.js +1 -1
- package/dist/editor/components/index.d.ts +1 -1
- package/dist/editor/components/index.js +1 -1
- package/dist/editor/copy.d.ts +0 -1
- package/dist/editor/copy.js +0 -1
- package/dist/editor/editor-utils.d.ts +2 -2
- package/dist/editor/editor-utils.js +2 -2
- package/dist/editor/entry-search.d.ts +10 -16
- package/dist/editor/entry-search.js +4 -24
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +13 -9
- package/dist/editor/hooks/useEntryManager.d.ts +3 -0
- package/dist/editor/hooks/useEntryManager.js +10 -3
- package/dist/editor/shell/ActivityRail.d.ts +4 -0
- package/dist/editor/shell/ActivityRail.js +13 -4
- package/dist/editor/shell/EditorShell.d.ts +6 -7
- package/dist/editor/shell/EditorShell.js +9 -13
- package/dist/editor/shell/PaneLayout.d.ts +1 -0
- package/dist/editor/shell/PaneLayout.js +23 -5
- package/dist/editor/shell/SidePanelHost.d.ts +11 -4
- package/dist/editor/shell/SidePanelHost.js +95 -7
- package/dist/editor/shell/TopBar.d.ts +4 -4
- package/dist/editor/shell/TopBar.js +6 -3
- package/dist/editor/utils/focus-return.d.ts +10 -2
- package/dist/editor/utils/focus-return.js +40 -6
- package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
- package/dist/operating-mode/client-safe-strategy.js +1 -10
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/editor/components/BranchLockBanner.d.ts +0 -23
- package/dist/editor/components/BranchLockBanner.js +0 -66
- package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
- package/dist/editor/components/QuickOpenPalette.js +0 -71
- package/dist/editor/components/SaveSplitButton.d.ts +0 -31
- package/dist/editor/components/SaveSplitButton.js +0 -121
|
@@ -1,61 +1,118 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
4
|
+
import { useMediaQuery, useMergedRef } from '@mantine/hooks';
|
|
5
|
+
import { IconChevronDown, IconLock } from '@tabler/icons-react';
|
|
4
6
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
|
|
5
|
-
import {
|
|
7
|
+
import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
|
|
6
8
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
7
9
|
import { SYSTEM_USER_ID } from '../../system-users.js';
|
|
8
10
|
import { TopBar } from '../shell/TopBar.js';
|
|
9
|
-
import { describeBranchLock } from './BranchLockBanner.js';
|
|
10
11
|
import { BranchPill } from './BranchPill.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
13
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
14
|
+
import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
|
|
15
|
+
import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
13
16
|
/**
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
17
|
+
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
18
|
+
* warning, Save all, the way forward and Submit or Withdraw. The open entry's own controls are in
|
|
19
|
+
* `EntryHeader`.
|
|
16
20
|
*/
|
|
17
|
-
export function EditorHeader({
|
|
18
|
-
|
|
19
|
-
const branchLockReason = describeBranchLock(
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
21
|
+
export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
22
|
+
const lock = { branchStatus, branchReadOnly, branchWriteBlocked, branchPending, branchMissing };
|
|
23
|
+
const branchLockReason = describeBranchLock(lock);
|
|
24
|
+
const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
|
|
25
|
+
const theme = useMantineTheme();
|
|
26
|
+
// Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
|
|
27
|
+
const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
|
|
28
|
+
const narrow = useMediaQuery(belowMd, false, { getInitialValueInEffect: false }) === true;
|
|
29
|
+
const editable = !!branchName && !branchLoading && !branchLockReason;
|
|
30
|
+
const workflow = workflowAction({
|
|
31
|
+
branchName,
|
|
32
|
+
busy,
|
|
33
|
+
branchStatus,
|
|
34
|
+
userContext,
|
|
35
|
+
branchCreatedBy,
|
|
36
|
+
branchAccess,
|
|
37
|
+
branchIsProtected,
|
|
38
|
+
savingAll: !!saveAllProgress,
|
|
39
|
+
});
|
|
40
|
+
// Below `md` one split button holds the bar's actions: Save all on an editable branch, else the
|
|
41
|
+
// row's way forward, with the workflow action in its caret. Withdraw leads only for a user who
|
|
42
|
+
// may withdraw; anyone else gets Switch branch…, the blocked Withdraw and its reason in the
|
|
43
|
+
// caret. A protected branch keeps New branch… either way. Wide, a Withdraw the user can't take
|
|
44
|
+
// shows blocked, with no Switch branch… beside it: that stays in the pill's menu.
|
|
45
|
+
const split = narrow && editable;
|
|
46
|
+
const workflowLeads = status.action === 'workflow' && !!workflow?.permitted;
|
|
47
|
+
const barAction = status.action === 'workflow'
|
|
48
|
+
? narrow && !workflowLeads
|
|
49
|
+
? 'switchBranch'
|
|
50
|
+
: undefined
|
|
51
|
+
: status.action;
|
|
52
|
+
// Controls come and go as a submit or withdraw changes the lock, and focus follows them.
|
|
53
|
+
const [[wayRef, saveAllRef, workflowRef], focusTarget] = useFocusHandOff(3);
|
|
54
|
+
const workflowControl = {
|
|
55
|
+
onSubmit,
|
|
56
|
+
onWithdraw,
|
|
57
|
+
controlRef: workflowRef,
|
|
58
|
+
// A withdraw that leaves no workflow action returns focus to the way forward, else Save all.
|
|
59
|
+
returnFocusTo: () => focusTarget(2),
|
|
60
|
+
};
|
|
61
|
+
const caretOnWay = narrow && !!barAction && !!workflow;
|
|
62
|
+
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, !!workflow?.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
|
|
63
|
+
// Its reserved width follows this branch's counts, not the session's largest.
|
|
64
|
+
, { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
|
|
51
65
|
(!branchName
|
|
52
66
|
? 'Open a branch first.'
|
|
53
67
|
: entryListFailed && modifiedCount > 0
|
|
54
68
|
? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
|
|
55
|
-
: undefined), saveAllProgress: saveAllProgress,
|
|
69
|
+
: undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
|
|
70
|
+
? onNewBranch
|
|
71
|
+
: barAction === 'refreshBranch'
|
|
72
|
+
? onBranchReloadData
|
|
73
|
+
: onBranchManagerOpen, caret: caretOnWay && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined })), !split && !caretOnWay && workflow && (_jsx(WorkflowButton, { ...workflowControl, action: workflow }))] }) }));
|
|
56
74
|
}
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
const TOOLTIP_EVENTS = { hover: true, focus: true, touch: false };
|
|
76
|
+
/**
|
|
77
|
+
* On a locked branch, what would let its drafts be saved. A new branch starts without them, so a
|
|
78
|
+
* branch never editable again leaves only discarding, as `ReadOnlyDraftNotice` says.
|
|
79
|
+
*/
|
|
80
|
+
function unsavedTooltip(status, count, mayWithdraw) {
|
|
81
|
+
if (!status?.readOnly)
|
|
82
|
+
return 'Show unsaved entries in the content panel';
|
|
83
|
+
const edits = `${count} unsaved ${count === 1 ? 'edit' : 'edits'} in this browser.`;
|
|
84
|
+
if (status.action === 'workflow' && mayWithdraw)
|
|
85
|
+
return `${edits} Withdraw to save them.`;
|
|
86
|
+
if (status.value === 'unavailable')
|
|
87
|
+
return `${edits} Use "Refresh branch" to load its details.`;
|
|
88
|
+
if (status.value === 'not-available')
|
|
89
|
+
return `${edits} They can't be saved unless this branch is available to you.`;
|
|
90
|
+
if (status.value === 'protected' || status.value === 'archived')
|
|
91
|
+
return `${edits} This branch won't become editable, so they can only be discarded.`;
|
|
92
|
+
return `${edits} They can be saved once the branch is editable again.`;
|
|
93
|
+
}
|
|
94
|
+
/** The branch's state beside the pill, heard in words with its reason, but not while it loads. */
|
|
95
|
+
function BranchStatusLabel({ status, reason }) {
|
|
96
|
+
const spoken = status.label && status.value !== 'loading'
|
|
97
|
+
? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
|
|
98
|
+
: '';
|
|
99
|
+
return (
|
|
100
|
+
// Always mounted, so a lock that arrives is announced.
|
|
101
|
+
_jsxs(Box, { role: "status", style: status.label ? { flexShrink: 0 } : HIDDEN_STYLE, children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), _jsx(VisuallyHidden, { children: spoken })] }));
|
|
102
|
+
}
|
|
103
|
+
const WAY_LABEL = {
|
|
104
|
+
newBranch: `${EDITOR_ACTIONS.newBranch}…`,
|
|
105
|
+
switchBranch: `${EDITOR_ACTIONS.switchBranch}…`,
|
|
106
|
+
refreshBranch: 'Refresh branch',
|
|
107
|
+
};
|
|
108
|
+
/** The way forward from a locked branch: New branch…, Switch branch… or Refresh branch. */
|
|
109
|
+
function BranchWayButton({ action, onClick, caret, buttonRef, }) {
|
|
110
|
+
return (
|
|
111
|
+
// Grouped even alone, as Save all is, so a caret coming or going never remounts the button.
|
|
112
|
+
_jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => (_jsx("div", { ...props, role: caret ? 'group' : undefined, "aria-label": caret ? 'Branch controls' : undefined })), children: [_jsx(Button, { ref: buttonRef, variant: "light", size: "sm", onClick: () => onClick(), "data-testid": `${action.replace('Branch', '')}-branch-button`, children: WAY_LABEL[action] }), caret] }));
|
|
113
|
+
}
|
|
114
|
+
/** Submit for review, or Withdraw from review once submitted or approved; null when neither shows. */
|
|
115
|
+
function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, savingAll, }) {
|
|
59
116
|
const isSubmitted = branchStatus === 'submitted';
|
|
60
117
|
const isEditing = branchStatus === 'editing';
|
|
61
118
|
// 'approved' withdraws too -- it is that status's only
|
|
@@ -89,31 +146,54 @@ function WorkflowButton({ branchName, busy, branchStatus, userContext, branchCre
|
|
|
89
146
|
// sent people to an admin to fix a non-permissions problem.
|
|
90
147
|
const statusHasAction = isEditing || isWithdrawable;
|
|
91
148
|
const canPerformAction = userHasPermission && statusHasAction;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
return {
|
|
150
|
+
kind: isWithdrawable ? 'withdraw' : 'submit',
|
|
151
|
+
// `busy` alone can't hold it: an entry load clears that shared flag mid-run.
|
|
152
|
+
blocked: !branchName || busy || savingAll || !canPerformAction,
|
|
153
|
+
permitted: canPerformAction,
|
|
154
|
+
reason: savingAll
|
|
155
|
+
? 'Wait for Save all to finish.'
|
|
156
|
+
: // No branch data (`branchStatus === undefined`) must not be
|
|
157
|
+
// interpolated raw into this label. Guarded explicitly here rather
|
|
158
|
+
// than left to the early return above, which hides the action
|
|
159
|
+
// today only as a side effect of `branchIsProtected`'s `?? true`
|
|
160
|
+
// default -- not a guarantee this arm stays unreachable.
|
|
161
|
+
branchStatus === undefined
|
|
162
|
+
? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
|
|
163
|
+
: !statusHasAction
|
|
164
|
+
? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
|
|
165
|
+
: !userHasPermission
|
|
166
|
+
? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
|
|
167
|
+
: '',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function WorkflowButton({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
|
|
171
|
+
const withdraw = action.kind === 'withdraw';
|
|
172
|
+
return (_jsx(Tooltip, { label: action.reason, disabled: !action.reason, multiline: true, maw: 320, children: _jsx(Button, { ref: controlRef, size: "sm",
|
|
173
|
+
// The entry's Save is the one filled primary; the rest take their colour's light variant.
|
|
174
|
+
variant: "light", color: withdraw ? 'orange' : undefined, style: { flexShrink: 0 }, onClick: () => {
|
|
175
|
+
if (action.blocked)
|
|
113
176
|
return;
|
|
114
|
-
if (
|
|
115
|
-
onWithdraw();
|
|
177
|
+
if (withdraw)
|
|
178
|
+
onWithdraw(returnFocusTo);
|
|
116
179
|
else
|
|
117
|
-
onSubmit();
|
|
118
|
-
}, "data-disabled":
|
|
180
|
+
onSubmit(returnFocusTo);
|
|
181
|
+
}, "data-disabled": action.blocked ? true : undefined, "aria-disabled": action.blocked ? true : undefined, "data-testid": withdraw ? 'withdraw-button' : 'submit-button', children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) }));
|
|
182
|
+
}
|
|
183
|
+
/** Save all's caret below `md`, holding the workflow action. */
|
|
184
|
+
function WorkflowCaret({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
|
|
185
|
+
const caret = useRef(null);
|
|
186
|
+
const caretRef = useMergedRef(caret, controlRef);
|
|
187
|
+
const menuFocus = useMenuItemFocusReturn(caret);
|
|
188
|
+
const withdraw = action.kind === 'withdraw';
|
|
189
|
+
return (
|
|
190
|
+
// Bounded, so a blocked item's reason wraps on a phone rather than run off the screen.
|
|
191
|
+
_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Branch actions", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", size: "sm", px: 8, "aria-label": "Branch actions", "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: _jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: action.blocked
|
|
192
|
+
? action.reason || 'Wait for the current save or load to finish.'
|
|
193
|
+
: undefined, onClick: () => {
|
|
194
|
+
if (withdraw)
|
|
195
|
+
onWithdraw(returnFocusTo);
|
|
196
|
+
else
|
|
197
|
+
onSubmit(returnFocusTo);
|
|
198
|
+
}, children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) })] }));
|
|
119
199
|
}
|
|
@@ -7,13 +7,13 @@ export interface EditorRailProps {
|
|
|
7
7
|
onContentToggle: () => void;
|
|
8
8
|
/** The Content button, which takes focus when the open entry it was reached from is gone. */
|
|
9
9
|
contentToggleRef?: Ref<HTMLButtonElement>;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
/** The shortcut that opens the panel at its search field: "⌘P", and "Meta+P" for `aria-keyshortcuts`. */
|
|
11
|
+
contentShortcut: {
|
|
12
|
+
label: string;
|
|
13
|
+
keys: string;
|
|
14
|
+
};
|
|
15
|
+
/** Entries with unsaved changes: the Content button marks them while its panel is closed. */
|
|
16
|
+
unsavedCount: number;
|
|
17
17
|
/** Without it, the Comments button is not offered (no branch, or a mode without comments). */
|
|
18
18
|
onCommentsOpen?: () => void;
|
|
19
19
|
commentsOpen: boolean;
|
|
@@ -45,4 +45,4 @@ export declare function SettingsMenu({ items, toggleRef, }: {
|
|
|
45
45
|
toggleRef?: Ref<HTMLButtonElement>;
|
|
46
46
|
}): import("react/jsx-runtime").JSX.Element | null;
|
|
47
47
|
/** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
|
|
48
|
-
export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef,
|
|
48
|
+
export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, contentShortcut, unsavedCount, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -3,11 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useRef, useState } from 'react';
|
|
4
4
|
import { ActionIcon, Menu, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
5
|
import { useMergedRef } from '@mantine/hooks';
|
|
6
|
-
import { IconActivity, IconExternalLink, IconFiles,
|
|
7
|
-
import { ActivityRail, RailButton } from '../shell/ActivityRail.js';
|
|
6
|
+
import { IconActivity, IconExternalLink, IconFiles, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSettings, IconUserCircle, } from '@tabler/icons-react';
|
|
7
|
+
import { ActivityRail, RailButton, RailDivider } from '../shell/ActivityRail.js';
|
|
8
8
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
9
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
9
10
|
import { CANOPYCMS_VERSION } from '../../version.js';
|
|
10
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
11
11
|
const ABOUT_URL = 'https://github.com/safeinsights/canopycms';
|
|
12
12
|
/**
|
|
13
13
|
* @internal Exported for tests. The Settings button and its menu of `items`; with none, no
|
|
@@ -41,7 +41,7 @@ function LayoutButton({ layout, onChange, blocked = false, }) {
|
|
|
41
41
|
} }), _jsx(VisuallyHidden, { role: "status", "data-testid": "rail-layout-status", children: switchedTo })] }));
|
|
42
42
|
}
|
|
43
43
|
/** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
|
|
44
|
-
export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef,
|
|
44
|
+
export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, contentShortcut, unsavedCount, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
|
|
45
45
|
const adminItems = [
|
|
46
46
|
onPermissionManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-permissions", onClick: onPermissionManagerOpen, children: "Manage permissions" }, "permissions")),
|
|
47
47
|
onGroupManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-groups", onClick: onGroupManagerOpen, children: "Manage groups" }, "groups")),
|
|
@@ -59,6 +59,8 @@ export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle,
|
|
|
59
59
|
_jsx(Menu.Label, { "data-testid": "settings-menu-version", children: `CanopyCMS ${CANOPYCMS_VERSION}` }, "version"),
|
|
60
60
|
_jsxs(Menu.Item, { component: "a", href: ABOUT_URL, target: "_blank", rel: "noopener noreferrer", "data-testid": "settings-menu-about", leftSection: _jsx(IconInfoCircle, { size: 14 }), rightSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), children: ["About CanopyCMS", _jsx(VisuallyHidden, { component: "span", children: " (opens in a new tab)" })] }, "about"),
|
|
61
61
|
];
|
|
62
|
+
const unsaved = !contentPanelOpen && unsavedCount > 0;
|
|
63
|
+
const contentLabel = unsaved ? `Content, ${unsavedCount} unsaved` : 'Content';
|
|
62
64
|
const commentsLabel = unresolvedCommentCount > 0 ? `Comments, ${unresolvedCommentCount} unresolved` : 'Comments';
|
|
63
|
-
return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label:
|
|
65
|
+
return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label: contentLabel, corner: unsaved ? _jsx(UnsavedMark, { "aria-hidden": true, "data-testid": "rail-unsaved-mark" }) : undefined, icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, shortcut: contentShortcut.label, "aria-keyshortcuts": contentShortcut.keys, "data-testid": "content-panel-toggle", ref: contentToggleRef, onClick: onContentToggle }), onCommentsOpen && (_jsx(RailButton, { railId: "comments", label: commentsLabel, icon: _jsx(IconMessageCircle, { size: 20 }), active: commentsOpen, count: unresolvedCommentCount, countTestId: "comments-unresolved-count", "aria-haspopup": "dialog", "data-testid": "comments-button", onClick: onCommentsOpen })), _jsx(RailButton, { railId: "media", label: "Media library", icon: _jsx(IconPhoto, { size: 20 }), active: mediaLibraryOpen, "aria-haspopup": "dialog", "data-testid": "media-library-button", ref: mediaButtonRef, onClick: onMediaLibraryOpen }), _jsx(RailDivider, {}), _jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked })] }), bottom: _jsx(SettingsMenu, { items: settingsItems, toggleRef: settingsToggleRef }), account: AccountComponent ? (_jsx(AccountComponent, {})) : onAccountClick || onLogoutClick ? (_jsxs(Stack, { gap: 6, align: "center", children: [onAccountClick && (_jsx(Tooltip, { label: "Account", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Account", onClick: onAccountClick, children: _jsx(IconUserCircle, { size: 20 }) }) })), onLogoutClick && (_jsx(Tooltip, { label: "Sign out", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Sign out", onClick: onLogoutClick, children: _jsx(IconLogout, { size: 20 }) }) }))] })) : undefined }));
|
|
64
66
|
}
|
|
@@ -5,11 +5,15 @@ export interface EntryBreadcrumbProps {
|
|
|
5
5
|
segments: BreadcrumbSegment[];
|
|
6
6
|
/** The open entry's label; undefined when no entry is open. */
|
|
7
7
|
entryLabel: string | undefined;
|
|
8
|
+
/** No entry yet because the branch or its entries are still loading: a placeholder crumb shows. */
|
|
9
|
+
entryLoading?: boolean;
|
|
8
10
|
/** The open entry has unsaved changes. */
|
|
9
11
|
entryDirty: boolean;
|
|
10
12
|
/** Why the open entry is read-only; set, the "Read-only" chip shows. */
|
|
11
13
|
readOnlyReason?: string;
|
|
12
14
|
onCollection: (collectionPath: string) => void;
|
|
15
|
+
/** Offers "Show in content panel", which brings the entry's row into view and focuses it. */
|
|
16
|
+
onReveal?: () => void;
|
|
13
17
|
onRefresh: () => void;
|
|
14
18
|
onDiscard: () => void;
|
|
15
19
|
/** With `onDelete`, offers "Change URL…" and "Delete entry"; absent where a path rule refuses them. */
|
|
@@ -29,4 +33,4 @@ export interface EntryBreadcrumbProps {
|
|
|
29
33
|
* the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
|
|
30
34
|
* entry's own does.
|
|
31
35
|
*/
|
|
32
|
-
export declare function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export declare function EntryBreadcrumb({ segments, entryLabel, entryLoading, entryDirty, readOnlyReason, onCollection, onReveal, onRefresh, onDiscard, onRename, entryNoPage, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
4
|
+
import { Badge, Box, Button, Group, Menu, Skeleton, Text, Tooltip, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { useMergedRef } from '@mantine/hooks';
|
|
6
6
|
import { IconChevronDown, IconChevronRight, IconLock } from '@tabler/icons-react';
|
|
7
7
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
@@ -40,7 +40,7 @@ const separator = (_jsx(IconChevronRight, { size: 14, "aria-hidden": "true", sty
|
|
|
40
40
|
* the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
|
|
41
41
|
* entry's own does.
|
|
42
42
|
*/
|
|
43
|
-
export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
|
|
43
|
+
export function EntryBreadcrumb({ segments, entryLabel, entryLoading = false, entryDirty, readOnlyReason, onCollection, onReveal, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
|
|
44
44
|
const trigger = useRef(null);
|
|
45
45
|
const triggerRef = useMergedRef(trigger, entryMenuRef);
|
|
46
46
|
const menuFocus = useMenuItemFocusReturn(trigger);
|
|
@@ -49,5 +49,5 @@ export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReas
|
|
|
49
49
|
// Clips crumbs that overflow, padded by the focus ring's reach so the ring is not clipped.
|
|
50
50
|
style: { flex: '0 1 auto', overflow: 'hidden', padding: 4, margin: -4 }, children: _jsxs(Group, { component: "ol", gap: 2, wrap: "nowrap", miw: 0, style: { listStyle: 'none', margin: 0, padding: 0 }, children: [segments.map((segment, index) => (_jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 0,
|
|
51
51
|
// Collections give up their width long before the entry's own crumb does.
|
|
52
|
-
style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, onClick: () => onCollection(segment.collectionPath) }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [segments.length > 0 && separator, entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(UnsavedMark, { "aria-hidden": "true", "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsxs(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
|
|
52
|
+
style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, onClick: () => onCollection(segment.collectionPath) }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [segments.length > 0 && separator, entryLabel === undefined && entryLoading ? (_jsx(Skeleton, { height: 16, width: 120, mx: 4, "data-testid": "entry-crumb-loading", children: _jsx(VisuallyHidden, { children: "Loading\u2026" }) })) : entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(UnsavedMark, { "aria-hidden": "true", "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [onReveal && (_jsxs(_Fragment, { children: [_jsx(Menu.Item, { onClick: onReveal, "data-testid": "entry-menu-reveal", children: "Show in content panel" }), _jsx(Menu.Divider, {})] })), _jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsxs(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
|
|
53
53
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Ref } from 'react';
|
|
2
|
+
import type { BranchStatus } from '../../types.js';
|
|
3
|
+
import type { EditorEntry } from '../Editor.js';
|
|
4
|
+
import type { BreadcrumbSegment } from '../editor-utils.js';
|
|
5
|
+
export interface EntryHeaderProps {
|
|
6
|
+
currentEntry: EditorEntry | undefined;
|
|
7
|
+
/** No entry yet because the branch or its entries are still loading. */
|
|
8
|
+
entryLoading?: boolean;
|
|
9
|
+
branchName: string;
|
|
10
|
+
/** Whether operations are currently in progress. */
|
|
11
|
+
busy: boolean;
|
|
12
|
+
/** The crumbs before the open entry's own. */
|
|
13
|
+
breadcrumbSegments: BreadcrumbSegment[];
|
|
14
|
+
/** Opens the content panel on a collection. */
|
|
15
|
+
onCollectionOpen: (collectionPath: string) => void;
|
|
16
|
+
/** Opens the content panel on the open entry's row. */
|
|
17
|
+
onEntryReveal?: () => void;
|
|
18
|
+
/** The entry menu's trigger. */
|
|
19
|
+
navigatorToggleRef?: Ref<HTMLButtonElement>;
|
|
20
|
+
/** Replaces the open entry with the server's copy. */
|
|
21
|
+
onFileReload: () => void;
|
|
22
|
+
onFileDiscardDraft: () => void;
|
|
23
|
+
/** Opens the open entry's "Change URL" dialog. */
|
|
24
|
+
onEntryRename: () => void;
|
|
25
|
+
/** Asks to delete the open entry. */
|
|
26
|
+
onEntryDelete: () => void;
|
|
27
|
+
onSave: () => void;
|
|
28
|
+
/** Save all is running. */
|
|
29
|
+
saveAllRunning: boolean;
|
|
30
|
+
/** A Save of the open entry is in flight. */
|
|
31
|
+
saving?: boolean;
|
|
32
|
+
/** Rises each time a single Save lands, whichever entry it saved. */
|
|
33
|
+
savedSignal?: number;
|
|
34
|
+
/** The last Save that landed saved the open entry. */
|
|
35
|
+
savedHere?: boolean;
|
|
36
|
+
/** The open entry has unsaved changes it could save. */
|
|
37
|
+
hasUnsavedChanges: boolean;
|
|
38
|
+
/** The open entry has a draft, savable or not (a read-only branch's still discards). */
|
|
39
|
+
entryHasDraft?: boolean;
|
|
40
|
+
/** Writing drafts to localStorage failed, so they live only in this tab. */
|
|
41
|
+
draftStorageFailed?: boolean;
|
|
42
|
+
/** The open entry can't be edited by this user (a path rule). */
|
|
43
|
+
entryReadOnly?: boolean;
|
|
44
|
+
/** The open entry's type has no schema in the running code. */
|
|
45
|
+
entryUnavailable?: boolean;
|
|
46
|
+
/** Current branch status (undefined if unknown). */
|
|
47
|
+
branchStatus: BranchStatus | undefined;
|
|
48
|
+
/** Whether the current branch is read-only (protected base branch in prod); disables Save. */
|
|
49
|
+
branchReadOnly?: boolean;
|
|
50
|
+
/** Server-computed: content writes are rejected on this branch; see `branchLockState`. */
|
|
51
|
+
branchWriteBlocked?: boolean;
|
|
52
|
+
/** No branch details yet: the fail-closed lock defaults are guesses the chip doesn't name. */
|
|
53
|
+
branchLoading?: boolean;
|
|
54
|
+
/** The branch list is still being fetched, so Save waits rather than naming a failure. */
|
|
55
|
+
branchPending?: boolean;
|
|
56
|
+
/** The branch list answered without this branch. */
|
|
57
|
+
branchMissing?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
60
|
+
export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Box, Group } from '@mantine/core';
|
|
4
|
+
import { SHELL_REGION_BORDER } from '../shell/EditorShell.js';
|
|
5
|
+
import { describeBranchLock } from '../branch-lock.js';
|
|
6
|
+
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
7
|
+
import { EntrySaveButton } from './EntrySaveButton.js';
|
|
8
|
+
const ENTRY_HEADER_HEIGHT = 48;
|
|
9
|
+
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
10
|
+
export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
11
|
+
const branchLockReason = describeBranchLock({
|
|
12
|
+
branchStatus,
|
|
13
|
+
branchReadOnly,
|
|
14
|
+
branchWriteBlocked,
|
|
15
|
+
branchPending,
|
|
16
|
+
branchMissing,
|
|
17
|
+
});
|
|
18
|
+
const entryLockReason = entryReadOnly
|
|
19
|
+
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
20
|
+
: undefined;
|
|
21
|
+
// The Tooltip hides it while the editor is busy.
|
|
22
|
+
const saveBlockedReason = branchLockReason ??
|
|
23
|
+
(!branchName
|
|
24
|
+
? 'Open a branch first.'
|
|
25
|
+
: !currentEntry
|
|
26
|
+
? 'Open an entry to save it.'
|
|
27
|
+
: (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
|
|
28
|
+
return (_jsxs(Group, { component: "section", "aria-label": "Entry", "data-testid": "entry-header", gap: "xs", wrap: "nowrap", justify: "space-between", px: "sm", py: 6, mih: ENTRY_HEADER_HEIGHT, style: {
|
|
29
|
+
flexShrink: 0,
|
|
30
|
+
borderBottom: SHELL_REGION_BORDER,
|
|
31
|
+
background: 'var(--mantine-color-body)',
|
|
32
|
+
}, children: [_jsx(Box, { miw: 0, style: { flex: '1 1 0%' }, children: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryLoading: entryLoading, entryDirty: hasUnsavedChanges,
|
|
33
|
+
// The branch bar's status already says a locked branch is read-only, so the chip carries
|
|
34
|
+
// only the entry's own reason: "Withdraw it" would not unlock the entry.
|
|
35
|
+
readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onReveal: onEntryReveal, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
|
|
36
|
+
// A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
|
|
37
|
+
onRename: entryReadOnly ? undefined : onEntryRename,
|
|
38
|
+
// An entry with a page has a preview `src`; one without changes only its slug.
|
|
39
|
+
entryNoPage: !!currentEntry && !currentEntry.previewSrc, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
|
|
40
|
+
(entryUnavailable
|
|
41
|
+
? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
|
|
42
|
+
: undefined), entryMenuRef: navigatorToggleRef }) }), _jsx(EntrySaveButton, { onSave: onSave, saveBlockedReason: saveBlockedReason,
|
|
43
|
+
// Loading, "Open an entry to save it." would claim an empty state too soon.
|
|
44
|
+
busy: busy || entryLoading, saveAllRunning: saveAllRunning, saving: saving, savedSignal: savedSignal, savedHere: savedHere, entryDirty: hasUnsavedChanges, draftStorageFailed: draftStorageFailed })] }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface EntrySaveButtonProps {
|
|
2
|
+
onSave: () => void;
|
|
3
|
+
/** Why Save can't act now; empty when it can. */
|
|
4
|
+
saveBlockedReason: string;
|
|
5
|
+
/** The editor is loading or saving: Save waits without a reason. */
|
|
6
|
+
busy: boolean;
|
|
7
|
+
/** Save all is running, so Save waits for it, and says so. */
|
|
8
|
+
saveAllRunning: boolean;
|
|
9
|
+
/** A Save of the open entry is in flight. */
|
|
10
|
+
saving?: boolean;
|
|
11
|
+
/** Rises each time a single Save lands, whichever entry it saved. */
|
|
12
|
+
savedSignal?: number;
|
|
13
|
+
/** The last Save that landed saved the open entry. */
|
|
14
|
+
savedHere?: boolean;
|
|
15
|
+
/** The open entry has unsaved changes. */
|
|
16
|
+
entryDirty: boolean;
|
|
17
|
+
/** Writing drafts to localStorage failed, so they live only in this tab. */
|
|
18
|
+
draftStorageFailed: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The open entry's Save, reading "Saving…" then "Saved" as a save lands. While the entry has
|
|
22
|
+
* unsaved changes, its Tooltip and description say they are kept on this device.
|
|
23
|
+
*/
|
|
24
|
+
export declare function EntrySaveButton({ onSave, saveBlockedReason, busy, saveAllRunning, saving, savedSignal, savedHere, entryDirty, draftStorageFailed, }: EntrySaveButtonProps): import("react/jsx-runtime").JSX.Element;
|