canopycms 0.1.0-int.115 → 0.1.0
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 +267 -231
- package/README.md +0 -1
- package/dist/ai/references.js +3 -1
- package/dist/api/assets.js +2 -2
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch.js +1 -1
- package/dist/api/content.js +1 -1
- package/dist/api/entries-constants.d.ts +1 -1
- package/dist/api/entries-constants.js +1 -1
- package/dist/api/entries.d.ts +2 -2
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/cli/cli.js +16 -14
- package/dist/cli/generate-ai-content.js +7 -5
- package/dist/cli/init.js +4 -4
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/config/schemas/config.d.ts +0 -5
- package/dist/config/schemas/config.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.js +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-store.d.ts +1 -1
- package/dist/content-store.js +1 -1
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +5 -4
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.js +3 -6
- package/dist/editor/Editor.d.ts +0 -2
- package/dist/editor/Editor.js +329 -181
- package/dist/editor/EditorAuthGate.js +8 -3
- package/dist/editor/EntryNavigator.d.ts +10 -0
- package/dist/editor/EntryNavigator.js +51 -2
- package/dist/editor/FormHeader.js +1 -1
- package/dist/editor/PreviewFrame.d.ts +5 -1
- package/dist/editor/PreviewFrame.js +3 -2
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/comments/form-comments.js +2 -1
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/EditorHeader.d.ts +50 -108
- package/dist/editor/components/EditorHeader.js +117 -138
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
- package/dist/editor/components/QuickOpenPalette.js +3 -2
- package/dist/editor/components/RenameEntryModal.d.ts +3 -1
- package/dist/editor/components/RenameEntryModal.js +3 -2
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/editor-utils.d.ts +11 -6
- package/dist/editor/editor-utils.js +8 -17
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
- package/dist/editor/hooks/useCommentSystem.js +3 -0
- package/dist/editor/hooks/useDraftManager.d.ts +24 -1
- package/dist/editor/hooks/useDraftManager.js +297 -16
- package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
- package/dist/editor/hooks/useEditorLayout.js +13 -25
- package/dist/editor/hooks/useEntryManager.d.ts +4 -0
- package/dist/editor/hooks/useEntryManager.js +30 -7
- package/dist/editor/media/MediaLibrary.d.ts +1 -1
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/theme.js +18 -3
- package/dist/git-manager.d.ts +1 -1
- package/dist/git-manager.js +2 -2
- package/dist/operating-mode/types.d.ts +1 -1
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/resolver.d.ts +1 -1
- package/dist/schema/resolver.js +1 -1
- package/dist/schema/schema-store.d.ts +1 -1
- package/dist/schema/schema-store.js +2 -2
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.js +1 -1
- package/dist/utils/content-write-lock.d.ts +1 -1
- package/dist/utils/content-write-lock.js +1 -1
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.js +27 -5
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -1
- package/dist/worker/cms-worker.js +2 -2
- package/dist/worker/github-auth.d.ts +1 -1
- package/dist/worker/github-auth.js +1 -1
- package/dist/worker/github-gateway-wire.d.ts +2 -1
- package/dist/worker/github-gateway-wire.js +7 -3
- package/dist/worker/github-gateway.js +1 -1
- package/dist/worker/github-mirror.d.ts +2 -2
- package/dist/worker/github-mirror.js +2 -2
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/self-checks.d.ts +1 -1
- package/dist/worker/shared-repo-git.js +1 -1
- package/package.json +5 -4
- package/dist/editor/EditorPanes.d.ts +0 -20
- package/dist/editor/EditorPanes.js +0 -124
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -70
- package/dist/editor/components/EditorSidebar.js +0 -50
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ReactElement, type Ref } from 'react';
|
|
2
|
+
import type { PaneArrangement } from '../shell/PaneLayout.js';
|
|
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
|
+
contentPanelOpen: boolean;
|
|
8
|
+
/** The content panel's element id, for `aria-controls`. */
|
|
9
|
+
contentPanelId: string;
|
|
10
|
+
onContentToggle: () => void;
|
|
11
|
+
onQuickOpen: () => void;
|
|
12
|
+
/** The quick open shortcut as the platform writes it, such as "⌘P". */
|
|
13
|
+
quickOpenShortcut: string;
|
|
14
|
+
/** The same shortcut for `aria-keyshortcuts`, such as "Meta+P". */
|
|
15
|
+
quickOpenKeys: string;
|
|
16
|
+
branchesOpen: boolean;
|
|
17
|
+
onBranchesOpen: () => void;
|
|
18
|
+
/** Without it, the Comments button is not offered (no branch, or a mode without comments). */
|
|
19
|
+
onCommentsOpen?: () => void;
|
|
20
|
+
commentsOpen: boolean;
|
|
21
|
+
unresolvedCommentCount: number;
|
|
22
|
+
mediaLibraryOpen: boolean;
|
|
23
|
+
onMediaLibraryOpen: () => void;
|
|
24
|
+
mediaButtonRef?: Ref<HTMLButtonElement>;
|
|
25
|
+
layout: PaneArrangement;
|
|
26
|
+
onLayoutChange: (layout: PaneArrangement) => void;
|
|
27
|
+
/** The preview is maximized, so the layout has nothing to arrange. */
|
|
28
|
+
layoutBlocked?: boolean;
|
|
29
|
+
/** Admin-only, like the other two below: each Settings item renders only when given. */
|
|
30
|
+
onPermissionManagerOpen?: () => void;
|
|
31
|
+
onGroupManagerOpen?: () => void;
|
|
32
|
+
onSystemHealthOpen?: () => void;
|
|
33
|
+
/** Takes focus back when a panel opened from the Settings menu closes. */
|
|
34
|
+
settingsToggleRef?: Ref<HTMLButtonElement>;
|
|
35
|
+
/** The adopter's account control, such as Clerk's UserButton; replaces the default buttons. */
|
|
36
|
+
AccountComponent?: React.ComponentType;
|
|
37
|
+
onAccountClick?: () => void;
|
|
38
|
+
onLogoutClick?: () => void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* @internal Exported for tests. The Settings button and its menu of `items`; with none, no
|
|
42
|
+
* button, since a menu that opens empty offers nothing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function SettingsMenu({ items, toggleRef, }: {
|
|
45
|
+
items: readonly ReactElement[];
|
|
46
|
+
toggleRef?: Ref<HTMLButtonElement>;
|
|
47
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
48
|
+
/** 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;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { ActionIcon, Menu, Stack, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useMergedRef } from '@mantine/hooks';
|
|
6
|
+
import { IconActivity, IconExternalLink, IconFiles, IconGitBranch, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSearch, IconSettings, IconUserCircle, } from '@tabler/icons-react';
|
|
7
|
+
import { ActivityRail, RailButton } from '../shell/ActivityRail.js';
|
|
8
|
+
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
9
|
+
import { CANOPYCMS_VERSION } from '../../version.js';
|
|
10
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
11
|
+
const ABOUT_URL = 'https://github.com/safeinsights/canopycms';
|
|
12
|
+
/**
|
|
13
|
+
* @internal Exported for tests. The Settings button and its menu of `items`; with none, no
|
|
14
|
+
* button, since a menu that opens empty offers nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function SettingsMenu({ items, toggleRef, }) {
|
|
17
|
+
const [opened, setOpened] = useState(false);
|
|
18
|
+
const toggle = useRef(null);
|
|
19
|
+
const mergedRef = useMergedRef(toggle, toggleRef);
|
|
20
|
+
const menuFocus = useMenuItemFocusReturn(toggle);
|
|
21
|
+
if (items.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
return (_jsxs(Menu, { shadow: "md", width: 220, position: "right-end", opened: opened, onChange: setOpened, ...menuFocus.menu, children: [_jsx(Menu.Target, { children: _jsx(RailButton, { railId: "settings", label: "Settings", icon: _jsx(IconSettings, { size: 20 }), tooltipDisabled: opened, ref: mergedRef, "data-testid": "settings-button" }) }), _jsx(Menu.Dropdown, { "data-testid": "settings-menu", ...menuFocus.dropdown, children: items })] }));
|
|
24
|
+
}
|
|
25
|
+
const LAYOUTS = {
|
|
26
|
+
side: {
|
|
27
|
+
name: 'side by side',
|
|
28
|
+
title: 'Side by side layout',
|
|
29
|
+
other: 'stacked',
|
|
30
|
+
Icon: IconLayoutColumns,
|
|
31
|
+
},
|
|
32
|
+
stacked: { name: 'stacked', title: 'Stacked layout', other: 'side', Icon: IconLayoutRows },
|
|
33
|
+
};
|
|
34
|
+
/** One button that shows the current layout and switches to the other. */
|
|
35
|
+
function LayoutButton({ layout, onChange, blocked = false, }) {
|
|
36
|
+
const [switchedTo, setSwitchedTo] = useState('');
|
|
37
|
+
const { name, other, Icon } = LAYOUTS[layout];
|
|
38
|
+
return (_jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "layout", label: `Switch to ${LAYOUTS[other].name} layout (now ${name})`, icon: _jsx(Icon, { size: 20 }), disabledReason: blocked ? 'Restore the preview to change the layout.' : undefined, "data-testid": "rail-layout-button", "data-layout": layout, onClick: () => {
|
|
39
|
+
onChange(other);
|
|
40
|
+
setSwitchedTo(LAYOUTS[other].title);
|
|
41
|
+
} }), _jsx(VisuallyHidden, { role: "status", "data-testid": "rail-layout-status", children: switchedTo })] }));
|
|
42
|
+
}
|
|
43
|
+
const monogram = (title) => Array.from(title.trim())[0]?.toUpperCase() ?? '?';
|
|
44
|
+
/** 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, }) {
|
|
46
|
+
const adminItems = [
|
|
47
|
+
onPermissionManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-permissions", onClick: onPermissionManagerOpen, children: "Manage permissions" }, "permissions")),
|
|
48
|
+
onGroupManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-groups", onClick: onGroupManagerOpen, children: "Manage groups" }, "groups")),
|
|
49
|
+
onSystemHealthOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-system-health", leftSection: _jsx(IconActivity, { size: 14 }), onClick: onSystemHealthOpen, children: "System health" }, "system-health")),
|
|
50
|
+
].filter((item) => !!item);
|
|
51
|
+
// Every user gets the version, which bug reports need, so the menu is never empty today.
|
|
52
|
+
const settingsItems = [
|
|
53
|
+
...(adminItems.length > 0
|
|
54
|
+
? [
|
|
55
|
+
_jsx(Menu.Label, { children: "Settings" }, "admin"),
|
|
56
|
+
...adminItems,
|
|
57
|
+
_jsx(Menu.Divider, {}, "divider"),
|
|
58
|
+
]
|
|
59
|
+
: []),
|
|
60
|
+
_jsx(Menu.Label, { "data-testid": "settings-menu-version", children: `CanopyCMS ${CANOPYCMS_VERSION}` }, "version"),
|
|
61
|
+
_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
|
+
];
|
|
63
|
+
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 }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Ref } from 'react';
|
|
2
|
+
import type { BreadcrumbSegment } from '../editor-utils.js';
|
|
3
|
+
export interface EntryBreadcrumbProps {
|
|
4
|
+
/** The crumbs before the entry's own, from `buildBreadcrumbSegments`. */
|
|
5
|
+
segments: BreadcrumbSegment[];
|
|
6
|
+
/** The open entry's label; undefined when no entry is open. */
|
|
7
|
+
entryLabel: string | undefined;
|
|
8
|
+
/** The open entry has unsaved changes. */
|
|
9
|
+
entryDirty: boolean;
|
|
10
|
+
/** Why the open entry or its branch is read-only; set, the "Read-only" chip shows. */
|
|
11
|
+
readOnlyReason?: string;
|
|
12
|
+
onAllContent: () => void;
|
|
13
|
+
onCollection: (collectionPath: string) => void;
|
|
14
|
+
onRefresh: () => void;
|
|
15
|
+
onDiscard: () => void;
|
|
16
|
+
/** With `onDelete`, offers "Rename…" and "Delete entry"; absent where a path rule refuses them. */
|
|
17
|
+
onRename?: () => void;
|
|
18
|
+
onDelete?: () => void;
|
|
19
|
+
refreshBlockedReason?: string;
|
|
20
|
+
discardBlockedReason?: string;
|
|
21
|
+
/** Why the entry can't be renamed or deleted now. */
|
|
22
|
+
changeBlockedReason?: string;
|
|
23
|
+
/** The entry menu's trigger, which stands in for a menu item that opened something. */
|
|
24
|
+
entryMenuRef?: Ref<HTMLButtonElement>;
|
|
25
|
+
/** The "All content" crumb, where focus goes once the open entry is gone. */
|
|
26
|
+
allContentRef?: Ref<HTMLButtonElement>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
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.
|
|
32
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useMergedRef } from '@mantine/hooks';
|
|
6
|
+
import { IconChevronDown, IconChevronRight, IconLock } from '@tabler/icons-react';
|
|
7
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
import { DestructiveMenuItem } from './DestructiveMenuItem.js';
|
|
9
|
+
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
10
|
+
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
11
|
+
/** Whether the element's text is cut short, kept current as it resizes. */
|
|
12
|
+
function useTruncated(label) {
|
|
13
|
+
const ref = useRef(null);
|
|
14
|
+
const [truncated, setTruncated] = useState(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const el = ref.current;
|
|
17
|
+
if (!el)
|
|
18
|
+
return;
|
|
19
|
+
const check = () => setTruncated(el.scrollWidth > el.clientWidth);
|
|
20
|
+
check();
|
|
21
|
+
const observer = new ResizeObserver(check);
|
|
22
|
+
observer.observe(el);
|
|
23
|
+
return () => observer.disconnect();
|
|
24
|
+
}, [label]);
|
|
25
|
+
return [ref, truncated];
|
|
26
|
+
}
|
|
27
|
+
const crumbText = (label, ref) => (_jsx(Text, { ref: ref, span: true, inherit: true, truncate: "end", display: "block", children: label }));
|
|
28
|
+
/** A cut-short crumb shows its full label in a Tooltip, on hover and on focus. */
|
|
29
|
+
function CrumbTooltip({ label, truncated, children, }) {
|
|
30
|
+
return (_jsx(Tooltip, { label: label, disabled: !truncated, events: { hover: true, focus: true, touch: false }, children: children }));
|
|
31
|
+
}
|
|
32
|
+
function CollectionCrumb({ label, onClick, buttonRef, }) {
|
|
33
|
+
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
|
+
}
|
|
36
|
+
const separator = (_jsx(IconChevronRight, { size: 14, "aria-hidden": "true", style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
37
|
+
/**
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onAllContent, onCollection, onRefresh, onDiscard, onRename, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, allContentRef, }) {
|
|
43
|
+
const trigger = useRef(null);
|
|
44
|
+
const triggerRef = useMergedRef(trigger, entryMenuRef);
|
|
45
|
+
const menuFocus = useMenuItemFocusReturn(trigger);
|
|
46
|
+
const [entryLabelRef, entryTruncated] = useTruncated(entryLabel ?? '');
|
|
47
|
+
return (_jsxs(Group, { gap: "xs", wrap: "nowrap", miw: 0, children: [_jsx(Box, { component: "nav", "aria-label": "Breadcrumb", miw: 0,
|
|
48
|
+
// Clips crumbs that overflow, padded by the focus ring's reach so the ring is not clipped.
|
|
49
|
+
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
|
+
// 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] })] }) }))] }));
|
|
56
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Marks an overlay Mod+P may open over: the
|
|
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
|
/** @internal Exported for tests and stories. */
|
|
4
4
|
export interface QuickOpenEntry {
|
|
@@ -7,8 +7,9 @@ import { useHotkeys } from '@mantine/hooks';
|
|
|
7
7
|
import { IconSearch } from '@tabler/icons-react';
|
|
8
8
|
import { filterEntries, groupEntriesByCollection } from '../entry-search.js';
|
|
9
9
|
import { modKeyLabel } from '../platform.js';
|
|
10
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
10
11
|
import { FOCUS_RETURN_ATTR, focusIfIdle } from '../utils/focus-return.js';
|
|
11
|
-
/** Marks an overlay Mod+P may open over: the
|
|
12
|
+
/** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
|
|
12
13
|
export const QUICK_OPEN_REPLACES_ATTR = 'data-quick-open-replaces';
|
|
13
14
|
/**
|
|
14
15
|
* True while a modal or drawer (not a popover) is open that `QUICK_OPEN_REPLACES_ATTR` doesn't mark.
|
|
@@ -61,5 +62,5 @@ export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionL
|
|
|
61
62
|
onSelect(path);
|
|
62
63
|
onClose();
|
|
63
64
|
};
|
|
64
|
-
return (_jsx(Modal, { opened: opened, onClose: onClose, title:
|
|
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."] }))] }) }));
|
|
65
66
|
}
|
|
@@ -19,5 +19,7 @@ export interface RenameEntryModalProps {
|
|
|
19
19
|
existingSlugs?: ReadonlySet<string>;
|
|
20
20
|
/** The URL path the entry will be served at for a slug; undefined when it has no page. */
|
|
21
21
|
previewPath?: (slug: string) => string | undefined;
|
|
22
|
+
/** Where focus goes once it closes; Mantine's default, the element focused at open, without it. */
|
|
23
|
+
returnFocusTo?: () => HTMLElement | null;
|
|
22
24
|
}
|
|
23
|
-
export declare function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, returnFocusTo, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -8,7 +8,8 @@ import { useState } from 'react';
|
|
|
8
8
|
import { Modal, Stack, TextInput, Group, Button, Alert, Text } from '@mantine/core';
|
|
9
9
|
import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
|
|
10
10
|
import { validateEntrySlug } from '../slug.js';
|
|
11
|
-
|
|
11
|
+
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, }) {
|
|
12
13
|
const [newSlug, setNewSlug] = useState(currentSlug);
|
|
13
14
|
const [touched, setTouched] = useState(false);
|
|
14
15
|
// Reset state when modal opens (adjust state during render instead of effect)
|
|
@@ -37,7 +38,7 @@ export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onCl
|
|
|
37
38
|
await onSave(newSlug);
|
|
38
39
|
};
|
|
39
40
|
const slugPath = previewPath?.(newSlug || 'your-slug');
|
|
40
|
-
return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Rename entry" }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, 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) => {
|
|
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) => {
|
|
41
42
|
setNewSlug(e.currentTarget.value);
|
|
42
43
|
setTouched(true);
|
|
43
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" })] })] }) }) }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LogicalPath } from '../../paths/types.js';
|
|
2
|
+
import { type SaveAllResult } from '../hooks/save-all.js';
|
|
3
|
+
interface SaveAllResultMessageProps {
|
|
4
|
+
result: SaveAllResult;
|
|
5
|
+
onOpenEntry: (path: LogicalPath) => void;
|
|
6
|
+
/** Runs Save all again over whatever is still unsaved. */
|
|
7
|
+
onRetry: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Names every entry Save all could not save, each opening that entry, with its reason and a
|
|
11
|
+
* warning or error mark. "Try again" is offered only when a failure is one a retry can pass.
|
|
12
|
+
* @internal Exported for stories.
|
|
13
|
+
*/
|
|
14
|
+
export declare function SaveAllResultMessage({ result, onOpenEntry, onRetry }: SaveAllResultMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
type Actions = Omit<SaveAllResultMessageProps, 'result'>;
|
|
16
|
+
/**
|
|
17
|
+
* Reports a finished Save all in one toast. Partly failed, it stays until dismissed: yellow when
|
|
18
|
+
* nothing failed for good, else red, each entry marked by its own severity.
|
|
19
|
+
*/
|
|
20
|
+
export declare function showSaveAllResult(result: SaveAllResult, actions: Actions): void;
|
|
21
|
+
/** Takes an entry out of the result on screen, and the toast down with its last entry. */
|
|
22
|
+
export declare function settleSaveAllEntry(contentId: string): void;
|
|
23
|
+
/** Takes a partly failed result down, as when the branch it belongs to is left. */
|
|
24
|
+
export declare function clearSaveAllResult(): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Anchor, Box, Button, Group, List, Stack, Text, VisuallyHidden } from '@mantine/core';
|
|
4
|
+
import { notifications } from '@mantine/notifications';
|
|
5
|
+
import { IconAlertTriangle, IconCircleX } from '@tabler/icons-react';
|
|
6
|
+
import { SAVE_ALL_RESULT_TOAST_ID, } from '../hooks/save-all.js';
|
|
7
|
+
import { getNotificationDuration } from '../utils/env.js';
|
|
8
|
+
const entries = (count) => `${count} ${count === 1 ? 'entry' : 'entries'}`;
|
|
9
|
+
/** Nothing is lost yet: no answer, or a change made elsewhere. */
|
|
10
|
+
const isWarning = (failure) => failure.kind === 'unanswered' || failure.kind === 'conflict';
|
|
11
|
+
/**
|
|
12
|
+
* Names every entry Save all could not save, each opening that entry, with its reason and a
|
|
13
|
+
* warning or error mark. "Try again" is offered only when a failure is one a retry can pass.
|
|
14
|
+
* @internal Exported for stories.
|
|
15
|
+
*/
|
|
16
|
+
export function SaveAllResultMessage({ result, onOpenEntry, onRetry }) {
|
|
17
|
+
const retryable = result.failed.some((failure) => failure.kind === 'error' || failure.retryable);
|
|
18
|
+
return (_jsxs(Stack, { gap: 6, "data-testid": "save-all-result", children: [result.saved.length > 0 && _jsx(Text, { size: "sm", children: `${entries(result.saved.length)} saved.` }), _jsx(List, { size: "sm", spacing: 6, listStyleType: "none", pl: 0, children: result.failed.map((failure) => (_jsx(List.Item, { "data-testid": "save-all-failure", children: _jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", children: [_jsx(Box, { component: "span", display: "inline-flex", mt: 2, "aria-hidden": "true", c: isWarning(failure) ? 'var(--mantine-color-text)' : 'var(--canopy-danger-text)', children: isWarning(failure) ? _jsx(IconAlertTriangle, { size: 16 }) : _jsx(IconCircleX, { size: 16 }) }), _jsxs("div", { children: [_jsx(VisuallyHidden, { component: "span", children: isWarning(failure) ? 'Warning: ' : 'Error: ' }), _jsx(Anchor, { component: "button", type: "button", size: "sm", fw: 600, onClick: () => onOpenEntry(failure.path), children: failure.label }), _jsx(Text, { size: "sm", span: true, children: `: ${failure.reason}` }), failure.recreate && (_jsx(Button, { display: "block", mt: 4, size: "compact-xs", variant: "default", onClick: failure.recreate, children: "Save as new entry" }))] })] }) }, failure.contentId))) }), _jsx(Text, { size: "sm", children: result.failed.length === 1
|
|
19
|
+
? 'Its unsaved changes are kept.'
|
|
20
|
+
: 'Their unsaved changes are kept.' }), retryable && (_jsx(Button, { size: "compact-sm", variant: "default", w: "fit-content", onClick: onRetry, children: "Try again" }))] }));
|
|
21
|
+
}
|
|
22
|
+
// The result on screen, so an entry settled elsewhere (saved, refreshed, discarded) leaves it.
|
|
23
|
+
let shown;
|
|
24
|
+
const renderFailures = (result, actions, update) => {
|
|
25
|
+
const data = {
|
|
26
|
+
id: SAVE_ALL_RESULT_TOAST_ID,
|
|
27
|
+
// An unanswered save may have landed, so "not saved" would claim more than is known.
|
|
28
|
+
title: `${result.failed.length === result.total
|
|
29
|
+
? entries(result.total)
|
|
30
|
+
: `${result.failed.length} of ${entries(result.total)}`} ${result.failed.some((failure) => failure.kind === 'unanswered') ? 'may not have saved' : 'not saved'}`,
|
|
31
|
+
message: _jsx(SaveAllResultMessage, { result: result, ...actions }),
|
|
32
|
+
color: result.failed.every(isWarning) ? 'yellow' : 'red',
|
|
33
|
+
autoClose: false,
|
|
34
|
+
withCloseButton: true,
|
|
35
|
+
onClose: () => {
|
|
36
|
+
shown = undefined;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
if (update)
|
|
40
|
+
notifications.update(data);
|
|
41
|
+
else
|
|
42
|
+
notifications.show(data);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Reports a finished Save all in one toast. Partly failed, it stays until dismissed: yellow when
|
|
46
|
+
* nothing failed for good, else red, each entry marked by its own severity.
|
|
47
|
+
*/
|
|
48
|
+
export function showSaveAllResult(result, actions) {
|
|
49
|
+
shown = undefined;
|
|
50
|
+
notifications.hide(SAVE_ALL_RESULT_TOAST_ID);
|
|
51
|
+
if (result.total === 0 || result.failed.length === 0) {
|
|
52
|
+
notifications.show({
|
|
53
|
+
id: SAVE_ALL_RESULT_TOAST_ID,
|
|
54
|
+
// An empty run found every change already on the server, or settled meanwhile.
|
|
55
|
+
message: result.total === 0 ? 'No changes to save' : `${entries(result.saved.length)} saved`,
|
|
56
|
+
color: result.total === 0 ? 'blue' : 'green',
|
|
57
|
+
autoClose: getNotificationDuration(4000),
|
|
58
|
+
withCloseButton: true,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
shown = { result, actions };
|
|
63
|
+
renderFailures(result, actions, false);
|
|
64
|
+
}
|
|
65
|
+
/** Takes an entry out of the result on screen, and the toast down with its last entry. */
|
|
66
|
+
export function settleSaveAllEntry(contentId) {
|
|
67
|
+
if (!shown)
|
|
68
|
+
return;
|
|
69
|
+
const failed = shown.result.failed.filter((failure) => failure.contentId !== contentId);
|
|
70
|
+
if (failed.length === shown.result.failed.length)
|
|
71
|
+
return;
|
|
72
|
+
if (failed.length === 0) {
|
|
73
|
+
clearSaveAllResult();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Settled, the entry is no longer part of the run the title counts.
|
|
77
|
+
shown = { ...shown, result: { ...shown.result, failed, total: shown.result.total - 1 } };
|
|
78
|
+
renderFailures(shown.result, shown.actions, true);
|
|
79
|
+
}
|
|
80
|
+
/** Takes a partly failed result down, as when the branch it belongs to is left. */
|
|
81
|
+
export function clearSaveAllResult() {
|
|
82
|
+
if (!shown)
|
|
83
|
+
return;
|
|
84
|
+
shown = undefined;
|
|
85
|
+
notifications.hide(SAVE_ALL_RESULT_TOAST_ID);
|
|
86
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SaveAllProgress } from '../hooks/save-all.js';
|
|
2
|
+
export interface SaveSplitButtonProps {
|
|
3
|
+
onSave: () => void;
|
|
4
|
+
/** Why Save can't act now; empty when it can. */
|
|
5
|
+
saveBlockedReason: string;
|
|
6
|
+
/** The editor is loading or saving: Save waits without a reason. */
|
|
7
|
+
busy: boolean;
|
|
8
|
+
onSaveAll: () => void;
|
|
9
|
+
/** Entries on the branch with unsaved changes. */
|
|
10
|
+
unsavedCount: number;
|
|
11
|
+
/** Why Save all can't act now, other than nothing being unsaved. */
|
|
12
|
+
saveAllBlockedReason?: string;
|
|
13
|
+
/** Set while Save all runs. */
|
|
14
|
+
saveAllProgress: SaveAllProgress | null;
|
|
15
|
+
/** A Save of the open entry is in flight. */
|
|
16
|
+
saving?: boolean;
|
|
17
|
+
/** Rises each time a single Save lands, whichever entry it saved. */
|
|
18
|
+
savedSignal?: number;
|
|
19
|
+
/** The last Save that landed saved the open entry. */
|
|
20
|
+
savedHere?: boolean;
|
|
21
|
+
/** The open entry has unsaved changes. */
|
|
22
|
+
entryDirty: boolean;
|
|
23
|
+
/** Writing drafts to localStorage failed, so they live only in this tab. */
|
|
24
|
+
draftStorageFailed: boolean;
|
|
25
|
+
}
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
export declare function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving, savedSignal, savedHere, entryDirty, draftStorageFailed, }: SaveSplitButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Box, Button, Loader, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { IconAlertTriangle, IconCheck, IconChevronDown, IconCircleDot, IconLoader2, } from '@tabler/icons-react';
|
|
7
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
9
|
+
import { useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
10
|
+
const STORAGE_FAILED_HINT = "Save before you leave or reload, or they'll be lost.";
|
|
11
|
+
const SAVED_SHOWN_MS = 2000;
|
|
12
|
+
// Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
|
|
13
|
+
const LABEL_TEXT_CLASS = 'canopy-save-label-text';
|
|
14
|
+
const LABEL_STYLES = `
|
|
15
|
+
@media (max-width: 47.99em) {
|
|
16
|
+
.${LABEL_TEXT_CLASS} {
|
|
17
|
+
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
|
18
|
+
clip: rect(0 0 0 0); white-space: nowrap;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
/**
|
|
23
|
+
* Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
|
|
24
|
+
* and the top bar never shifts.
|
|
25
|
+
*/
|
|
26
|
+
function SaveLabels({ shown, spinner }) {
|
|
27
|
+
const labels = {
|
|
28
|
+
save: EDITOR_ACTIONS.save,
|
|
29
|
+
saving: (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: shown === 'saving' &&
|
|
30
|
+
(spinner ? (_jsx(Loader, { size: 14, color: "currentColor" })) : (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" }))) }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saving\u2026" })] })),
|
|
31
|
+
saved: (_jsxs(_Fragment, { children: [_jsx(IconCheck, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saved" })] })),
|
|
32
|
+
};
|
|
33
|
+
return (_jsx(Box, { component: "span", pos: "relative", style: { display: 'inline-grid' }, children: Object.keys(labels).map((key) => (_jsx(Box, { component: "span", "aria-hidden": key === shown ? undefined : true, style: {
|
|
34
|
+
gridArea: '1 / 1',
|
|
35
|
+
display: 'inline-flex',
|
|
36
|
+
alignItems: 'center',
|
|
37
|
+
justifyContent: 'center',
|
|
38
|
+
gap: 6,
|
|
39
|
+
visibility: key === shown ? 'visible' : 'hidden',
|
|
40
|
+
}, children: labels[key] }, key))) }));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The unsaved indicator, then Save with a caret offering "Save all". The indicator is icon-only
|
|
44
|
+
* below `lg`, its text then its Tooltip and accessible name.
|
|
45
|
+
*/
|
|
46
|
+
export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
|
|
47
|
+
const caret = useRef(null);
|
|
48
|
+
const menuFocus = useMenuItemFocusReturn(caret);
|
|
49
|
+
const reducedMotion = useReducedMotion();
|
|
50
|
+
// Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
|
|
51
|
+
// before mount or while another entry was open shows nothing later.
|
|
52
|
+
const [seenSignal, setSeenSignal] = useState(savedSignal ?? 0);
|
|
53
|
+
const [flashing, setFlashing] = useState();
|
|
54
|
+
// Leaving the saved entry ends its "Saved", so a quick return doesn't show or announce it again.
|
|
55
|
+
const [wasHere, setWasHere] = useState(savedHere);
|
|
56
|
+
if (wasHere !== savedHere) {
|
|
57
|
+
setWasHere(savedHere);
|
|
58
|
+
if (!savedHere)
|
|
59
|
+
setFlashing(undefined);
|
|
60
|
+
}
|
|
61
|
+
if (savedSignal !== undefined && savedSignal > seenSignal) {
|
|
62
|
+
setSeenSignal(savedSignal);
|
|
63
|
+
setFlashing(savedHere ? savedSignal : undefined);
|
|
64
|
+
}
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (flashing === undefined)
|
|
67
|
+
return;
|
|
68
|
+
const timer = setTimeout(() => setFlashing(undefined), SAVED_SHOWN_MS);
|
|
69
|
+
return () => clearTimeout(timer);
|
|
70
|
+
}, [flashing]);
|
|
71
|
+
// Blocked, Save stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
|
|
72
|
+
const saveBlocked = !!saveBlockedReason || busy || !!saveAllProgress;
|
|
73
|
+
const label = saving
|
|
74
|
+
? 'saving'
|
|
75
|
+
: flashing !== undefined &&
|
|
76
|
+
flashing === savedSignal &&
|
|
77
|
+
savedHere &&
|
|
78
|
+
!entryDirty &&
|
|
79
|
+
!saveAllProgress
|
|
80
|
+
? 'saved'
|
|
81
|
+
: 'save';
|
|
82
|
+
const saveGreyed = saveBlocked && !saveAllProgress && label === 'save';
|
|
83
|
+
const indicatorText = draftStorageFailed
|
|
84
|
+
? 'Unsaved · not kept on this device'
|
|
85
|
+
: 'Unsaved · kept on this device';
|
|
86
|
+
const indicatorColor = draftStorageFailed ? 'var(--canopy-danger-text)' : 'dimmed';
|
|
87
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: { flexShrink: 0 }, children: entryDirty && (_jsx(Tooltip, { label: draftStorageFailed ? `${indicatorText}. ${STORAGE_FAILED_HINT}` : indicatorText, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: indicatorColor, "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: indicatorText }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: draftStorageFailed ? _jsx(IconAlertTriangle, { size: 18 }) : _jsx(IconCircleDot, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: indicatorText }), draftStorageFailed && (_jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] }))] }) })) }), _jsx(VisuallyHidden, { role: "status", children: saveAllProgress
|
|
88
|
+
? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
|
|
89
|
+
: label === 'saved'
|
|
90
|
+
? 'Saved'
|
|
91
|
+
: '' }), _jsx("style", { children: LABEL_STYLES }), _jsxs(Button.Group, { style: { flexShrink: 0 }, children: [_jsx(Tooltip, { label: saveBlockedReason,
|
|
92
|
+
// Not beside "Saving…" or "Saved", which already say what happened.
|
|
93
|
+
disabled: !saveBlockedReason || 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
|
+
if (!saveBlocked)
|
|
95
|
+
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,
|
|
97
|
+
// Light beside a greyed Save, so a blocked split button shows no filled half.
|
|
98
|
+
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
|
+
(saveAllProgress
|
|
100
|
+
? `${EDITOR_ACTIONS.saveAll} is already running.`
|
|
101
|
+
: unsavedCount === 0
|
|
102
|
+
? 'No entry on this branch has unsaved changes.'
|
|
103
|
+
: busy
|
|
104
|
+
? 'Wait for the current save or load to finish.'
|
|
105
|
+
: undefined), children: unsavedCount > 0
|
|
106
|
+
? `${EDITOR_ACTIONS.saveAll} (${unsavedCount})`
|
|
107
|
+
: EDITOR_ACTIONS.saveAll }) })] })] })] }));
|
|
108
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from './
|
|
1
|
+
export * from './BranchLockBanner.js';
|
|
2
2
|
export * from './EditorHeader.js';
|
|
3
|
-
export * from './
|
|
3
|
+
export * from './EditorRail.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from './
|
|
1
|
+
export * from './BranchLockBanner.js';
|
|
2
2
|
export * from './EditorHeader.js';
|
|
3
|
-
export * from './
|
|
3
|
+
export * from './EditorRail.js';
|
|
@@ -21,11 +21,11 @@ interface EditorStateProviderProps {
|
|
|
21
21
|
/** Initial modal states (useful for deep linking) */
|
|
22
22
|
initialModals?: Partial<ModalState>;
|
|
23
23
|
}
|
|
24
|
-
/** @internal No importer; deletion candidate in
|
|
24
|
+
/** @internal No importer; deletion candidate in safeinsights/canopycms#678. */
|
|
25
25
|
export declare function EditorStateProvider({ children, initialModals }: EditorStateProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
26
26
|
/**
|
|
27
27
|
* Convenience hook for loading states only.
|
|
28
|
-
* @internal No importer; deletion candidate in
|
|
28
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
29
29
|
*/
|
|
30
30
|
export declare function useEditorLoading(): {
|
|
31
31
|
busy: boolean;
|
|
@@ -36,7 +36,7 @@ export declare function useEditorLoading(): {
|
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
38
|
* Convenience hook for modal states only.
|
|
39
|
-
* @internal No importer; deletion candidate in
|
|
39
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
40
40
|
*/
|
|
41
41
|
export declare function useEditorModals(): {
|
|
42
42
|
openModal: (key: keyof ModalState) => void;
|
|
@@ -50,7 +50,7 @@ export declare function useEditorModals(): {
|
|
|
50
50
|
};
|
|
51
51
|
/**
|
|
52
52
|
* Convenience hook for preview state only.
|
|
53
|
-
* @internal No importer; deletion candidate in
|
|
53
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
54
54
|
*/
|
|
55
55
|
export declare function useEditorPreview(): {
|
|
56
56
|
data: FormValue;
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
6
6
|
*/
|
|
7
7
|
import { createContext, useContext, useCallback, useState, useMemo } from 'react';
|
|
8
8
|
const EditorStateContext = createContext(null);
|
|
9
|
-
/** @internal No importer; deletion candidate in
|
|
9
|
+
/** @internal No importer; deletion candidate in safeinsights/canopycms#678. */
|
|
10
10
|
export function EditorStateProvider({ children, initialModals }) {
|
|
11
11
|
const [loading, setLoadingState] = useState({
|
|
12
12
|
branches: false,
|
|
@@ -88,7 +88,7 @@ function useEditorState() {
|
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
90
|
* Convenience hook for loading states only.
|
|
91
|
-
* @internal No importer; deletion candidate in
|
|
91
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
92
92
|
*/
|
|
93
93
|
export function useEditorLoading() {
|
|
94
94
|
const { state, actions } = useEditorState();
|
|
@@ -100,7 +100,7 @@ export function useEditorLoading() {
|
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
102
|
* Convenience hook for modal states only.
|
|
103
|
-
* @internal No importer; deletion candidate in
|
|
103
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
104
104
|
*/
|
|
105
105
|
export function useEditorModals() {
|
|
106
106
|
const { state, actions } = useEditorState();
|
|
@@ -113,7 +113,7 @@ export function useEditorModals() {
|
|
|
113
113
|
}
|
|
114
114
|
/**
|
|
115
115
|
* Convenience hook for preview state only.
|
|
116
|
-
* @internal No importer; deletion candidate in
|
|
116
|
+
* @internal No importer; deletion candidate in safeinsights/canopycms#678.
|
|
117
117
|
*/
|
|
118
118
|
export function useEditorPreview() {
|
|
119
119
|
const { state, actions } = useEditorState();
|