canopycms 0.1.0 → 0.2.0-int.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -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';
@@ -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,18 +1,28 @@
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_ACTIONS } from '../copy.js';
5
6
  import { formatRelativeTime } from '../relative-time.js';
6
7
  import { editorImageSrc } from './editor-image-src.js';
7
8
  import { CARD_THUMBNAIL_HEIGHT } from './media-library-view.js';
8
9
  import { useThumbnailFailure } from './useThumbnailFailure.js';
9
10
  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
27
  const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
18
28
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
@@ -31,10 +41,13 @@ export const AssetCard = ({ asset, baseUrl, onSelect, onDelete, density = 'grid'
31
41
  // native <button> Enter/Space activation.
32
42
  role: onSelect ? 'button' : undefined, tabIndex: onSelect ? 0 : undefined, onKeyDown: onSelect ? handleKeyDown : undefined,
33
43
  // 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) => {
44
+ 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) => {
45
+ event.stopPropagation();
46
+ onRemove(asset);
47
+ }, 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
48
  event.stopPropagation();
36
- onDelete(asset);
37
- }, children: _jsx(IconTrash, { size: 14 }) }) })), _jsx(Card.Section, { children: asset.kind === 'pdf' ? (_jsx("div", { style: {
49
+ onRestore(asset);
50
+ }, 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
51
  height: thumbnailHeight,
39
52
  display: 'flex',
40
53
  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,7 +1,8 @@
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_ACTIONS } from '../copy.js';
5
6
  import { formatRelativeTime } from '../relative-time.js';
6
7
  import { editorImageSrc } from './editor-image-src.js';
7
8
  import { formatBytes } from './format-bytes.js';
@@ -25,17 +26,23 @@ 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, }) => {
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;
30
35
  const thumbnailSrc = editorImageSrc(asset.src, baseUrl ?? '', { width: THUMBNAIL_WIDTH });
31
36
  const [thumbnailFailed, markThumbnailFailed] = useThumbnailFailure(thumbnailSrc);
32
37
  const hasDimensions = asset.width !== undefined && asset.height !== undefined;
33
38
  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
39
  // 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 }) }) }));
40
+ _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) }) })] }));
41
+ const action = removed
42
+ ? 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 }) }) }))
43
+ : 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
44
  if (onSelect) {
38
45
  return (_jsx(UnstyledButton, { onClick: () => onSelect(asset), "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: content }));
39
46
  }
40
- return (_jsxs(Group, { gap: "xs", wrap: "nowrap", "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: [content, removeButton] }));
47
+ return (_jsxs(Group, { gap: "xs", wrap: "nowrap", "data-testid": `asset-row-${asset.hash32}`, style: rowStyle, children: [content, action] }));
41
48
  };
@@ -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
  */