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.
- package/CHANGELOG.md +144 -41
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +98 -37
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/finalize.js +14 -1
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +19 -4
- package/dist/assets/store-s3.js +56 -13
- package/dist/assets/types.d.ts +26 -2
- package/dist/cli/cli.js +112 -34
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +58 -36
- package/dist/editor/EntryNavigator.d.ts +8 -1
- package/dist/editor/EntryNavigator.js +31 -16
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +11 -2
- package/dist/editor/PreviewFrame.js +20 -4
- package/dist/editor/PreviewPane.d.ts +15 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.js +6 -2
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +29 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +20 -7
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +14 -7
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +16 -0
- package/dist/editor/preview-bridge.js +12 -3
- package/dist/editor/preview-path.d.ts +11 -4
- package/dist/editor/preview-path.js +62 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.js +2 -0
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
|
|
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)
|
|
10
|
-
|
|
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,
|
|
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,
|
|
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: [
|
|
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
|
-
|
|
37
|
-
}, children: _jsx(
|
|
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
|
|
13
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
|
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,
|
|
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
|
|
3
|
-
import {
|
|
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(
|
|
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
|
-
},
|
|
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/
|
|
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
|
*/
|