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.
Files changed (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -1,22 +1,51 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from 'react';
3
- import { Button, Paper, Stack } from '@mantine/core';
2
+ import { useId, useRef } from 'react';
3
+ import { ActionIcon, Button, Menu, Paper, Stack, Text, Tooltip, VisuallyHidden, } from '@mantine/core';
4
+ import { IconDots } from '@tabler/icons-react';
4
5
  import { isPlainRecord } from '../../validation/field-traversal.js';
5
6
  import { formatCanopyPath } from '../canopy-path.js';
6
7
  import { useFieldCommentControl } from '../comments/field-comment-context.js';
8
+ import { DestructiveMenuItem } from '../components/DestructiveMenuItem.js';
7
9
  import { EDITOR_ACTIONS } from '../copy.js';
10
+ import { focusFieldTarget } from '../field-target.js';
8
11
  import { groupDescriptionProps } from './FieldDescription.js';
9
12
  import { FieldLabel } from './FieldLabel.js';
10
- export const ObjectField = ({ label, required, description, fields, value, onChange, renderField, path, dataCanopyField, onRemove, }) => {
13
+ const quoted = (label) => (label ? ` "${label}"` : '');
14
+ /**
15
+ * An object field's group of fields. Its Clear is an Undo-instead-of-confirm removal
16
+ * (docs/ux-guidelines.md, Destructive actions) whose Undo stays in the label row, where the clear
17
+ * happened, with no timer, until the caller drops `onUndoClear`. FormRenderer drops it once it
18
+ * can no longer put the object back where it was, another entry or branch opens, or the entry is
19
+ * saved or its draft discarded.
20
+ */
21
+ export const ObjectField = ({ label, required, description, fields, value, onChange, renderField, path, dataCanopyField, onClear, onUndoClear, clearNote, }) => {
11
22
  const current = isPlainRecord(value) ? value : {};
12
23
  const descriptionBaseId = useId();
13
24
  const commentControl = useFieldCommentControl(dataCanopyField ?? formatCanopyPath(path));
14
- return (_jsx(Paper, { withBorder: true, p: "md", bg: "var(--canopy-surface-subtle)", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label, required: required, description: description, descriptionBaseId: descriptionBaseId, commentControl: commentControl, actions: onRemove && (_jsx(Button, { variant: "subtle", color: "gray", "aria-label": label ? `${EDITOR_ACTIONS.clear} "${label}"` : undefined, onClick: (event) => {
15
- // The button goes with the value, so focus moves to the object, not the page.
16
- const field = event.currentTarget.closest('[data-removal-focus]');
17
- onRemove();
18
- field?.focus();
19
- }, children: EDITOR_ACTIONS.clear })) }), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
25
+ const groupRef = useRef(null);
26
+ const undoRef = useRef(null);
27
+ const actionsLabel = label ? `Actions for ${label}` : 'Actions';
28
+ // The menu goes with the value and the Undo takes its place, so focus moves there, one key from
29
+ // undoing. With no Undo offered, the object takes focus rather than the page.
30
+ const clear = () => {
31
+ onClear?.();
32
+ requestAnimationFrame(() => (undoRef.current ?? groupRef.current)?.focus());
33
+ };
34
+ const undo = () => {
35
+ onUndoClear?.();
36
+ requestAnimationFrame(() => {
37
+ if (groupRef.current)
38
+ focusFieldTarget(groupRef.current);
39
+ });
40
+ };
41
+ let actions = null;
42
+ if (onUndoClear) {
43
+ actions = (_jsx(Button, { ref: undoRef, size: "compact-sm", variant: "subtle", "aria-label": `${EDITOR_ACTIONS.undoClear}${quoted(label)}`, onClick: undo, children: EDITOR_ACTIONS.undoClear }));
44
+ }
45
+ else if (onClear) {
46
+ actions = (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 220, children: [_jsx(Tooltip, { label: actionsLabel, openDelay: 500, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": actionsLabel, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsx(Menu.Dropdown, { children: _jsx(DestructiveMenuItem, { onClick: clear, children: `${EDITOR_ACTIONS.clear}${quoted(label)}` }) })] }));
47
+ }
48
+ return (_jsx(Paper, { ref: groupRef, withBorder: true, p: "md", bg: "var(--canopy-surface-subtle)", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label, required: required, description: description, descriptionBaseId: descriptionBaseId, commentControl: commentControl, actions: actions }), onUndoClear && clearNote && (_jsx(Text, { size: "xs", c: "dimmed", "aria-hidden": true, children: clearNote })), (onClear || onUndoClear) && (_jsx(VisuallyHidden, { role: "status", children: onUndoClear ? `Cleared${quoted(label)}.${clearNote ? ` ${clearNote}` : ''}` : '' })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
20
49
  const fieldPath = [...path, field.name];
21
50
  return (_jsx("div", { children: renderField(field, current[field.name], (next) => onChange({ ...current, [field.name]: next }), fieldPath) }, formatCanopyPath(fieldPath)));
22
51
  }) })] }) }));
@@ -2,6 +2,8 @@ import type { ContentId, LogicalPath } from '../../../paths/types.js';
2
2
  export interface EntryLinkOption {
3
3
  contentId: ContentId;
4
4
  label: string;
5
+ /** The entry's title, the link text, where `label` is a singleton's type label. */
6
+ title?: string;
5
7
  slug?: string;
6
8
  collectionPath?: LogicalPath;
7
9
  collectionName?: string;
@@ -21,6 +21,7 @@ export const InsertEntryLink = ({ onInsert }) => {
21
21
  return entries;
22
22
  const lower = search.toLowerCase();
23
23
  return entries.filter((e) => e.label.toLowerCase().includes(lower) ||
24
+ e.title?.toLowerCase().includes(lower) ||
24
25
  e.slug?.toLowerCase().includes(lower) ||
25
26
  e.collectionName?.toLowerCase().includes(lower));
26
27
  }, [entries, search]);
