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.
Files changed (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. 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 { statusLocked, branchDataUnavailable } = branchLockState(lock);
21
- if (!lock.branchReadOnly && !statusLocked)
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 ("Editing", "In review", "Protected"), when the mode shows one. */
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. Its menu holds the branch actions and lists the
29
- * unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
30
- * name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
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. Its menu holds the branch actions and lists the
14
- * unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
15
- * name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
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, style: {
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, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Drawer, Text, Title } from '@mantine/core';
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(Drawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, ...focusReturnProps(returnFocusTo), children: children }));
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 rename dialog. */
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, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, 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;
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 { branchStatusPresentation } from '../branch-status.js';
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, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, 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 { statusLocked, branchDataUnavailable } = branchLockState({
19
- branchStatus,
20
- branchReadOnly,
21
- branchWriteBlocked,
22
- });
23
- // Why the branch takes no writes, with the way forward; shared by Save, Save all and the chip.
24
- const branchLockReason = 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;
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 = branchStatusPresentation(branchStatus, branchIsProtected);
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, readOnlyReason: branchLoading
56
- ? undefined
57
- : currentEntry || branchName
58
- ? (branchLockReason ?? entryLockReason)
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, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
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, allContentRef: allContentRef }), 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
+ : 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({ siteTitle, onHomeOpen, contentPanelOpen, contentPanelId, onContentToggle, 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;
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, Text, Tooltip, VisuallyHidden } from '@mantine/core';
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({ siteTitle, onHomeOpen, contentPanelOpen, contentPanelId, onContentToggle, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
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: "home", label: `Open the first entry of ${siteTitle}`, icon: _jsx(Text, { component: "span", fw: 700, size: "md", "aria-hidden": true, children: monogram(siteTitle) }), tinted: true, "data-testid": "rail-home-button", onClick: onHomeOpen }), _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", 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 }));
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 or its branch is read-only; set, the "Read-only" chip shows. */
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 "Rename…" and "Delete entry"; absent where a path rule refuses them. */
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 renamed or deleted now. */
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: "All content" opens the content panel, a collection crumb reveals
30
- * that collection in it, and the entry's own crumb is current and opens the entry menu. Crumbs
31
- * shrink and truncate before the entry's own does.
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, onAllContent, onCollection, onRefresh, onDiscard, onRename, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, allContentRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
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, buttonRef, }) {
33
+ function CollectionCrumb({ label, onClick }) {
33
34
  const [ref, truncated] = useTruncated(label);
34
- return (_jsx(CrumbTooltip, { label: label, truncated: truncated, children: _jsx(Button, { ref: buttonRef, 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
+ 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: "All content" opens the content panel, a collection crumb reveals
39
- * that collection in it, and the entry's own crumb is current and opens the entry menu. Crumbs
40
- * shrink and truncate before the entry's own does.
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, onAllContent, onCollection, onRefresh, onDiscard, onRename, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, allContentRef, }) {
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, buttonRef: index === 0 ? allContentRef : undefined, onClick: () => segment.collectionPath ? onCollection(segment.collectionPath) : onAllContent() }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [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(Box, { component: "span", "aria-hidden": "true", w: 8, h: 8, style: {
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
- /** @internal Exported for tests and stories. */
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) => (_jsxs(Combobox.Option, { value: entry.path, "data-testid": "quick-open-option", children: [_jsx(Highlight, { highlight: query.trim(), size: "sm", children: entry.label }), entry.slug && _jsx(Text, { size: "xs", children: entry.slug })] }, entry.path))) }, 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."] }))] }) }));
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
- * Allows changing the slug (middle segment) of an entry filename.
5
- * The content ID is preserved, so drafts and references remain intact.
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
- export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving = false, error = null, warning = null, existingSlugs, previewPath, returnFocusTo, }) {
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
- return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Rename entry" }), 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 }))), _jsxs(Text, { size: "sm", c: "dimmed", children: ["Renaming \u201C", entryLabel, "\u201D"] }), _jsx(TextInput, { label: "Slug", description: slugPath ? `URL path: ${slugPath}` : 'Lowercase letters, numbers and hyphens', value: newSlug, onChange: (e) => {
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: "Rename" })] })] }) }) }));
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
- * The unsaved indicator, then Save with a caret offering "Save all". The indicator is icon-only
28
- * below `lg`, its text then its Tooltip and accessible name.
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;