canopycms 0.1.0 → 0.2.0-int.121
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 +202 -43
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +118 -47
- 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/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +29 -10
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +25 -6
- package/dist/assets/store-s3.js +81 -19
- package/dist/assets/types.d.ts +33 -4
- package/dist/cli/cli.js +151 -56
- package/dist/config/types.d.ts +3 -2
- 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 +93 -39
- package/dist/editor/EntryNavigator.d.ts +17 -5
- package/dist/editor/EntryNavigator.js +66 -31
- 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 +13 -2
- package/dist/editor/PreviewFrame.js +62 -5
- package/dist/editor/PreviewPane.d.ts +17 -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 +8 -7
- package/dist/editor/components/BranchLockBanner.js +30 -3
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +14 -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/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- 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/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- 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/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- 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 +24 -9
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +18 -9
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- 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/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- 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 +23 -0
- package/dist/editor/preview-bridge.js +105 -4
- package/dist/editor/preview-path.d.ts +16 -4
- package/dist/editor/preview-path.js +80 -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.d.ts +1 -0
- package/dist/editor/theme.js +19 -1
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -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 {
|
|
9
|
+
import { describeBranchLock } 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,9 @@ 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
|
-
|
|
19
|
-
|
|
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
|
+
// Shared by Save and Save all; set exactly when BranchLockBanner renders.
|
|
19
|
+
const branchLockReason = describeBranchLock({ branchStatus, branchReadOnly, branchWriteBlocked });
|
|
35
20
|
const entryLockReason = entryReadOnly
|
|
36
21
|
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
37
22
|
: undefined;
|
|
@@ -42,7 +27,7 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
|
|
|
42
27
|
: !currentEntry
|
|
43
28
|
? 'Open an entry to save it.'
|
|
44
29
|
: (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
|
|
45
|
-
const presentation =
|
|
30
|
+
const presentation = branchBarStatusPresentation(branchStatus, branchIsProtected);
|
|
46
31
|
const status = !branchLoading &&
|
|
47
32
|
clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
|
|
48
33
|
presentation
|
|
@@ -52,16 +37,17 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
|
|
|
52
37
|
testId: `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`,
|
|
53
38
|
}
|
|
54
39
|
: 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,
|
|
40
|
+
return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges,
|
|
41
|
+
// BranchLockBanner already says why a locked branch is read-only, so the chip carries
|
|
42
|
+
// only the entry's own reason, even beside the banner: "Withdraw it" would not unlock it.
|
|
43
|
+
readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
|
|
60
44
|
// A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
|
|
61
|
-
onRename: entryReadOnly ? undefined : onEntryRename,
|
|
45
|
+
onRename: entryReadOnly ? undefined : onEntryRename,
|
|
46
|
+
// An entry with a page has a preview `src`; one without changes only its slug.
|
|
47
|
+
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
48
|
(entryUnavailable
|
|
63
49
|
? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
|
|
64
|
-
: undefined), entryMenuRef: navigatorToggleRef
|
|
50
|
+
: 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
51
|
(!branchName
|
|
66
52
|
? 'Open a branch first.'
|
|
67
53
|
: 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
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
|
|
3
|
+
* the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
|
|
4
|
+
*/
|
|
5
|
+
export declare const showPreviewLinkOutcome: (href: string, path: string, opened: boolean) => void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Anchor } from '@mantine/core';
|
|
4
|
+
import { notifications } from '@mantine/notifications';
|
|
5
|
+
/**
|
|
6
|
+
* Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
|
|
7
|
+
* the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
|
|
8
|
+
*/
|
|
9
|
+
export const showPreviewLinkOutcome = (href, path, opened) => {
|
|
10
|
+
if (!opened) {
|
|
11
|
+
notifications.show({
|
|
12
|
+
color: 'yellow',
|
|
13
|
+
autoClose: false,
|
|
14
|
+
message: (_jsxs(_Fragment, { children: [path, " has no entry here, and your browser blocked the new tab.", ' ', _jsxs(Anchor, { href: href, target: "_blank", rel: "noopener noreferrer", children: ["Open ", path] })] })),
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const show = () => notifications.show({
|
|
19
|
+
color: 'blue',
|
|
20
|
+
message: `${path} has no entry here, so it opened in a new tab.`,
|
|
21
|
+
});
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
if (document.visibilityState === 'visible')
|
|
24
|
+
return show();
|
|
25
|
+
const onVisible = () => {
|
|
26
|
+
if (document.visibilityState !== 'visible')
|
|
27
|
+
return;
|
|
28
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
29
|
+
show();
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
32
|
+
}, 300);
|
|
33
|
+
};
|
|
@@ -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;
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
4
|
import { Box, Button, Loader, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
5
|
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
-
import { IconAlertTriangle, IconCheck, IconChevronDown,
|
|
6
|
+
import { IconAlertTriangle, IconCheck, IconChevronDown, IconLoader2 } from '@tabler/icons-react';
|
|
7
7
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
8
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
9
9
|
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
10
|
+
const STORAGE_FAILED_TEXT = 'Unsaved · not kept on this device';
|
|
10
11
|
const STORAGE_FAILED_HINT = "Save before you leave or reload, or they'll be lost.";
|
|
12
|
+
const KEPT_HINT = 'Unsaved changes are kept on this device until you save.';
|
|
13
|
+
// Visually hidden, as Mantine's VisuallyHidden, for a region that must stay mounted.
|
|
14
|
+
const HIDDEN_STYLE = {
|
|
15
|
+
position: 'absolute',
|
|
16
|
+
width: 1,
|
|
17
|
+
height: 1,
|
|
18
|
+
overflow: 'hidden',
|
|
19
|
+
clip: 'rect(0 0 0 0)',
|
|
20
|
+
whiteSpace: 'nowrap',
|
|
21
|
+
};
|
|
11
22
|
const SAVED_SHOWN_MS = 2000;
|
|
12
23
|
// Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
|
|
13
24
|
const LABEL_TEXT_CLASS = 'canopy-save-label-text';
|
|
@@ -40,11 +51,13 @@ function SaveLabels({ shown, spinner }) {
|
|
|
40
51
|
}, children: labels[key] }, key))) }));
|
|
41
52
|
}
|
|
42
53
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
54
|
+
* Save with a caret offering "Save all". While the open entry has unsaved changes, Save's Tooltip
|
|
55
|
+
* and description say they are kept on this device. Only when keeping them failed does the bar
|
|
56
|
+
* carry a warning before Save, icon-only below `lg`, its text then its Tooltip and accessible name.
|
|
45
57
|
*/
|
|
46
58
|
export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
|
|
47
59
|
const caret = useRef(null);
|
|
60
|
+
const keptHintId = useId();
|
|
48
61
|
const menuFocus = useMenuItemFocusReturn(caret);
|
|
49
62
|
const reducedMotion = useReducedMotion();
|
|
50
63
|
// Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
|
|
@@ -80,20 +93,20 @@ export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, un
|
|
|
80
93
|
? 'saved'
|
|
81
94
|
: 'save';
|
|
82
95
|
const saveGreyed = saveBlocked && !saveAllProgress && label === 'save';
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
return (_jsxs(_Fragment, { children: [_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: { flexShrink: 0 }, children:
|
|
96
|
+
const storageWarning = entryDirty && draftStorageFailed;
|
|
97
|
+
// Why Save is blocked, else where unsaved changes are held; a blocked Save's reason leads.
|
|
98
|
+
const keptHint = entryDirty && !draftStorageFailed && !saveBlockedReason ? KEPT_HINT : '';
|
|
99
|
+
const saveTooltip = saveBlockedReason || keptHint;
|
|
100
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: storageWarning ? { flexShrink: 0 } : HIDDEN_STYLE, children: storageWarning && (_jsx(Tooltip, { label: `${STORAGE_FAILED_TEXT}. ${STORAGE_FAILED_HINT}`, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: "var(--canopy-danger-text)", "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: _jsx(IconAlertTriangle, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] })] }) })) }), _jsx(VisuallyHidden, { role: "status", children: saveAllProgress
|
|
88
101
|
? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
|
|
89
102
|
: label === 'saved'
|
|
90
103
|
? 'Saved'
|
|
91
|
-
: '' }), _jsx("style", { children: LABEL_STYLES }), _jsxs(Button.Group, { style: { flexShrink: 0 }, children: [_jsx(Tooltip, { label:
|
|
104
|
+
: '' }), keptHint && (_jsx("span", { hidden: true, id: keptHintId, children: keptHint })), _jsx("style", { children: LABEL_STYLES }), _jsxs(Button.Group, { style: { flexShrink: 0 }, children: [_jsx(Tooltip, { label: saveTooltip,
|
|
92
105
|
// Not beside "Saving…" or "Saved", which already say what happened.
|
|
93
|
-
disabled: !
|
|
106
|
+
disabled: !saveTooltip || busy || label !== 'save', multiline: true, maw: 280, children: _jsx(Button, { "data-testid": "save-button", variant: "filled", size: "sm", leftSection: saveAllProgress && !reducedMotion ? (_jsx(Loader, { size: 14, color: "currentColor" })) : undefined, onClick: () => {
|
|
94
107
|
if (!saveBlocked)
|
|
95
108
|
onSave();
|
|
96
|
-
}, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, children: saveAllProgress ? (`Saving ${Math.min(saveAllProgress.done + 1, saveAllProgress.total)} of ${saveAllProgress.total}…`) : (_jsx(SaveLabels, { shown: label, spinner: !reducedMotion })) }) }), _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Save options", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caret,
|
|
109
|
+
}, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, ...(keptHint ? { 'aria-describedby': keptHintId } : {}), children: saveAllProgress ? (`Saving ${Math.min(saveAllProgress.done + 1, saveAllProgress.total)} of ${saveAllProgress.total}…`) : (_jsx(SaveLabels, { shown: label, spinner: !reducedMotion })) }) }), _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Save options", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caret,
|
|
97
110
|
// Light beside a greyed Save, so a blocked split button shows no filled half.
|
|
98
111
|
variant: saveGreyed ? 'light' : 'filled', size: "sm", px: 8, "aria-label": "Save options", "data-testid": "save-options-button", style: { borderInlineStart: '1px solid var(--mantine-color-body)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: _jsx(ReasonedMenuItem, { "data-testid": "save-all-menu-item", onClick: onSaveAll, blockedReason: saveAllBlockedReason ??
|
|
99
112
|
(saveAllProgress
|
|
@@ -4,7 +4,6 @@ export interface StagedChangesDrawerProps {
|
|
|
4
4
|
/** Closes the drawer. Called directly when clean, only after a confirmed discard when dirty, and never while saving. */
|
|
5
5
|
onClose: () => void;
|
|
6
6
|
title: string;
|
|
7
|
-
description: string;
|
|
8
7
|
size: number;
|
|
9
8
|
isDirty: boolean;
|
|
10
9
|
isSaving: boolean;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useReducer, useRef } from 'react';
|
|
4
|
-
import { Badge, Button,
|
|
4
|
+
import { Badge, Button, Fieldset, Group, Text } from '@mantine/core';
|
|
5
5
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
6
6
|
import { focusReturnProps, rememberOpener, returnTarget } from '../utils/focus-return.js';
|
|
7
|
+
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
7
8
|
/**
|
|
8
9
|
* A key that changes each time the drawer finishes closing. A panel keyed with it, and passing
|
|
9
10
|
* `onExited` through, gets fresh state for each opening, as the drawer's own children do. A
|
|
@@ -33,7 +34,7 @@ function useBeforeUnloadWhileDirty(isDirty) {
|
|
|
33
34
|
* them from being lost unnoticed: a title badge and a sticky save bar while dirty, a
|
|
34
35
|
* discard confirm on close and on the save bar's Discard changes, and a `beforeunload` prompt.
|
|
35
36
|
*/
|
|
36
|
-
export const StagedChangesDrawer = ({ opened, onClose, title,
|
|
37
|
+
export const StagedChangesDrawer = ({ opened, onClose, title, size, isDirty, isSaving, canEdit, saveLabel, onSave, onDiscard, childModalOpen = false, onExited, returnFocusTo, children, }) => {
|
|
37
38
|
// A ref, not state: @mantine/modals runs the confirm's onClose inside its reducer, during
|
|
38
39
|
// render, where setting another component's state draws a React warning. While the confirm is open the
|
|
39
40
|
// drawer still hears Escape, so requestClose must ignore it.
|
|
@@ -76,10 +77,10 @@ export const StagedChangesDrawer = ({ opened, onClose, title, description, size,
|
|
|
76
77
|
confirmDiscard('Discarding drops the changes you have not saved.', onDiscard, focusReturnProps(() => returnTarget(opener, () => titleRef.current)));
|
|
77
78
|
}, [isSaving, onDiscard, confirmDiscard]);
|
|
78
79
|
const { onExitTransitionEnd: returnFocus, ...focusReturn } = focusReturnProps(returnFocusTo);
|
|
79
|
-
return (_jsxs(
|
|
80
|
+
return (_jsxs(OverlayDrawer, { opened: opened, onClose: requestClose, closeOnEscape: !childModalOpen, ...focusReturn, onExitTransitionEnd: () => {
|
|
80
81
|
returnFocus?.();
|
|
81
82
|
onExited?.();
|
|
82
|
-
}, position: "right", title:
|
|
83
|
+
}, position: "right", title: title, titleRef: titleRef, titleTabIndex: -1, adornment: isDirty && (_jsx(Badge, { color: "orange", variant: "light", children: "Unsaved changes" })), size: size, children: [_jsx(Fieldset, { variant: "unstyled", disabled: isSaving, "aria-busy": isSaving, children: children }), canEdit && isDirty && (
|
|
83
84
|
// The drawer's content box is the scroll container, so a sticky bar stays in view
|
|
84
85
|
// however long the panel's list grows.
|
|
85
86
|
_jsxs(Group, { justify: "space-between", py: "sm", gap: "sm", style: {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
export interface UndoToastMessageProps {
|
|
2
|
-
/** Names what was removed: `"Branch-aware" from Features`, `item 2 from Features`, `
|
|
2
|
+
/** Names what was removed: `"Branch-aware" from Features`, `item 2 from Features`, `the image from Hero image`. */
|
|
3
3
|
label: string;
|
|
4
|
-
/** `clear` for an optional object's Clear. */
|
|
5
|
-
action?: 'remove' | 'clear';
|
|
6
4
|
/** A sentence after the message, such as where the item's comments went. */
|
|
7
5
|
note?: string;
|
|
8
6
|
onUndo: () => void;
|
|
@@ -14,5 +12,8 @@ export interface UndoToastMessageProps {
|
|
|
14
12
|
/** Called when focus leaves the toast, so its timer can run again. */
|
|
15
13
|
onRelease?: () => void;
|
|
16
14
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The body of the toast a form Remove raises in place of a confirmation. An object's Clear offers
|
|
17
|
+
* its Undo in the form instead (`ObjectField`), since the object stays where it was.
|
|
18
|
+
*/
|
|
19
|
+
export declare function UndoToastMessage({ label, note, onUndo, onHold, onRelease, }: UndoToastMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Group, Text } from '@mantine/core';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The body of the toast a form Remove raises in place of a confirmation. An object's Clear offers
|
|
5
|
+
* its Undo in the form instead (`ObjectField`), since the object stays where it was.
|
|
6
|
+
*/
|
|
7
|
+
export function UndoToastMessage({ label, note, onUndo, onHold, onRelease, }) {
|
|
6
8
|
return (_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", onFocus: onHold, onBlur: (event) => {
|
|
7
9
|
if (!event.currentTarget.contains(event.relatedTarget))
|
|
8
10
|
onRelease?.();
|
|
9
|
-
}, children: [_jsx(Text, { size: "sm", miw: 0, children: note ?
|
|
11
|
+
}, children: [_jsx(Text, { size: "sm", miw: 0, children: note ? `Removed ${label}. ${note}` : `Removed ${label}` }), _jsx(Button, { size: "compact-sm", variant: "subtle", style: { flexShrink: 0 }, onClick: onUndo, "aria-label": `Undo removing ${label}`, children: "Undo" })] }));
|
|
10
12
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ElementProps } from '@mantine/core';
|
|
3
|
+
/**
|
|
4
|
+
* The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
|
|
5
|
+
* `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
|
|
6
|
+
* The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
|
|
7
|
+
* It forwards its ref, so a Tooltip can wrap it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const UnsavedMark: React.ForwardRefExoticComponent<Omit<ElementProps<"span">, "color" | "style"> & React.RefAttributes<HTMLSpanElement>>;
|