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
|
@@ -14,6 +14,8 @@ export declare function branchLockState({ branchStatus, branchReadOnly, branchWr
|
|
|
14
14
|
statusLocked: boolean;
|
|
15
15
|
branchDataUnavailable: boolean;
|
|
16
16
|
};
|
|
17
|
+
/** Whether the banner renders; the top bar reads the same answer, so the two cannot drift. */
|
|
18
|
+
export declare function branchLockBannerShown(lock: BranchLockInput): boolean;
|
|
17
19
|
export interface BranchLockBannerProps extends BranchLockInput {
|
|
18
20
|
branchName: string;
|
|
19
21
|
onBranchManagerOpen: () => void;
|
|
@@ -15,10 +15,14 @@ export function branchLockState({ branchStatus, branchReadOnly = false, branchWr
|
|
|
15
15
|
const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
|
|
16
16
|
return { statusLocked, branchDataUnavailable };
|
|
17
17
|
}
|
|
18
|
+
/** Whether the banner renders; the top bar reads the same answer, so the two cannot drift. */
|
|
19
|
+
export function branchLockBannerShown(lock) {
|
|
20
|
+
return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
|
|
21
|
+
}
|
|
18
22
|
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
19
23
|
export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
|
|
20
|
-
const {
|
|
21
|
-
if (!lock
|
|
24
|
+
const { branchDataUnavailable } = branchLockState(lock);
|
|
25
|
+
if (!branchLockBannerShown(lock))
|
|
22
26
|
return null;
|
|
23
27
|
const [testId, message, action] = lock.branchReadOnly
|
|
24
28
|
? [
|
|
@@ -4,7 +4,7 @@ export interface BranchPillProps {
|
|
|
4
4
|
branchName: string;
|
|
5
5
|
/** The editor is busy; with no branch name yet, that is the branch list loading. */
|
|
6
6
|
loading: boolean;
|
|
7
|
-
/** The status label ("
|
|
7
|
+
/** The status label ("In review", "Protected"), set only for a status worth a place in the bar. */
|
|
8
8
|
status?: {
|
|
9
9
|
label: string;
|
|
10
10
|
testId: string;
|
|
@@ -25,8 +25,9 @@ export interface BranchPillProps {
|
|
|
25
25
|
pillRef?: Ref<HTMLButtonElement>;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* The branch's name, status and unsaved count
|
|
29
|
-
* unsaved entries, each opening that entry. Below `sm` it is the
|
|
30
|
-
* name then in its Tooltip, accessible name and menu label: the
|
|
28
|
+
* The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
|
|
29
|
+
* branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
|
|
30
|
+
* branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
|
|
31
|
+
* row's actions must not clip.
|
|
31
32
|
*/
|
|
32
33
|
export declare function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -7,12 +7,14 @@ import { IconChevronDown, IconGitBranch } from '@tabler/icons-react';
|
|
|
7
7
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
8
|
import { DestructiveMenuItem } from './DestructiveMenuItem.js';
|
|
9
9
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
10
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
10
11
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
11
12
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
12
13
|
/**
|
|
13
|
-
* The branch's name, status and unsaved count
|
|
14
|
-
* unsaved entries, each opening that entry. Below `sm` it is the
|
|
15
|
-
* name then in its Tooltip, accessible name and menu label: the
|
|
14
|
+
* The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
|
|
15
|
+
* branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
|
|
16
|
+
* branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
|
|
17
|
+
* row's actions must not clip.
|
|
16
18
|
*/
|
|
17
19
|
export function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }) {
|
|
18
20
|
const trigger = useRef(null);
|
|
@@ -23,11 +25,7 @@ export function BranchPill({ branchName, loading, status, unsavedCount: unsaved,
|
|
|
23
25
|
const name = branchName || (loading ? 'Loading branches…' : 'No branch open');
|
|
24
26
|
return (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: status ? `${name} · ${status.label}` : name, disabled: wide, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "default", size: "sm", miw: 0, px: { base: 8, sm: 14 },
|
|
25
27
|
// The count is the bar's one branch-wide unsaved signal: the row wraps before it clips.
|
|
26
|
-
style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Text, { span: true, size: "xs", fw: 600, px: 6,
|
|
27
|
-
flexShrink: 0,
|
|
28
|
-
borderRadius: 'var(--mantine-radius-xl)',
|
|
29
|
-
background: 'var(--canopy-count-bg)',
|
|
30
|
-
}, "data-testid": "branch-unsaved-count", children: [unsaved, _jsx(Box, { component: "span", visibleFrom: "sm", children: ' unsaved' }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: ' unsaved' })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
|
|
28
|
+
style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Box, { component: "span", display: "inline-flex", style: { alignItems: 'center', gap: 4, flexShrink: 0 }, "data-testid": "branch-unsaved-count", children: [_jsx(UnsavedMark, { "aria-hidden": "true" }), _jsx(Text, { span: true, size: "xs", fw: 600, "aria-hidden": "true", children: unsaved }), _jsx(VisuallyHidden, { component: "span", children: `, ${plural(unsaved, 'entry', 'entries')} with unsaved changes` })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
|
|
31
29
|
? `Discard all changes (${plural(unsaved, 'entry', 'entries')})`
|
|
32
30
|
: 'Discard all changes' })] })] }));
|
|
33
31
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx
|
|
3
|
-
import {
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
4
4
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
5
|
-
export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(
|
|
5
|
+
export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(OverlayDrawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: "Branches", size: 420, ...focusReturnProps(returnFocusTo), children: children }));
|
|
@@ -20,8 +20,6 @@ export interface EditorHeaderProps {
|
|
|
20
20
|
}>;
|
|
21
21
|
/** Entries with unsaved changes on the branch. */
|
|
22
22
|
modifiedCount: number;
|
|
23
|
-
/** Opens the content panel. */
|
|
24
|
-
onNavigatorOpen: () => void;
|
|
25
23
|
/** Opens the content panel on a collection. */
|
|
26
24
|
onCollectionOpen: (collectionPath: string) => void;
|
|
27
25
|
/** The entry menu's trigger. */
|
|
@@ -31,12 +29,10 @@ export interface EditorHeaderProps {
|
|
|
31
29
|
/** Replaces the open entry with the server's copy. */
|
|
32
30
|
onFileReload: () => void;
|
|
33
31
|
onFileDiscardDraft: () => void;
|
|
34
|
-
/** Opens the open entry's
|
|
32
|
+
/** Opens the open entry's "Change URL" dialog. */
|
|
35
33
|
onEntryRename: () => void;
|
|
36
34
|
/** Asks to delete the open entry. */
|
|
37
35
|
onEntryDelete: () => void;
|
|
38
|
-
/** The "All content" crumb. */
|
|
39
|
-
allContentRef?: Ref<HTMLButtonElement>;
|
|
40
36
|
onEntrySelect: (path: LogicalPath) => void;
|
|
41
37
|
/** Re-reads the branch and its entry list, keeping drafts. */
|
|
42
38
|
onBranchReloadData: () => void;
|
|
@@ -98,4 +94,4 @@ export interface EditorHeaderProps {
|
|
|
98
94
|
* The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
|
|
99
95
|
* pill, Submit or Withdraw, and Save with Save all.
|
|
100
96
|
*/
|
|
101
|
-
export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount,
|
|
97
|
+
export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving, savedSignal, savedHere, entryListFailed, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchReadOnly, branchWriteBlocked, branchLoading, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -2,11 +2,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Button, Tooltip } from '@mantine/core';
|
|
3
3
|
import { clientOperatingStrategy } from '../../operating-mode/client.js';
|
|
4
4
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
|
|
5
|
-
import {
|
|
5
|
+
import { branchBarStatusPresentation } from '../branch-status.js';
|
|
6
6
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
7
7
|
import { SYSTEM_USER_ID } from '../../system-users.js';
|
|
8
8
|
import { TopBar } from '../shell/TopBar.js';
|
|
9
|
-
import { branchLockState } from './BranchLockBanner.js';
|
|
9
|
+
import { branchLockBannerShown, branchLockState } from './BranchLockBanner.js';
|
|
10
10
|
import { BranchPill } from './BranchPill.js';
|
|
11
11
|
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
12
12
|
import { SaveSplitButton } from './SaveSplitButton.js';
|
|
@@ -14,24 +14,24 @@ import { SaveSplitButton } from './SaveSplitButton.js';
|
|
|
14
14
|
* The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
|
|
15
15
|
* pill, Submit or Withdraw, and Save with Save all.
|
|
16
16
|
*/
|
|
17
|
-
export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount,
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
17
|
+
export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving = false, savedSignal, savedHere = false, entryListFailed = false, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, }) {
|
|
18
|
+
const lock = { branchStatus, branchReadOnly, branchWriteBlocked };
|
|
19
|
+
const { statusLocked, branchDataUnavailable } = branchLockState(lock);
|
|
20
|
+
// Why the branch takes no writes, with the way forward; shared by Save and Save all. It is set
|
|
21
|
+
// exactly when BranchLockBanner renders, which says the same.
|
|
22
|
+
const branchLockReason = !branchLockBannerShown(lock)
|
|
23
|
+
? undefined
|
|
24
|
+
: branchReadOnly
|
|
25
|
+
? 'The base branch is read-only. Create a branch to make changes.'
|
|
26
|
+
: branchDataUnavailable
|
|
27
|
+
? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
|
|
28
|
+
: statusLocked
|
|
29
|
+
? branchStatus === 'submitted'
|
|
30
|
+
? 'This branch is in review. Withdraw it to make changes.'
|
|
31
|
+
: branchStatus === 'approved'
|
|
32
|
+
? 'This branch is approved. Withdraw it to make changes.'
|
|
33
|
+
: `This branch is ${branchStatus}. Its content is read-only.`
|
|
34
|
+
: undefined;
|
|
35
35
|
const entryLockReason = entryReadOnly
|
|
36
36
|
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
37
37
|
: undefined;
|
|
@@ -42,7 +42,7 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
|
|
|
42
42
|
: !currentEntry
|
|
43
43
|
? 'Open an entry to save it.'
|
|
44
44
|
: (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
|
|
45
|
-
const presentation =
|
|
45
|
+
const presentation = branchBarStatusPresentation(branchStatus, branchIsProtected);
|
|
46
46
|
const status = !branchLoading &&
|
|
47
47
|
clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
|
|
48
48
|
presentation
|
|
@@ -52,16 +52,17 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
|
|
|
52
52
|
testId: `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`,
|
|
53
53
|
}
|
|
54
54
|
: undefined;
|
|
55
|
-
return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
: undefined, onAllContent: onNavigatorOpen, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
|
|
55
|
+
return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges,
|
|
56
|
+
// BranchLockBanner already says why a locked branch is read-only, so the chip carries
|
|
57
|
+
// only the entry's own reason, even beside the banner: "Withdraw it" would not unlock it.
|
|
58
|
+
readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
|
|
60
59
|
// A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
|
|
61
|
-
onRename: entryReadOnly ? undefined : onEntryRename,
|
|
60
|
+
onRename: entryReadOnly ? undefined : onEntryRename,
|
|
61
|
+
// An entry with a page has a preview `src`; one without changes only its slug.
|
|
62
|
+
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 ??
|
|
62
63
|
(entryUnavailable
|
|
63
64
|
? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
|
|
64
|
-
: undefined), entryMenuRef: navigatorToggleRef
|
|
65
|
+
: undefined), entryMenuRef: navigatorToggleRef }), trailing: _jsxs(_Fragment, { children: [_jsx(BranchPill, { branchName: branchName, loading: busy, status: status, unsavedCount: modifiedCount, unsavedEntries: editedFiles, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, onEntrySelect: onEntrySelect, pillRef: branchToggleRef }), _jsx(WorkflowButton, { branchName: branchName, busy: busy, branchStatus: branchStatus, userContext: userContext, branchCreatedBy: branchCreatedBy, branchAccess: branchAccess, branchIsProtected: branchIsProtected, onSubmit: onSubmit, onWithdraw: onWithdraw, savingAll: !!saveAllProgress }), _jsx(SaveSplitButton, { onSave: onSave, saveBlockedReason: saveBlockedReason, busy: busy, onSaveAll: onSaveAll, unsavedCount: modifiedCount, saveAllBlockedReason: branchLockReason ??
|
|
65
66
|
(!branchName
|
|
66
67
|
? 'Open a branch first.'
|
|
67
68
|
: entryListFailed && modifiedCount > 0
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { type ReactElement, type Ref } from 'react';
|
|
2
2
|
import type { PaneArrangement } from '../shell/PaneLayout.js';
|
|
3
3
|
export interface EditorRailProps {
|
|
4
|
-
siteTitle: string;
|
|
5
|
-
/** Opens the site's first entry, which is also what the editor opens with no entry asked for. */
|
|
6
|
-
onHomeOpen: () => void;
|
|
7
4
|
contentPanelOpen: boolean;
|
|
8
5
|
/** The content panel's element id, for `aria-controls`. */
|
|
9
6
|
contentPanelId: string;
|
|
10
7
|
onContentToggle: () => void;
|
|
8
|
+
/** The Content button, which takes focus when the open entry it was reached from is gone. */
|
|
9
|
+
contentToggleRef?: Ref<HTMLButtonElement>;
|
|
11
10
|
onQuickOpen: () => void;
|
|
12
11
|
/** The quick open shortcut as the platform writes it, such as "⌘P". */
|
|
13
12
|
quickOpenShortcut: string;
|
|
@@ -46,4 +45,4 @@ export declare function SettingsMenu({ items, toggleRef, }: {
|
|
|
46
45
|
toggleRef?: Ref<HTMLButtonElement>;
|
|
47
46
|
}): import("react/jsx-runtime").JSX.Element | null;
|
|
48
47
|
/** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
|
|
49
|
-
export declare function EditorRail({
|
|
48
|
+
export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): 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 { useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Menu, Stack,
|
|
4
|
+
import { ActionIcon, Menu, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
5
|
import { useMergedRef } from '@mantine/hooks';
|
|
6
6
|
import { IconActivity, IconExternalLink, IconFiles, IconGitBranch, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSearch, IconSettings, IconUserCircle, } from '@tabler/icons-react';
|
|
7
7
|
import { ActivityRail, RailButton } from '../shell/ActivityRail.js';
|
|
@@ -40,9 +40,8 @@ function LayoutButton({ layout, onChange, blocked = false, }) {
|
|
|
40
40
|
setSwitchedTo(LAYOUTS[other].title);
|
|
41
41
|
} }), _jsx(VisuallyHidden, { role: "status", "data-testid": "rail-layout-status", children: switchedTo })] }));
|
|
42
42
|
}
|
|
43
|
-
const monogram = (title) => Array.from(title.trim())[0]?.toUpperCase() ?? '?';
|
|
44
43
|
/** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
|
|
45
|
-
export function EditorRail({
|
|
44
|
+
export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
|
|
46
45
|
const adminItems = [
|
|
47
46
|
onPermissionManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-permissions", onClick: onPermissionManagerOpen, children: "Manage permissions" }, "permissions")),
|
|
48
47
|
onGroupManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-groups", onClick: onGroupManagerOpen, children: "Manage groups" }, "groups")),
|
|
@@ -61,5 +60,5 @@ export function EditorRail({ siteTitle, onHomeOpen, contentPanelOpen, contentPan
|
|
|
61
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"),
|
|
62
61
|
];
|
|
63
62
|
const commentsLabel = unresolvedCommentCount > 0 ? `Comments, ${unresolvedCommentCount} unresolved` : 'Comments';
|
|
64
|
-
return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "
|
|
63
|
+
return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label: "Content", icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, "data-testid": "content-panel-toggle", ref: contentToggleRef, onClick: onContentToggle }), _jsx(RailButton, { railId: "search", label: EDITOR_ACTIONS.openEntry, shortcut: quickOpenShortcut, "aria-keyshortcuts": quickOpenKeys, icon: _jsx(IconSearch, { size: 20 }), "aria-haspopup": "dialog", "data-testid": "rail-search-button", onClick: onQuickOpen }), _jsx(RailButton, { railId: "branches", label: "Branches", icon: _jsx(IconGitBranch, { size: 20 }), active: branchesOpen, "aria-haspopup": "dialog", "data-testid": "rail-branches-button", onClick: onBranchesOpen }), 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 })] }), bottom: _jsxs(_Fragment, { children: [_jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked }), _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 }));
|
|
65
64
|
}
|
|
@@ -7,27 +7,26 @@ export interface EntryBreadcrumbProps {
|
|
|
7
7
|
entryLabel: string | undefined;
|
|
8
8
|
/** The open entry has unsaved changes. */
|
|
9
9
|
entryDirty: boolean;
|
|
10
|
-
/** Why the open entry
|
|
10
|
+
/** Why the open entry is read-only; set, the "Read-only" chip shows. */
|
|
11
11
|
readOnlyReason?: string;
|
|
12
|
-
onAllContent: () => void;
|
|
13
12
|
onCollection: (collectionPath: string) => void;
|
|
14
13
|
onRefresh: () => void;
|
|
15
14
|
onDiscard: () => void;
|
|
16
|
-
/** With `onDelete`, offers "
|
|
15
|
+
/** With `onDelete`, offers "Change URL…" and "Delete entry"; absent where a path rule refuses them. */
|
|
17
16
|
onRename?: () => void;
|
|
17
|
+
/** The entry has no page on the site, so "Change URL…" reads "Change slug…". */
|
|
18
|
+
entryNoPage?: boolean;
|
|
18
19
|
onDelete?: () => void;
|
|
19
20
|
refreshBlockedReason?: string;
|
|
20
21
|
discardBlockedReason?: string;
|
|
21
|
-
/** Why the entry can't be
|
|
22
|
+
/** Why the entry's URL can't be changed, or the entry deleted, now. */
|
|
22
23
|
changeBlockedReason?: string;
|
|
23
24
|
/** The entry menu's trigger, which stands in for a menu item that opened something. */
|
|
24
25
|
entryMenuRef?: Ref<HTMLButtonElement>;
|
|
25
|
-
/** The "All content" crumb, where focus goes once the open entry is gone. */
|
|
26
|
-
allContentRef?: Ref<HTMLButtonElement>;
|
|
27
26
|
}
|
|
28
27
|
/**
|
|
29
|
-
* Where the open entry sits:
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Where the open entry sits: a collection crumb reveals that collection in the content panel, and
|
|
29
|
+
* the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
|
|
30
|
+
* entry's own does.
|
|
32
31
|
*/
|
|
33
|
-
export declare function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason,
|
|
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;
|
|
@@ -7,6 +7,7 @@ import { IconChevronDown, IconChevronRight, IconLock } from '@tabler/icons-react
|
|
|
7
7
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
8
|
import { DestructiveMenuItem } from './DestructiveMenuItem.js';
|
|
9
9
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
10
|
+
import { UnsavedMark } from './UnsavedMark.js';
|
|
10
11
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
11
12
|
/** Whether the element's text is cut short, kept current as it resizes. */
|
|
12
13
|
function useTruncated(label) {
|
|
@@ -29,17 +30,17 @@ const crumbText = (label, ref) => (_jsx(Text, { ref: ref, span: true, inherit: t
|
|
|
29
30
|
function CrumbTooltip({ label, truncated, children, }) {
|
|
30
31
|
return (_jsx(Tooltip, { label: label, disabled: !truncated, events: { hover: true, focus: true, touch: false }, children: children }));
|
|
31
32
|
}
|
|
32
|
-
function CollectionCrumb({ label, onClick
|
|
33
|
+
function CollectionCrumb({ label, onClick }) {
|
|
33
34
|
const [ref, truncated] = useTruncated(label);
|
|
34
|
-
return (_jsx(CrumbTooltip, { label: label, truncated: truncated, children: _jsx(Button, {
|
|
35
|
+
return (_jsx(CrumbTooltip, { label: label, truncated: truncated, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", fw: 400, miw: 0, maw: 180, onClick: onClick, "data-testid": "breadcrumb-segment", styles: { label: { minWidth: 0 } }, children: crumbText(label, ref) }) }));
|
|
35
36
|
}
|
|
36
37
|
const separator = (_jsx(IconChevronRight, { size: 14, "aria-hidden": "true", style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
37
38
|
/**
|
|
38
|
-
* Where the open entry sits:
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* Where the open entry sits: a collection crumb reveals that collection in the content panel, and
|
|
40
|
+
* the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
|
|
41
|
+
* entry's own does.
|
|
41
42
|
*/
|
|
42
|
-
export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason,
|
|
43
|
+
export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
|
|
43
44
|
const trigger = useRef(null);
|
|
44
45
|
const triggerRef = useMergedRef(trigger, entryMenuRef);
|
|
45
46
|
const menuFocus = useMenuItemFocusReturn(trigger);
|
|
@@ -48,9 +49,5 @@ export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReas
|
|
|
48
49
|
// Clips crumbs that overflow, padded by the focus ring's reach so the ring is not clipped.
|
|
49
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,
|
|
50
51
|
// Collections give up their width long before the entry's own crumb does.
|
|
51
|
-
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,
|
|
52
|
-
borderRadius: '50%',
|
|
53
|
-
flexShrink: 0,
|
|
54
|
-
background: 'var(--mantine-color-text)',
|
|
55
|
-
}, "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, {}), _jsx(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: "Rename\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 ? (_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] })] }) }))] }));
|
|
56
53
|
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
|
|
2
2
|
export declare const QUICK_OPEN_REPLACES_ATTR = "data-quick-open-replaces";
|
|
3
|
-
/**
|
|
3
|
+
/** One option: an entry, with the type and route its second line shows. */
|
|
4
4
|
export interface QuickOpenEntry {
|
|
5
5
|
path: string;
|
|
6
6
|
label: string;
|
|
7
|
+
/** The entry's title, matched by the search where the label is a singleton's type label. */
|
|
8
|
+
title?: string;
|
|
7
9
|
slug?: string;
|
|
8
10
|
collectionPath?: string;
|
|
9
11
|
collectionName?: string;
|
|
12
|
+
/** The entry's type (`entryTypeLine`); absent for a singleton, whose label already names it. */
|
|
13
|
+
typeLabel?: string;
|
|
14
|
+
/** The entry's route on the site (`buildPreviewRoute`); absent when it has no page. */
|
|
15
|
+
route?: string;
|
|
10
16
|
}
|
|
11
17
|
export interface QuickOpenPaletteProps {
|
|
12
18
|
opened: boolean;
|
|
@@ -19,6 +19,8 @@ export const QUICK_OPEN_REPLACES_ATTR = 'data-quick-open-replaces';
|
|
|
19
19
|
const otherOverlayOpen = () => Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"]')).some((dialog) => !dialog.closest(`[${QUICK_OPEN_REPLACES_ATTR}]`));
|
|
20
20
|
/** Options rendered at once; the rest are reached by typing more. */
|
|
21
21
|
const MAX_OPTIONS = 50;
|
|
22
|
+
/** An option's second line: its type and its route, whichever it has. */
|
|
23
|
+
const detailLine = (entry) => [entry.typeLabel, entry.route].filter(Boolean).join(' · ');
|
|
22
24
|
export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }) {
|
|
23
25
|
// `[]` ignores no tags and `true` fires from contenteditable, so the shortcut works from inside
|
|
24
26
|
// a text field or the MDX editor.
|
|
@@ -62,5 +64,8 @@ export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionL
|
|
|
62
64
|
onSelect(path);
|
|
63
65
|
onClose();
|
|
64
66
|
};
|
|
65
|
-
return (_jsx(Modal, { opened: opened, onClose: onClose, title: EDITOR_ACTIONS.openEntry, size: "lg", yOffset: "10vh", returnFocus: !returnFocusTo, onExitTransitionEnd: () => returnFocusTo && focusIfIdle(returnFocusTo()), [QUICK_OPEN_REPLACES_ATTR]: true, [FOCUS_RETURN_ATTR]: true, children: _jsxs(Combobox, { store: combobox, onOptionSubmit: handleSubmit, children: [_jsx(Combobox.EventsTarget, { withExpandedAttribute: true, "data-mantine-stop-propagation": false, children: _jsx(TextInput, { role: "combobox", "aria-label": "Search entries", "aria-autocomplete": "list", placeholder: "Type an entry title or slug", leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), value: query, onChange: (event) => setQuery(event.currentTarget.value), "data-autofocus": true, "data-testid": "quick-open-input" }) }), _jsx(Combobox.Options, { mt: "sm", "aria-label": "Entries", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "scroll", scrollbars: "y", children: matches.length === 0 ? (_jsxs(Text, { size: "sm", c: "dimmed", p: "xs", "data-testid": "quick-open-empty", children: ["No entries match \u201C", query, "\u201D."] })) : (groups.map((group, index) => (_jsx(Combobox.Group, { label: group.heading || undefined, children: group.entries.map((entry) =>
|
|
67
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: EDITOR_ACTIONS.openEntry, size: "lg", yOffset: "10vh", returnFocus: !returnFocusTo, onExitTransitionEnd: () => returnFocusTo && focusIfIdle(returnFocusTo()), [QUICK_OPEN_REPLACES_ATTR]: true, [FOCUS_RETURN_ATTR]: true, children: _jsxs(Combobox, { store: combobox, onOptionSubmit: handleSubmit, children: [_jsx(Combobox.EventsTarget, { withExpandedAttribute: true, "data-mantine-stop-propagation": false, children: _jsx(TextInput, { role: "combobox", "aria-label": "Search entries", "aria-autocomplete": "list", placeholder: "Type an entry title or slug", leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), value: query, onChange: (event) => setQuery(event.currentTarget.value), "data-autofocus": true, "data-testid": "quick-open-input" }) }), _jsx(Combobox.Options, { mt: "sm", "aria-label": "Entries", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "scroll", scrollbars: "y", children: matches.length === 0 ? (_jsxs(Text, { size: "sm", c: "dimmed", p: "xs", "data-testid": "quick-open-empty", children: ["No entries match \u201C", query, "\u201D."] })) : (groups.map((group, index) => (_jsx(Combobox.Group, { label: group.heading || undefined, children: group.entries.map((entry) => {
|
|
68
|
+
const detail = detailLine(entry);
|
|
69
|
+
return (_jsxs(Combobox.Option, { value: entry.path, "data-testid": "quick-open-option", children: [_jsx(Highlight, { highlight: query.trim(), size: "sm", children: entry.label }), detail && (_jsx(Text, { size: "xs", "data-testid": "quick-open-option-detail", children: detail }))] }, entry.path));
|
|
70
|
+
}) }, index)))) }) }), matches.length > MAX_OPTIONS && (_jsxs(Text, { size: "xs", c: "dimmed", mt: "xs", children: ["Showing ", MAX_OPTIONS, " of ", matches.length, ". Keep typing to narrow the list."] }))] }) }));
|
|
66
71
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import type { EntryNameSource } from '../editor-utils.js';
|
|
1
2
|
export interface RenameEntryModalProps {
|
|
2
3
|
/** Whether the modal is open */
|
|
3
4
|
isOpen: boolean;
|
|
4
5
|
/** Current entry label for display */
|
|
5
6
|
entryLabel: string;
|
|
7
|
+
/** Where the entry's name comes from (`entryNameSource`); a title field when absent. */
|
|
8
|
+
nameSource?: EntryNameSource;
|
|
9
|
+
/** The entry has a page on the site; without one, the dialog changes only its slug. */
|
|
10
|
+
hasPage?: boolean;
|
|
6
11
|
/** Current slug value */
|
|
7
12
|
currentSlug: string;
|
|
8
13
|
/** Called when save is clicked */
|
|
@@ -22,4 +27,4 @@ export interface RenameEntryModalProps {
|
|
|
22
27
|
/** Where focus goes once it closes; Mantine's default, the element focused at open, without it. */
|
|
23
28
|
returnFocusTo?: () => HTMLElement | null;
|
|
24
29
|
}
|
|
25
|
-
export declare function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, returnFocusTo, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
|
|
30
|
+
export declare function RenameEntryModal({ isOpen, entryLabel, nameSource, hasPage, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, returnFocusTo, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* "Change URL", or "Change slug" for an entry with no page: changes an entry's slug, the middle
|
|
5
|
+
* segment of its file name, which sets its URL. Whether its name changes with it depends on where
|
|
6
|
+
* the name comes from (`entryNameSource`). The content ID is preserved, so drafts and references
|
|
7
|
+
* remain intact.
|
|
6
8
|
*/
|
|
7
9
|
import { useState } from 'react';
|
|
8
10
|
import { Modal, Stack, TextInput, Group, Button, Alert, Text } from '@mantine/core';
|
|
9
11
|
import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
|
|
10
12
|
import { validateEntrySlug } from '../slug.js';
|
|
11
13
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
12
|
-
|
|
14
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
15
|
+
/** What the dialog says it changes, and whether the entry's name changes with it. */
|
|
16
|
+
const nameHelp = (entryLabel, source, hasPage) => {
|
|
17
|
+
const changes = `This changes the ${hasPage ? 'URL' : 'slug'} of “${entryLabel}”`;
|
|
18
|
+
if (source?.from === 'type')
|
|
19
|
+
return `${changes}, not its name, which is its type's.`;
|
|
20
|
+
if (source?.from === 'slug') {
|
|
21
|
+
return `${changes}, and its name with it: with no title, its name follows the ${hasPage ? 'URL' : 'slug'}.`;
|
|
22
|
+
}
|
|
23
|
+
const field = source?.fieldLabel ? `“${source.fieldLabel}” field` : 'title field';
|
|
24
|
+
return `${changes}, not its name, which comes from its ${field}. Edit that field in the form to change the name.`;
|
|
25
|
+
};
|
|
26
|
+
export function RenameEntryModal({ isOpen, entryLabel, nameSource, hasPage = true, currentSlug, onSave, onClose, isSaving = false, error = null, warning = null, existingSlugs, previewPath, returnFocusTo, }) {
|
|
13
27
|
const [newSlug, setNewSlug] = useState(currentSlug);
|
|
14
28
|
const [touched, setTouched] = useState(false);
|
|
15
29
|
// Reset state when modal opens (adjust state during render instead of effect)
|
|
@@ -38,8 +52,9 @@ export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onCl
|
|
|
38
52
|
await onSave(newSlug);
|
|
39
53
|
};
|
|
40
54
|
const slugPath = previewPath?.(newSlug || 'your-slug');
|
|
41
|
-
|
|
55
|
+
const action = hasPage ? EDITOR_ACTIONS.changeUrl : EDITOR_ACTIONS.changeSlug;
|
|
56
|
+
return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: action }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, ...focusReturnProps(returnFocusTo), children: _jsx("form", { onSubmit: handleSubmit, noValidate: true, children: _jsxs(Stack, { gap: "md", "data-testid": "rename-entry-modal", children: [warning ? (_jsx(Alert, { icon: _jsx(IconAlertTriangle, { size: 16 }), color: "yellow", variant: "light", children: warning })) : (error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error }))), _jsx(Text, { size: "sm", c: "dimmed", "data-testid": "change-url-help", children: nameHelp(entryLabel, nameSource, hasPage) }), _jsx(TextInput, { label: "Slug", description: slugPath ? `URL path: ${slugPath}` : 'Lowercase letters, numbers and hyphens', value: newSlug, onChange: (e) => {
|
|
42
57
|
setNewSlug(e.currentTarget.value);
|
|
43
58
|
setTouched(true);
|
|
44
|
-
}, error: touched ? validationError : null, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { size: "sm", type: "submit", loading: isSaving, disabled: newSlug === currentSlug, "data-testid": "rename-entry-submit", children:
|
|
59
|
+
}, error: touched ? validationError : null, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { size: "sm", type: "submit", loading: isSaving, disabled: newSlug === currentSlug, "data-testid": "rename-entry-submit", children: action })] })] }) }) }));
|
|
45
60
|
}
|
|
@@ -24,7 +24,8 @@ export interface SaveSplitButtonProps {
|
|
|
24
24
|
draftStorageFailed: boolean;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Save with a caret offering "Save all". While the open entry has unsaved changes, Save's Tooltip
|
|
28
|
+
* and description say they are kept on this device. Only when keeping them failed does the bar
|
|
29
|
+
* carry a warning before Save, icon-only below `lg`, its text then its Tooltip and accessible name.
|
|
29
30
|
*/
|
|
30
31
|
export declare function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving, savedSignal, savedHere, entryDirty, draftStorageFailed, }: SaveSplitButtonProps): import("react/jsx-runtime").JSX.Element;
|