@@ -35,7 +36,8 @@ export const InsertEntryLink = ({ onInsert }) => {
35
36
  return groups;
36
37
  }, [filtered]);
37
38
  const handleSelect = (entry) => {
38
- const rawText = entry.label || entry.slug || 'Link';
39
+ // The title, not a singleton's type label: published content reads the page's own name.
40
+ const rawText = entry.title || entry.label || entry.slug || 'Link';
39
41
  // Escape backslashes first, then characters that end the link text or that
40
42
  // the editor parses as MDX (`{` opens an expression, `<` a JSX tag).
41
43
  const linkText = rawText.replace(/\\/g, '\\\\').replace(/([[\]()<>{}])/g, '\\$1');
@@ -82,7 +82,7 @@ const GroupManagerDrawer = ({ opened, onClose, onExited, returnFocusTo, internal
82
82
  addMember(groupId, userId);
83
83
  userSearch.hideSearch();
84
84
  }, [addMember, userSearch]);
85
- return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Groups", description: "Manage groups and organizations", size: 600, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save groups", onSave: save, onDiscard: discard, childModalOpen: isModalOpen || deleteConfirmOpen, onExited: onExited, returnFocusTo: returnFocusTo, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to manage groups." })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: errorIsConflict ? 'yellow' : 'red', mb: "sm", title: errorIsConflict ? 'Not saved' : 'Error', withCloseButton: true, onClose: () => setError(null), children: error })), loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading groups..." })] })) : (_jsxs(Tabs, { defaultValue: "internal", style: { flex: 1, display: 'flex', flexDirection: 'column' }, children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "internal", children: "Internal groups" }), _jsx(Tabs.Tab, { value: "external", children: "External groups" })] }), _jsx(Tabs.Panel, { value: "internal", style: { flex: 1, overflow: 'auto' }, children: _jsx(InternalGroupsTab, { groups: groups, unsavedGroupIds: unsavedGroupIds, canEdit: canEdit, onCreateGroup: handleCreateGroup, onEditGroup: handleEditGroup, onDeleteGroup: confirmDeleteGroup, onAddMember: handleAddMember, onRemoveMember: removeMember, onGetUserMetadata: onGetUserMetadata, activeSearchGroupId: userSearch.activeGroupId, searchQuery: userSearch.searchQuery, searchResults: userSearch.searchResults, isSearching: userSearch.isSearching, searchError: userSearch.searchError, onSearchQueryChange: userSearch.setSearchQuery, onShowSearch: userSearch.showSearch, onHideSearch: userSearch.hideSearch, canSearch: !!onSearchUsers }) }), _jsx(Tabs.Panel, { value: "external", style: { flex: 1, overflow: 'auto' }, children: _jsx(ExternalGroupsTab, { canEdit: canEdit, searchQuery: externalGroupSearch.searchQuery, searchResults: externalGroupSearch.searchResults, isSearching: externalGroupSearch.isSearching, searchError: externalGroupSearch.searchError, onSearchQueryChange: externalGroupSearch.setSearchQuery, canSearch: !!onSearchExternalGroups }) })] })), _jsx(GroupForm, { isOpen: isModalOpen, editingGroup: editingGroup, formData: formData, onFormChange: handleFormChange, onSave: handleSaveModal, onClose: () => setIsModalOpen(false) })] }) }));
85
+ return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Groups", size: 600, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save groups", onSave: save, onDiscard: discard, childModalOpen: isModalOpen || deleteConfirmOpen, onExited: onExited, returnFocusTo: returnFocusTo, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to manage groups." })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: errorIsConflict ? 'yellow' : 'red', mb: "sm", title: errorIsConflict ? 'Not saved' : 'Error', withCloseButton: true, onClose: () => setError(null), children: error })), loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading groups..." })] })) : (_jsxs(Tabs, { defaultValue: "internal", style: { flex: 1, display: 'flex', flexDirection: 'column' }, children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "internal", children: "Internal groups" }), _jsx(Tabs.Tab, { value: "external", children: "External groups" })] }), _jsx(Tabs.Panel, { value: "internal", style: { flex: 1, overflow: 'auto' }, children: _jsx(InternalGroupsTab, { groups: groups, unsavedGroupIds: unsavedGroupIds, canEdit: canEdit, onCreateGroup: handleCreateGroup, onEditGroup: handleEditGroup, onDeleteGroup: confirmDeleteGroup, onAddMember: handleAddMember, onRemoveMember: removeMember, onGetUserMetadata: onGetUserMetadata, activeSearchGroupId: userSearch.activeGroupId, searchQuery: userSearch.searchQuery, searchResults: userSearch.searchResults, isSearching: userSearch.isSearching, searchError: userSearch.searchError, onSearchQueryChange: userSearch.setSearchQuery, onShowSearch: userSearch.showSearch, onHideSearch: userSearch.hideSearch, canSearch: !!onSearchUsers }) }), _jsx(Tabs.Panel, { value: "external", style: { flex: 1, overflow: 'auto' }, children: _jsx(ExternalGroupsTab, { canEdit: canEdit, searchQuery: externalGroupSearch.searchQuery, searchResults: externalGroupSearch.searchResults, isSearching: externalGroupSearch.isSearching, searchError: externalGroupSearch.searchError, onSearchQueryChange: externalGroupSearch.setSearchQuery, canSearch: !!onSearchExternalGroups }) })] })), _jsx(GroupForm, { isOpen: isModalOpen, editingGroup: editingGroup, formData: formData, onFormChange: handleFormChange, onSave: handleSaveModal, onClose: () => setIsModalOpen(false) })] }) }));
86
86
  };
87
87
  export { useGroupState } from './hooks/useGroupState.js';
88
88
  export { useUserSearch } from './hooks/useUserSearch.js';
@@ -88,6 +88,8 @@ export interface UseBranchManagerReturn {
88
88
  branches: BranchListItem[];
89
89
  branchSummaries: BranchSummary[];
90
90
  currentBranch: BranchListItem | undefined;
91
+ /** The listing failed and nothing earlier stands in for it. */
92
+ branchesLoadFailed: boolean;
91
93
  /** Shows a branch the server just created before any listing includes it. */
92
94
  addCreatedBranch: (branch: BranchListItem) => void;
93
95
  handleSubmit: (branchName: string) => Promise<void>;
@@ -601,6 +601,7 @@ export function useBranchManager(options) {
601
601
  branches,
602
602
  branchSummaries,
603
603
  currentBranch,
604
+ branchesLoadFailed: !!branchesError && !branchesData,
604
605
  addCreatedBranch,
605
606
  handleSubmit,
606
607
  handleWithdraw,
@@ -11,6 +11,8 @@ interface EditorLayoutPrefs {
11
11
  sideSplitPercent: number;
12
12
  stackedSplitPercent: number;
13
13
  previewWidth: PreviewWidth;
14
+ /** The preview asks for the site's header and footer; see `siteChromeSrc`. */
15
+ siteChrome: boolean;
14
16
  contentPanelOpen: boolean;
15
17
  contentPanelWidth: number;
16
18
  }
@@ -46,6 +48,8 @@ export interface UseEditorLayoutReturn {
46
48
  setSplitPercent: (layout: PaneArrangement, percent: number) => void;
47
49
  previewWidth: PreviewWidth;
48
50
  setPreviewWidth: (width: PreviewWidth) => void;
51
+ siteChrome: boolean;
52
+ setSiteChrome: (siteChrome: boolean) => void;
49
53
  contentPanelOpen: boolean;
50
54
  setContentPanelOpen: (open: boolean) => void;
51
55
  contentPanelWidth: number;
@@ -15,6 +15,7 @@ export const DEFAULT_EDITOR_LAYOUT_PREFS = {
15
15
  sideSplitPercent: DEFAULT_SIDE_SPLIT_PERCENT,
16
16
  stackedSplitPercent: DEFAULT_STACKED_SPLIT_PERCENT,
17
17
  previewWidth: 'fit',
18
+ siteChrome: false,
18
19
  contentPanelOpen: false,
19
20
  contentPanelWidth: 280,
20
21
  };
@@ -50,6 +51,7 @@ export function parseEditorLayoutPrefs(raw) {
50
51
  sideSplitPercent: numberOr(parsed.sideSplitPercent, defaults.sideSplitPercent, clampSplitPercent),
51
52
  stackedSplitPercent: numberOr(parsed.stackedSplitPercent, defaults.stackedSplitPercent, clampSplitPercent),
52
53
  previewWidth: oneOf(PREVIEW_WIDTHS, parsed.previewWidth, defaults.previewWidth),
54
+ siteChrome: boolOr(parsed.siteChrome, defaults.siteChrome),
53
55
  contentPanelOpen: boolOr(parsed.contentPanelOpen, defaults.contentPanelOpen),
54
56
  contentPanelWidth: numberOr(parsed.contentPanelWidth, defaults.contentPanelWidth, clampContentPanelWidth),
55
57
  };
@@ -80,6 +82,7 @@ export function useEditorLayout(options) {
80
82
  : { ...prev, stackedSplitPercent: clamped });
81
83
  }, [setPrefs]);
82
84
  const setPreviewWidth = useCallback((previewWidth) => setPrefs((prev) => ({ ...prev, previewWidth })), [setPrefs]);
85
+ const setSiteChrome = useCallback((siteChrome) => setPrefs((prev) => ({ ...prev, siteChrome })), [setPrefs]);
83
86
  const setContentPanelOpen = useCallback((contentPanelOpen) => setPrefs((prev) => ({ ...prev, contentPanelOpen })), [setPrefs]);
84
87
  const setContentPanelWidth = useCallback((width) => {
85
88
  if (!Number.isFinite(width))
@@ -98,6 +101,8 @@ export function useEditorLayout(options) {
98
101
  setSplitPercent,
99
102
  previewWidth: prefs.previewWidth,
100
103
  setPreviewWidth,
104
+ siteChrome: prefs.siteChrome,
105
+ setSiteChrome,
101
106
  contentPanelOpen: prefs.contentPanelOpen,
102
107
  setContentPanelOpen,
103
108
  contentPanelWidth: prefs.contentPanelWidth,
@@ -34,6 +34,8 @@ export interface UseEntryManagerOptions {
34
34
  branchName: string;
35
35
  collections?: EditorCollection[];
36
36
  resolvePreviewSrc: (entry: Partial<EditorEntry>) => string | undefined;
37
+ /** An entry's route on the site (`buildPreviewRoute`), which picks the home page to open first. */
38
+ resolvePreviewRoute?: (entry: EditorEntry) => string | undefined;
37
39
  setBusy: (busy: boolean) => void;
38
40
  }
39
41
  export interface UseEntryManagerReturn {
@@ -1,7 +1,7 @@
1
1
  import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import useSWR, { useSWRConfig } from 'swr';
3
3
  import { notifications } from '@mantine/notifications';
4
- import { buildWritePayload, normalizeContentPayload } from '../editor-utils.js';
4
+ import { buildWritePayload, normalizeContentPayload, resolveHomeEntry } from '../editor-utils.js';
5
5
  import { isDataOnlyFormat } from '../../utils/format.js';
6
6
  import { withTitleAtPath } from '../../utils/title-field.js';
7
7
  import { getErrorMessage } from '../../utils/error.js';
@@ -53,7 +53,7 @@ const EMPTY_COLLECTIONS = [];
53
53
  const EMPTY_SCHEMAS = [];
54
54
  /**
55
55
  * The URL's `entry`: a path; `''` for no entry and the navigator open (the crash screen's escape
56
- * from an entry that crashes the editor); null opens the first entry.
56
+ * from an entry that crashes the editor); null opens the home page (`resolveHomeEntry`).
57
57
  */
58
58
  const readUrlEntry = () => typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('entry');
59
59
  /**
@@ -162,6 +162,9 @@ export function useEntryManager(options) {
162
162
  // the user switches mid-flight, which is exactly when the check matters).
163
163
  const currentBranchRef = useRef(options.branchName);
164
164
  currentBranchRef.current = options.branchName;
165
+ // Read by the selection check, which runs only when the entries or the selection change.
166
+ const resolvePreviewRouteRef = useRef(options.resolvePreviewRoute);
167
+ resolvePreviewRouteRef.current = options.resolvePreviewRoute;
165
168
  const recordCommittedRefreshSeq = (branch, seq) => {
166
169
  refreshSeqRef.current.committed.set(branch, seq);
167
170
  // A commit also implies any future claim must outrank this tag, so a
@@ -181,13 +184,13 @@ export function useEntryManager(options) {
181
184
  initialUrlEntry.current = readUrlEntry();
182
185
  }
183
186
  // Set by the URL's empty `entry` or by `closeEntry`; honoured until an entry is selected or the
184
- // branch changes, after which a lost selection opens the first again.
187
+ // branch changes, after which a lost selection opens the home page again.
185
188
  const noEntryRequested = useRef(initialUrlEntry.current === '');
186
189
  const selectEntryPath = useCallback((path) => {
187
190
  selectionsByCallerRef.current += 1;
188
191
  setSelectedPath(path);
189
192
  }, []);
190
- // No entry open, as the URL's empty `entry` asks, where a lost selection opens the first.
193
+ // No entry open, as the URL's empty `entry` asks, where a lost selection opens the home page.
191
194
  const closeEntry = useCallback(() => {
192
195
  noEntryRequested.current = true;
193
196
  selectEntryPath('');
@@ -503,7 +506,7 @@ export function useEntryManager(options) {
503
506
  const stillOnIt = selectedPathRef.current === path;
504
507
  const selections = selectionsByCallerRef.current;
505
508
  // The rename landed whatever the refresh does. The new path is selected only once the list
506
- // holds it: before that, the selection check would find it missing and open the first entry.
509
+ // holds it: before that, the selection check would find it missing and open the home page.
507
510
  const landed = async (newPath) => {
508
511
  const listed = await refreshEntries().catch((err) => {
509
512
  console.error(err);
@@ -545,7 +548,7 @@ export function useEntryManager(options) {
545
548
  return;
546
549
  }
547
550
  void refreshEntries().catch(console.error);
548
- throw new MayHaveLandedError(`Renaming "${label}" may have gone through even though the server didn't answer. Check the "Content" panel before trying again.`);
551
+ throw new MayHaveLandedError(`Changing the URL of "${label}" may have gone through even though the server didn't answer. Check the "Content" panel before trying again.`);
549
552
  }
550
553
  finally {
551
554
  options.setBusy(false);
@@ -705,8 +708,7 @@ export function useEntryManager(options) {
705
708
  return;
706
709
  if (selectedPath === '' && noEntryRequested.current)
707
710
  return;
708
- // Fall back to first entry
709
- setSelectedPath(entriesState[0]?.path ?? '');
711
+ setSelectedPath(resolveHomeEntry(entriesState, resolvePreviewRouteRef.current)?.path ?? '');
710
712
  }, [entriesState, selectedPath]);
711
713
  // Update URL when selection changes (skip until URL sync has happened)
712
714
  useEffect(() => {
@@ -6,13 +6,16 @@ export interface AssetCardProps {
6
6
  baseUrl?: string;
7
7
  /** Present in picker mode - clicking the card selects the asset. */
8
8
  onSelect?: (asset: AssetRecord) => void;
9
- /** Present only where the viewer may remove the asset (manage mode) - renders the trash button. */
10
- onDelete?: (asset: AssetRecord) => void;
9
+ /** Present only where the viewer may remove the asset from the library (manage mode). */
10
+ onRemove?: (asset: AssetRecord) => void;
11
+ /** Present only where the viewer may restore a removed (`hidden`) asset to the library. */
12
+ onRestore?: (asset: AssetRecord) => void;
11
13
  /** `compact` shrinks the thumbnail and drops the date line. */
12
14
  density?: 'grid' | 'compact';
13
15
  }
14
16
  /**
15
17
  * One thumbnail card in the MediaLibrary grid. PDFs have no image to preview, so they render a
16
- * file-type icon instead.
18
+ * file-type icon instead. A file removed from the library (`hidden`, listed only for an admin who
19
+ * asked to see removed files) is badged "Removed" and offers Restore instead of Remove.
17
20
  */
18
21
  export declare const AssetCard: React.FC<AssetCardProps>;
@@ -1,20 +1,32 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { ActionIcon, Card, Text, Tooltip } from '@mantine/core';
4
- import { IconFileTypePdf, IconPhotoOff, IconTrash } from '@tabler/icons-react';
3
+ import { ActionIcon, Badge, Card, Text, Tooltip } from '@mantine/core';
4
+ import { IconEyeOff, IconFileTypePdf, IconPhotoOff, IconRestore } from '@tabler/icons-react';
5
+ import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
6
+ import { EDITOR_ACTIONS } from '../copy.js';
5
7
  import { formatRelativeTime } from '../relative-time.js';
6
8
  import { editorImageSrc } from './editor-image-src.js';
7
9
  import { CARD_THUMBNAIL_HEIGHT } from './media-library-view.js';
8
10
  import { useThumbnailFailure } from './useThumbnailFailure.js';
9
- const THUMBNAIL_WIDTH = 160;
11
+ /** Over the thumbnail, so opaque: the default fill. */
12
+ const overlayActionStyle = {
13
+ position: 'absolute',
14
+ top: 4,
15
+ right: 4,
16
+ zIndex: 1,
17
+ };
10
18
  /**
11
19
  * One thumbnail card in the MediaLibrary grid. PDFs have no image to preview, so they render a
12
- * file-type icon instead.
20
+ * file-type icon instead. A file removed from the library (`hidden`, listed only for an admin who
21
+ * asked to see removed files) is badged "Removed" and offers Restore instead of Remove.
13
22
  */
14
- export const AssetCard = ({ asset, baseUrl, onSelect, onDelete, density = 'grid', }) => {
23
+ export const AssetCard = ({ asset, baseUrl, onSelect, onRemove, onRestore, density = 'grid', }) => {
15
24
  const compact = density === 'compact';
25
+ const removed = asset.hidden === true;
16
26
  const thumbnailHeight = CARD_THUMBNAIL_HEIGHT[density];
17
- const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
27
+ const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', {
28
+ width: EDITOR_THUMBNAIL_WIDTHS.card,
29
+ });
18
30
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
19
31
  const handleKeyDown = (event) => {
20
32
  if (!onSelect)
@@ -31,10 +43,13 @@ export const AssetCard = ({ asset, baseUrl, onSelect, onDelete, density = 'grid'
31
43
  // native <button> Enter/Space activation.
32
44
  role: onSelect ? 'button' : undefined, tabIndex: onSelect ? 0 : undefined, onKeyDown: onSelect ? handleKeyDown : undefined,
33
45
  // Mantine draws its focus ring only on its own controls or this class; a bare Card gets none.
34
- className: onSelect ? 'mantine-focus-auto' : undefined, "aria-label": onSelect ? `Select ${asset.filename}` : undefined, children: [onDelete && (_jsx(Tooltip, { label: "Remove from library", children: _jsx(ActionIcon, { variant: "default", c: "var(--canopy-danger-text)", radius: "xl", style: { position: 'absolute', top: 4, right: 4, zIndex: 1 }, "aria-label": `Remove ${asset.filename} from library`, "data-testid": `asset-card-delete-${asset.hash32}`, onClick: (event) => {
46
+ className: onSelect ? 'mantine-focus-auto' : undefined, "aria-label": onSelect ? `Select ${asset.filename}` : undefined, children: [!removed && onRemove && (_jsx(Tooltip, { label: `${EDITOR_ACTIONS.remove} from library`, children: _jsx(ActionIcon, { variant: "default", radius: "xl", style: overlayActionStyle, "aria-label": `${EDITOR_ACTIONS.remove} ${asset.filename} from library`, "data-testid": `asset-card-remove-${asset.hash32}`, onClick: (event) => {
47
+ event.stopPropagation();
48
+ onRemove(asset);
49
+ }, children: _jsx(IconEyeOff, { size: 14 }) }) })), removed && onRestore && (_jsx(Tooltip, { label: `${EDITOR_ACTIONS.restore} to library`, children: _jsx(ActionIcon, { variant: "default", radius: "xl", style: overlayActionStyle, "aria-label": `${EDITOR_ACTIONS.restore} ${asset.filename} to library`, "data-testid": `asset-card-restore-${asset.hash32}`, onClick: (event) => {
35
50
  event.stopPropagation();
36
- onDelete(asset);
37
- }, children: _jsx(IconTrash, { size: 14 }) }) })), _jsx(Card.Section, { children: asset.kind === 'pdf' ? (_jsx("div", { style: {
51
+ onRestore(asset);
52
+ }, children: _jsx(IconRestore, { size: 14 }) }) })), removed && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", style: { position: 'absolute', top: 8, left: 8, zIndex: 1 }, "data-testid": `asset-card-removed-${asset.hash32}`, children: "Removed" })), _jsx(Card.Section, { children: asset.kind === 'pdf' ? (_jsx("div", { style: {
38
53
  height: thumbnailHeight,
39
54
  display: 'flex',
40
55
  alignItems: 'center',
@@ -6,11 +6,16 @@ export interface AssetListRowProps {
6
6
  baseUrl?: string;
7
7
  /**
8
8
  * Present in picker mode - the whole row is then a button that selects the asset. A button
9
- * cannot hold another, so a selectable row renders no remove control.
9
+ * cannot hold another, so a selectable row renders no remove or restore control.
10
10
  */
11
11
  onSelect?: (asset: AssetRecord) => void;
12
- /** Present only where the viewer may remove the asset - renders the trash button. */
13
- onDelete?: (asset: AssetRecord) => void;
12
+ /** Present only where the viewer may remove the asset from the library. */
13
+ onRemove?: (asset: AssetRecord) => void;
14
+ /** Present only where the viewer may restore a removed (`hidden`) asset to the library. */
15
+ onRestore?: (asset: AssetRecord) => void;
14
16
  }
15
- /** One asset as a row in the list layout: thumbnail, name, dimensions, size and upload date. */
17
+ /**
18
+ * One asset as a row in the list layout: thumbnail, name, dimensions, size and upload date. A file
19
+ * removed from the library (`hidden`) is badged "Removed" and offers Restore instead of Remove.
20
+ */
16
21
  export declare const AssetListRow: React.FC<AssetListRowProps>;
@@ -1,13 +1,14 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from '@mantine/core';
4
- import { IconFileTypePdf, IconPhotoOff, IconTrash } from '@tabler/icons-react';
3
+ import { ActionIcon, Badge, Group, Text, Tooltip, UnstyledButton } from '@mantine/core';
4
+ import { IconEyeOff, IconFileTypePdf, IconPhotoOff, IconRestore } from '@tabler/icons-react';
5
+ import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
6
+ import { EDITOR_ACTIONS } from '../copy.js';
5
7
  import { formatRelativeTime } from '../relative-time.js';
6
8
  import { editorImageSrc } from './editor-image-src.js';
7
9
  import { formatBytes } from './format-bytes.js';
8
10
  import { useThumbnailFailure } from './useThumbnailFailure.js';
9
11
  const THUMBNAIL_SIZE = 40;
10
- const THUMBNAIL_WIDTH = 80;
11
12
  const rowStyle = {
12
13
  width: '100%',
13
14
  padding: 6,
@@ -25,17 +26,25 @@ const thumbnailBoxStyle = {
25
26
  background: 'var(--canopy-surface-muted)',
26
27
  overflow: 'hidden',
27
28
  };
28
- /** One asset as a row in the list layout: thumbnail, name, dimensions, size and upload date. */
29
- export const AssetListRow = ({ asset, baseUrl, onSelect, onDelete, }) => {
30
- const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
29
+ /**
30
+ * One asset as a row in the list layout: thumbnail, name, dimensions, size and upload date. A file
31
+ * removed from the library (`hidden`) is badged "Removed" and offers Restore instead of Remove.
32
+ */
33
+ export const AssetListRow = ({ asset, baseUrl, onSelect, onRemove, onRestore, }) => {
34
+ const removed = asset.hidden === true;
35
+ const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', {
36
+ width: EDITOR_THUMBNAIL_WIDTHS.listRow,
37
+ });
31
38
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
32
39
  const hasDimensions = asset.width !== undefined && asset.height !== undefined;
33
40
  const content = (_jsxs(Group, { gap: "sm", wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx("div", { style: thumbnailBoxStyle, children: asset.kind === 'pdf' ? (_jsx(IconFileTypePdf, { size: 24, stroke: 1.5, "aria-hidden": true })) : thumbnailFailed ? (_jsx(IconPhotoOff, { size: 20, stroke: 1.5, color: "var(--mantine-color-dimmed)", "data-testid": `asset-row-thumbnail-fallback-${asset.hash32}`, "aria-label": "Preview unavailable" })) : (
34
41
  // Decorative: the filename beside it already names the row.
35
- _jsx("img", { src: thumbnailSrc, alt: "", style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' }, onError: markThumbnailFailed })) }), _jsx(Tooltip, { multiline: true, w: 260, openDelay: 400, label: _jsxs(_Fragment, { children: [_jsx("div", { style: { overflowWrap: 'anywhere' }, children: asset.filename }), _jsxs("div", { style: { overflowWrap: 'anywhere' }, children: ["Slug: ", asset.slug] })] }), children: _jsx(Text, { size: "sm", fw: 500, truncate: true, style: { flex: 1, minWidth: 0 }, children: asset.filename }) }), _jsx(Text, { size: "xs", c: "dimmed", w: 84, ta: "right", visibleFrom: "sm", style: { flex: '0 0 auto' }, children: hasDimensions ? `${asset.width} × ${asset.height}` : '' }), _jsx(Text, { size: "xs", c: "dimmed", w: 52, ta: "right", visibleFrom: "sm", style: { flex: '0 0 auto' }, children: formatBytes(asset.size) }), _jsx(Tooltip, { label: new Date(asset.uploadedAt).toLocaleString(), children: _jsx(Text, { size: "xs", c: "dimmed", w: 72, ta: "right", style: { flex: '0 0 auto' }, children: formatRelativeTime(asset.uploadedAt) }) })] }));
36
- const removeButton = onDelete && (_jsx(Tooltip, { label: "Remove from library", children: _jsx(ActionIcon, { variant: "subtle", color: "red", "aria-label": `Remove ${asset.filename} from library`, "data-testid": `asset-row-delete-${asset.hash32}`, onClick: () => onDelete(asset), children: _jsx(IconTrash, { size: 16 }) }) }));
42
+ _jsx("img", { src: thumbnailSrc, alt: "", style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' }, onError: markThumbnailFailed })) }), _jsx(Tooltip, { multiline: true, w: 260, openDelay: 400, label: _jsxs(_Fragment, { children: [_jsx("div", { style: { overflowWrap: 'anywhere' }, children: asset.filename }), _jsxs("div", { style: { overflowWrap: 'anywhere' }, children: ["Slug: ", asset.slug] })] }), children: _jsx(Text, { size: "sm", fw: 500, truncate: true, style: { flex: 1, minWidth: 0 }, children: asset.filename }) }), removed && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", style: { flex: '0 0 auto' }, "data-testid": `asset-row-removed-${asset.hash32}`, children: "Removed" })), _jsx(Text, { size: "xs", c: "dimmed", w: 84, ta: "right", visibleFrom: "sm", style: { flex: '0 0 auto' }, children: hasDimensions ? `${asset.width} × ${asset.height}` : '' }), _jsx(Text, { size: "xs", c: "dimmed", w: 52, ta: "right", visibleFrom: "sm", style: { flex: '0 0 auto' }, children: formatBytes(asset.size) }), _jsx(Tooltip, { label: new Date(asset.uploadedAt).toLocaleString(), children: _jsx(Text, { size: "xs", c: "dimmed", w: 72, ta: "right", style: { flex: '0 0 auto' }, children: formatRelativeTime(asset.uploadedAt) }) })] }));
43
+ const action = removed
44
+ ? onRestore && (_jsx(Tooltip, { label: `${EDITOR_ACTIONS.restore} to library`, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": `${EDITOR_ACTIONS.restore} ${asset.filename} to library`, "data-testid": `asset-row-restore-${asset.hash32}`, onClick: () => onRestore(asset), children: _jsx(IconRestore, { size: 16 }) }) }))
45
+ : onRemove && (_jsx(Tooltip, { label: `${EDITOR_ACTIONS.remove} from library`, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": `${EDITOR_ACTIONS.remove} ${asset.filename} from library`, "data-testid": `asset-row-remove-${asset.hash32}`, onClick: () => onRemove(asset), children: _jsx(IconEyeOff, { size: 16 }) }) }));
37
46
  if (onSelect) {
38
47
  return (_jsx(UnstyledButton, { onClick: () => onSelect(asset), "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: content }));
39
48
  }
40
- return (_jsxs(Group, { gap: "xs", wrap: "nowrap", "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: [content, removeButton] }));
49
+ return (_jsxs(Group, { gap: "xs", wrap: "nowrap", "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: [content, action] }));
41
50
  };
@@ -5,20 +5,18 @@ export interface CropStepProps {
5
5
  onClose: () => void;
6
6
  /** Full-frame image src to crop - `assetUrl()` with no crop directive, so the user always crops the original frame. */
7
7
  imageSrc: string;
8
- /** Numeric aspect ratio (width/height) parsed from the field config's "W:H" string. */
9
- aspect: number;
10
- /** Existing crop rect, if re-cropping - seeds react-easy-crop's initial selection (best effort). */
8
+ /** Numeric aspect ratio (width/height) parsed from the field config's "W:H" string. Absent: the crop box is free-form. */
9
+ aspect?: number;
10
+ /** Existing crop rect, if re-cropping - the box it opens with. */
11
11
  initialCrop?: CropRect;
12
12
  onConfirm: (rect: CropRect) => void;
13
13
  }
14
14
  /**
15
- * react-easy-crop wrapped in a Modal for the ImageField crop step. Percentage
16
- * -> normalized-rect conversion is delegated to crop-math.ts's pure,
17
- * independently-unit-tested `cropAreaPercentToRect`.
15
+ * react-image-crop in a Modal for the ImageField crop step. Box -> normalized-rect
16
+ * conversion is delegated to crop-math.ts's pure, independently-unit-tested
17
+ * `cropAreaPercentToRect`.
18
18
  *
19
- * The pan/zoom/pending-selection state lives in `CropStepBody`, mounted only
20
- * while `opened` and keyed on `imageSrc` - so a fresh crop session always
21
- * starts from fresh state (React remounts on key change) without an effect
22
- * that would otherwise need to call `setState` synchronously to reset it.
19
+ * The box state lives in `CropStepBody`, mounted only while `opened` and keyed on `imageSrc`,
20
+ * so a fresh crop session always starts from a fresh box (React remounts on key change).
23
21
  */
24
22
  export declare const CropStep: React.FC<CropStepProps>;
@@ -1,41 +1,53 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
- import { Button, Group, Modal, Slider, Stack, Text } from '@mantine/core';
5
- import Cropper from 'react-easy-crop';
6
- import { cropAreaPercentToRect, cropRectToAreaPercent } from './crop-math.js';
7
- const MIN_ZOOM = 1;
8
- const MAX_ZOOM = 3;
9
- const ZOOM_STEP = 0.05;
4
+ import { Button, Group, Modal, Stack, Text } from '@mantine/core';
5
+ import ReactCrop from 'react-image-crop';
6
+ import { cropAreaPercentToRect, initialCropBox } from './crop-math.js';
7
+ const MIN_BOX_PX = 16;
8
+ const CORNER_LABEL = (corner) => `${corner} corner. Arrow keys resize the box.`;
9
+ const ARIA_LABELS = {
10
+ cropArea: 'Crop box. Arrow keys move it; hold Shift for larger steps.',
11
+ nwDragHandle: CORNER_LABEL('Top-left'),
12
+ neDragHandle: CORNER_LABEL('Top-right'),
13
+ seDragHandle: CORNER_LABEL('Bottom-right'),
14
+ swDragHandle: CORNER_LABEL('Bottom-left'),
15
+ nDragHandle: 'Top edge. Up and down arrow keys resize the box.',
16
+ sDragHandle: 'Bottom edge. Up and down arrow keys resize the box.',
17
+ eDragHandle: 'Right edge. Left and right arrow keys resize the box.',
18
+ wDragHandle: 'Left edge. Left and right arrow keys resize the box.',
19
+ };
20
+ const PREVIEW_MAX_HEIGHT = 360;
21
+ const toPercentCrop = (box) => ({
22
+ unit: '%',
23
+ ...box,
24
+ });
10
25
  /**
11
- * react-easy-crop wrapped in a Modal for the ImageField crop step. Percentage
12
- * -> normalized-rect conversion is delegated to crop-math.ts's pure,
13
- * independently-unit-tested `cropAreaPercentToRect`.
26
+ * react-image-crop in a Modal for the ImageField crop step. Box -> normalized-rect
27
+ * conversion is delegated to crop-math.ts's pure, independently-unit-tested
28
+ * `cropAreaPercentToRect`.
14
29
  *
15
- * The pan/zoom/pending-selection state lives in `CropStepBody`, mounted only
16
- * while `opened` and keyed on `imageSrc` - so a fresh crop session always
17
- * starts from fresh state (React remounts on key change) without an effect
18
- * that would otherwise need to call `setState` synchronously to reset it.
30
+ * The box state lives in `CropStepBody`, mounted only while `opened` and keyed on `imageSrc`,
31
+ * so a fresh crop session always starts from a fresh box (React remounts on key change).
19
32
  */
20
33
  export const CropStep = ({ opened, onClose, imageSrc, aspect, initialCrop, onConfirm, }) => {
21
34
  return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Crop image", size: "lg", overlayProps: { blur: 2 }, "data-testid": "crop-step-modal", children: opened && (_jsx(CropStepBody, { imageSrc: imageSrc, aspect: aspect, initialCrop: initialCrop, onConfirm: onConfirm, onClose: onClose }, imageSrc)) }));
22
35
  };
23
36
  const CropStepBody = ({ imageSrc, aspect, initialCrop, onConfirm, onClose, }) => {
24
- const [crop, setCrop] = useState({ x: 0, y: 0 });
25
- const [zoom, setZoom] = useState(1);
26
- const [pendingArea, setPendingArea] = useState(null);
27
- const handleConfirm = () => {
28
- if (!pendingArea)
29
- return;
30
- const rect = cropAreaPercentToRect(pendingArea);
31
- if (rect)
32
- onConfirm(rect);
37
+ const [box, setBox] = useState(undefined);
38
+ const [loadFailed, setLoadFailed] = useState(false);
39
+ // The box can't be seeded until the image's displayed size is known, so Apply stays off until then.
40
+ const rect = box ? cropAreaPercentToRect(box) : null;
41
+ const handleImageLoad = (event) => {
42
+ const { width, height } = event.currentTarget;
43
+ setBox(toPercentCrop(initialCropBox({ imageWidth: width, imageHeight: height, aspect, existing: initialCrop })));
33
44
  };
34
- const initialCroppedAreaPercentages = initialCrop ? cropRectToAreaPercent(initialCrop) : undefined;
35
45
  return (_jsxs(Stack, { gap: "md", children: [_jsx("div", { style: {
36
- position: 'relative',
37
- width: '100%',
38
- height: 360,
46
+ display: 'flex',
47
+ justifyContent: 'center',
39
48
  background: 'var(--mantine-color-black)',
40
- }, children: _jsx(Cropper, { image: imageSrc, crop: crop, zoom: zoom, aspect: aspect, initialCroppedAreaPercentages: initialCroppedAreaPercentages, onCropChange: setCrop, onZoomChange: setZoom, onCropComplete: (croppedArea) => setPendingArea(croppedArea) }) }), _jsxs(Group, { gap: "sm", align: "center", children: [_jsx(Text, { size: "sm", w: 50, children: "Zoom" }), _jsx(Slider, { style: { flex: 1 }, min: MIN_ZOOM, max: MAX_ZOOM, step: ZOOM_STEP, value: zoom, onChange: setZoom, label: (value) => `${value.toFixed(2)}x` })] }), _jsxs(Group, { justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleConfirm, disabled: !pendingArea, "data-testid": "crop-step-apply", children: "Apply crop" })] })] }));
49
+ '--rc-focus-color': 'var(--canopy-focus-ring)',
50
+ }, children: _jsx(ReactCrop, { crop: box, aspect: aspect, minWidth: MIN_BOX_PX, minHeight: MIN_BOX_PX, keepSelection: true, ariaLabels: ARIA_LABELS, onChange: (_pixels, percent) => setBox(percent), children: _jsx("img", { src: imageSrc, alt: "", style: { maxHeight: PREVIEW_MAX_HEIGHT, maxWidth: '100%', display: 'block' }, onLoad: handleImageLoad, onError: () => setLoadFailed(true) }) }) }), loadFailed && (_jsx(Text, { size: "sm", c: "var(--canopy-danger-text)", role: "alert", children: "This image couldn't be loaded, so it can't be cropped." })), _jsx(Text, { size: "sm", c: "dimmed", children: aspect === undefined
51
+ ? 'Drag the box to move it, or its edges and corners to resize it. With a keyboard, arrow keys move it; Shift takes larger steps.'
52
+ : 'Drag the box to move it, or a corner to resize it. Its shape is fixed. With a keyboard, arrow keys move it; Shift takes larger steps.' }), _jsxs(Group, { justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: () => rect && onConfirm(rect), disabled: !rect, "data-testid": "crop-step-apply", children: "Apply crop" })] })] }));
41
53
  };
@@ -1,8 +1,9 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Drawer, Modal, Text, Title } from '@mantine/core';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Modal } from '@mantine/core';
4
4
  import { MediaLibraryBody } from './MediaLibraryBody.js';
5
5
  import { focusReturnProps } from '../utils/focus-return.js';
6
+ import { OverlayDrawer } from '../shell/OverlayHeader.js';
6
7
  /**
7
8
  * One component, two presentations, sharing the same grid/list/upload/search/
8
9
  * delete core (MediaLibraryBody) - see
@@ -12,11 +13,11 @@ export const MediaLibrary = ({ opened, onClose, mode, onSelect, returnFocusTo, }
12
13
  if (mode === 'picker') {
13
14
  return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Media library", size: "lg", overlayProps: { blur: 2 }, "data-testid": "media-library-picker", children: _jsx(MediaLibraryBody, { opened: opened, mode: "picker", onSelect: onSelect }) }));
14
15
  }
15
- return (_jsx(Drawer, { opened: opened, onClose: onClose, position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Media library" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Upload and manage images and PDFs" })] }), padding: "md", size: 700,
16
+ return (_jsx(OverlayDrawer, { opened: opened, onClose: onClose, position: "right", title: "Media library", size: 700,
16
17
  // The body fills the drawer and the library's own ScrollArea scrolls, so the toolbar stays put
17
18
  // and the list's IntersectionObserver sees a real clipping viewport.
18
19
  styles: {
19
20
  content: { display: 'flex', flexDirection: 'column' },
20
21
  body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' },
21
- }, overlayProps: { blur: 2 }, "data-testid": "media-library-manage", ...focusReturnProps(returnFocusTo), children: _jsx(MediaLibraryBody, { opened: opened, mode: "manage" }) }));
22
+ }, "data-testid": "media-library-manage", ...focusReturnProps(returnFocusTo), children: _jsx(MediaLibraryBody, { opened: opened, mode: "manage" }) }));
22
23
  };
@@ -8,7 +8,7 @@ export interface MediaLibraryBodyProps {
8
8
  onSelect?: (asset: AssetRecord) => void;
9
9
  }
10
10
  /**
11
- * Shared grid/list/upload/search/delete core for both MediaLibrary
11
+ * Shared grid/list/upload/search/remove core for both MediaLibrary
12
12
  * presentations (Drawer "manage" mode, Modal "picker" mode) - see
13
13
  * MediaLibrary.tsx for the two wrappers.
14
14
  */