@fgv/ts-app-shell 5.1.0-5 → 5.1.0-7
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.
|
@@ -27,31 +27,123 @@
|
|
|
27
27
|
*
|
|
28
28
|
* @packageDocumentation
|
|
29
29
|
*/
|
|
30
|
-
import React, { useCallback, useState } from 'react';
|
|
30
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
31
|
+
import { StarIcon as StarIconOutline } from '@heroicons/react/24/outline';
|
|
32
|
+
import { StarIcon as StarIconSolid, ExclamationTriangleIcon, BuildingLibraryIcon, ShieldCheckIcon, ShieldExclamationIcon, ArrowDownTrayIcon, PencilSquareIcon, ArrowsPointingInIcon, TrashIcon, FolderPlusIcon, ArchiveBoxArrowDownIcon, FolderOpenIcon, ArrowUpTrayIcon, PlusIcon, ChevronRightIcon } from '@heroicons/react/20/solid';
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Context Menu (internal)
|
|
35
|
+
// ============================================================================
|
|
36
|
+
const LONG_PRESS_MS = 500;
|
|
37
|
+
function CollectionContextMenu(props) {
|
|
38
|
+
const { menu, isHidden, onHide, onShow, onClose } = props;
|
|
39
|
+
const ref = useRef(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function handleClickOutside(e) {
|
|
42
|
+
if (ref.current && !ref.current.contains(e.target)) {
|
|
43
|
+
onClose();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
47
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
48
|
+
}, [onClose]);
|
|
49
|
+
const action = isHidden ? onShow : onHide;
|
|
50
|
+
if (!action) {
|
|
51
|
+
return React.createElement(React.Fragment, null);
|
|
52
|
+
}
|
|
53
|
+
return (React.createElement("div", { ref: ref, className: "fixed z-50 bg-surface-raised border border-border rounded shadow-lg py-1 min-w-[140px]", style: { left: menu.x, top: menu.y } },
|
|
54
|
+
React.createElement("button", { className: "w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors", onClick: () => {
|
|
55
|
+
action(menu.collectionId);
|
|
56
|
+
onClose();
|
|
57
|
+
} }, isHidden ? 'Show collection' : 'Hide collection')));
|
|
58
|
+
}
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Long-press hook (internal)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
function useLongPress(onLongPress) {
|
|
63
|
+
const timerRef = useRef(undefined);
|
|
64
|
+
const firedRef = useRef(false);
|
|
65
|
+
const onTouchStart = useCallback((e) => {
|
|
66
|
+
firedRef.current = false;
|
|
67
|
+
timerRef.current = setTimeout(() => {
|
|
68
|
+
firedRef.current = true;
|
|
69
|
+
onLongPress(e);
|
|
70
|
+
}, LONG_PRESS_MS);
|
|
71
|
+
}, [onLongPress]);
|
|
72
|
+
const cancel = useCallback(() => {
|
|
73
|
+
if (timerRef.current !== undefined) {
|
|
74
|
+
clearTimeout(timerRef.current);
|
|
75
|
+
timerRef.current = undefined;
|
|
76
|
+
}
|
|
77
|
+
}, []);
|
|
78
|
+
return { onTouchStart, onTouchEnd: cancel, onTouchMove: cancel };
|
|
79
|
+
}
|
|
31
80
|
// ============================================================================
|
|
32
81
|
// CollectionRow (internal)
|
|
33
82
|
// ============================================================================
|
|
34
83
|
function CollectionRow(props) {
|
|
35
|
-
var _a
|
|
36
|
-
const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge } = props;
|
|
84
|
+
var _a;
|
|
85
|
+
const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge, borderColorClass, onContextMenu, isHiddenRow } = props;
|
|
37
86
|
const displayName = (_a = collection.name) !== null && _a !== void 0 ? _a : collection.id;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
(collection.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
const handleContextMenu = useCallback((e) => {
|
|
88
|
+
if (onContextMenu) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
onContextMenu(collection.id, e.clientX, e.clientY);
|
|
91
|
+
}
|
|
92
|
+
}, [onContextMenu, collection.id]);
|
|
93
|
+
const handleLongPress = useCallback((e) => {
|
|
94
|
+
if (onContextMenu && e.touches.length > 0) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
const touch = e.touches[0];
|
|
97
|
+
onContextMenu(collection.id, touch.clientX, touch.clientY);
|
|
98
|
+
}
|
|
99
|
+
}, [onContextMenu, collection.id]);
|
|
100
|
+
const longPress = useLongPress(handleLongPress);
|
|
101
|
+
return (React.createElement("div", Object.assign({ onClick: () => onToggleVisibility(collection.id), onContextMenu: handleContextMenu }, longPress, { className: `flex flex-col px-3 py-2.5 text-sm transition-colors hover:bg-hover cursor-pointer ${isHiddenRow
|
|
102
|
+
? 'text-muted opacity-30 line-through'
|
|
103
|
+
: collection.isVisible
|
|
104
|
+
? 'text-secondary'
|
|
105
|
+
: 'text-muted opacity-50'} ${borderColorClass ? `border-l-4 ${borderColorClass}` : ''}`, role: "button", "aria-pressed": collection.isVisible, title: collection.sourceName ? `Source: ${collection.sourceName}` : displayName }),
|
|
106
|
+
React.createElement("div", { className: "flex items-center gap-1.5" },
|
|
107
|
+
onSetDefault && collection.isMutable && (React.createElement("button", { onClick: (e) => {
|
|
108
|
+
e.stopPropagation();
|
|
109
|
+
onSetDefault(collection.id);
|
|
110
|
+
}, className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
|
|
111
|
+
? 'Default collection for new items'
|
|
112
|
+
: 'Set as default collection for new items', "aria-label": collection.isDefault ? `${displayName} is default` : `Set ${displayName} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? (React.createElement(StarIconSolid, { className: "w-4 h-4" })) : (React.createElement(StarIconOutline, { className: "w-4 h-4" })))),
|
|
113
|
+
collection.hasConflict && (React.createElement("span", { className: "shrink-0 text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` },
|
|
114
|
+
React.createElement(ExclamationTriangleIcon, { className: "w-4 h-4" }))),
|
|
115
|
+
!collection.isMutable && (React.createElement("span", { className: "shrink-0 text-muted", title: "Built-in collection (read-only)" },
|
|
116
|
+
React.createElement(BuildingLibraryIcon, { className: "w-4 h-4" }))),
|
|
117
|
+
collection.isProtected &&
|
|
118
|
+
(collection.isUnlocked || !onUnlock ? (React.createElement("span", { className: `shrink-0 ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, collection.isUnlocked ? (React.createElement(ShieldCheckIcon, { className: "w-4 h-4" })) : (React.createElement(ShieldExclamationIcon, { className: "w-4 h-4" })))) : (React.createElement("button", { onClick: (e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
onUnlock(collection.id);
|
|
121
|
+
}, className: "shrink-0 text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${displayName}` },
|
|
122
|
+
React.createElement(ShieldExclamationIcon, { className: "w-4 h-4" })))),
|
|
123
|
+
React.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
|
|
124
|
+
React.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount)),
|
|
125
|
+
React.createElement("div", { className: "flex items-center gap-1 mt-1 ml-[24px]" },
|
|
126
|
+
collection.isMutable && onExport && (React.createElement("button", { onClick: (e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
onExport(collection.id);
|
|
129
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` },
|
|
130
|
+
React.createElement(ArrowDownTrayIcon, { className: "w-4 h-4" }))),
|
|
131
|
+
collection.isMutable && onRename && (React.createElement("button", { onClick: (e) => {
|
|
132
|
+
e.stopPropagation();
|
|
133
|
+
onRename(collection.id);
|
|
134
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` },
|
|
135
|
+
React.createElement(PencilSquareIcon, { className: "w-4 h-4" }))),
|
|
136
|
+
collection.isMutable && onMerge && (React.createElement("button", { onClick: (e) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
onMerge(collection.id);
|
|
139
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` },
|
|
140
|
+
React.createElement(ArrowsPointingInIcon, { className: "w-4 h-4" }))),
|
|
141
|
+
collection.isMutable && onDelete && (React.createElement("button", { onClick: (e) => {
|
|
142
|
+
e.stopPropagation();
|
|
143
|
+
onDelete(collection.id);
|
|
144
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` },
|
|
145
|
+
React.createElement(TrashIcon, { className: "w-4 h-4" }))),
|
|
146
|
+
!collection.isMutable && React.createElement("span", { className: "text-xs text-muted" }, "(built-in)"))));
|
|
55
147
|
}
|
|
56
148
|
// ============================================================================
|
|
57
149
|
// CollectionSection
|
|
@@ -65,43 +157,65 @@ function CollectionRow(props) {
|
|
|
65
157
|
* @public
|
|
66
158
|
*/
|
|
67
159
|
export function CollectionSection(props) {
|
|
68
|
-
|
|
160
|
+
var _a;
|
|
161
|
+
const { collections, onToggleVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, onHideCollection, onShowCollection, defaultCollapsed = false, sourceColorMap, sourceColorFallback } = props;
|
|
69
162
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
const ids = allVisible
|
|
77
|
-
? collections.map((c) => c.id)
|
|
78
|
-
: collections.filter((c) => !c.isVisible).map((c) => c.id);
|
|
79
|
-
ids.forEach((id) => onToggleVisibility(id));
|
|
80
|
-
}
|
|
81
|
-
}, [onToggleAllVisibility, onToggleVisibility, allVisible, collections]);
|
|
163
|
+
const [hiddenExpanded, setHiddenExpanded] = useState(false);
|
|
164
|
+
const [contextMenu, setContextMenu] = useState(undefined);
|
|
165
|
+
const visibleCollections = collections.filter((c) => !c.isHidden);
|
|
166
|
+
const hiddenCollections = collections.filter((c) => c.isHidden);
|
|
82
167
|
const handleToggleCollapse = useCallback(() => {
|
|
83
168
|
setCollapsed((prev) => !prev);
|
|
84
169
|
}, []);
|
|
170
|
+
const handleToggleHiddenExpanded = useCallback(() => {
|
|
171
|
+
setHiddenExpanded((prev) => !prev);
|
|
172
|
+
}, []);
|
|
173
|
+
const handleOpenContextMenu = useCallback((collectionId, x, y) => {
|
|
174
|
+
setContextMenu({ collectionId, x, y });
|
|
175
|
+
}, []);
|
|
176
|
+
const handleCloseContextMenu = useCallback(() => {
|
|
177
|
+
setContextMenu(undefined);
|
|
178
|
+
}, []);
|
|
179
|
+
const getBorderColor = useCallback((sourceName) => {
|
|
180
|
+
if (!sourceColorMap)
|
|
181
|
+
return undefined;
|
|
182
|
+
if (sourceName && sourceName in sourceColorMap) {
|
|
183
|
+
return sourceColorMap[sourceName];
|
|
184
|
+
}
|
|
185
|
+
return sourceColorFallback;
|
|
186
|
+
}, [sourceColorMap, sourceColorFallback]);
|
|
187
|
+
const contextMenuCollection = contextMenu
|
|
188
|
+
? collections.find((c) => c.id === contextMenu.collectionId)
|
|
189
|
+
: undefined;
|
|
85
190
|
return (React.createElement("div", { className: "flex flex-col border-t border-border mt-1" },
|
|
86
191
|
React.createElement("div", { className: "flex items-center justify-between px-3 py-1.5" },
|
|
87
192
|
React.createElement("button", { onClick: handleToggleCollapse, className: "flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-secondary transition-colors" },
|
|
88
|
-
React.createElement(
|
|
193
|
+
React.createElement(ChevronRightIcon, { className: `w-3 h-3 transition-transform ${collapsed ? '' : 'rotate-90'}` }),
|
|
89
194
|
"Collections",
|
|
90
195
|
React.createElement("span", { className: "text-muted normal-case font-normal" },
|
|
91
196
|
"(",
|
|
92
197
|
collections.length,
|
|
93
198
|
")")),
|
|
94
|
-
collections.length > 1 && (React.createElement("button", { onClick: handleToggleAllVisibility, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: allVisible ? 'Hide all collections' : 'Show all collections', "aria-label": allVisible ? 'Hide all collections' : 'Show all collections' }, allVisible ? '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}' : '\u{1F441}')),
|
|
95
199
|
React.createElement("div", { className: "flex items-center gap-1" },
|
|
96
200
|
onAddDirectory && (React.createElement("button", { onClick: onAddDirectory, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Add directory", "aria-label": "Add directory" },
|
|
97
|
-
"
|
|
98
|
-
'\uD83D\uDCC1')),
|
|
201
|
+
React.createElement(FolderPlusIcon, { className: "w-4 h-4" }))),
|
|
99
202
|
onExportAllAsZip && (React.createElement("button", { onClick: onExportAllAsZip, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Export all mutable collections as zip", "aria-label": "Export all as zip" },
|
|
100
|
-
"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
onImportCollection && (React.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
|
|
104
|
-
|
|
105
|
-
|
|
203
|
+
React.createElement(ArchiveBoxArrowDownIcon, { className: "w-4 h-4" }))),
|
|
204
|
+
onOpenCollectionFromFile && (React.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" },
|
|
205
|
+
React.createElement(FolderOpenIcon, { className: "w-4 h-4" }))),
|
|
206
|
+
onImportCollection && (React.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
|
|
207
|
+
React.createElement(ArrowUpTrayIcon, { className: "w-4 h-4" }))),
|
|
208
|
+
onCreateCollection && (React.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" },
|
|
209
|
+
React.createElement(PlusIcon, { className: "w-4 h-4" }))))),
|
|
210
|
+
!collapsed && (React.createElement("div", { className: "flex flex-col" },
|
|
211
|
+
visibleCollections.length === 0 && hiddenCollections.length === 0 ? (React.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (visibleCollections.map((collection) => (React.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onHideCollection ? handleOpenContextMenu : undefined })))),
|
|
212
|
+
hiddenCollections.length > 0 && (React.createElement(React.Fragment, null,
|
|
213
|
+
React.createElement("button", { onClick: handleToggleHiddenExpanded, className: "flex items-center gap-1 px-3 py-1 text-xs text-muted hover:text-secondary transition-colors" },
|
|
214
|
+
React.createElement(ChevronRightIcon, { className: `w-3 h-3 transition-transform ${hiddenExpanded ? 'rotate-90' : ''}` }),
|
|
215
|
+
hiddenCollections.length,
|
|
216
|
+
" hidden"),
|
|
217
|
+
hiddenExpanded &&
|
|
218
|
+
hiddenCollections.map((collection) => (React.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onShowCollection ? handleOpenContextMenu : undefined, isHiddenRow: true }))))))),
|
|
219
|
+
contextMenu && contextMenuCollection && (React.createElement(CollectionContextMenu, { menu: contextMenu, isHidden: (_a = contextMenuCollection.isHidden) !== null && _a !== void 0 ? _a : false, onHide: onHideCollection, onShow: onShowCollection, onClose: handleCloseContextMenu }))));
|
|
106
220
|
}
|
|
107
221
|
//# sourceMappingURL=CollectionSection.js.map
|
|
@@ -33,18 +33,25 @@ export interface ICollectionRowItem {
|
|
|
33
33
|
* in another storage root. When true, a repair action should be offered.
|
|
34
34
|
*/
|
|
35
35
|
readonly hasConflict?: boolean;
|
|
36
|
+
/** Whether this collection is explicitly hidden by the user */
|
|
37
|
+
readonly isHidden?: boolean;
|
|
38
|
+
/** The name of the storage source this collection was loaded from */
|
|
39
|
+
readonly sourceName?: string;
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
38
42
|
* Props for the CollectionSection component.
|
|
39
43
|
* @public
|
|
40
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* Maps source names to Tailwind border color classes for the left-border indicator.
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export type SourceColorMap = Readonly<Record<string, string>>;
|
|
41
50
|
export interface ICollectionSectionProps {
|
|
42
51
|
/** Collection items to display */
|
|
43
52
|
readonly collections: ReadonlyArray<ICollectionRowItem>;
|
|
44
53
|
/** Callback when visibility is toggled for a collection */
|
|
45
54
|
readonly onToggleVisibility: (collectionId: string) => void;
|
|
46
|
-
/** Callback when all-visible toggle is clicked; receives true to show all, false to hide all */
|
|
47
|
-
readonly onToggleAllVisibility?: (showAll: boolean) => void;
|
|
48
55
|
/** Callback when "Add Directory" is clicked */
|
|
49
56
|
readonly onAddDirectory?: () => void;
|
|
50
57
|
/** Callback when "New Collection" is clicked */
|
|
@@ -67,8 +74,16 @@ export interface ICollectionSectionProps {
|
|
|
67
74
|
readonly onRenameCollection?: (collectionId: string) => void;
|
|
68
75
|
/** Callback when merge is clicked for a mutable collection */
|
|
69
76
|
readonly onMergeCollection?: (collectionId: string) => void;
|
|
77
|
+
/** Callback when hide is selected from the context menu */
|
|
78
|
+
readonly onHideCollection?: (collectionId: string) => void;
|
|
79
|
+
/** Callback when show (unhide) is selected from the context menu */
|
|
80
|
+
readonly onShowCollection?: (collectionId: string) => void;
|
|
70
81
|
/** Whether the section starts collapsed */
|
|
71
82
|
readonly defaultCollapsed?: boolean;
|
|
83
|
+
/** Maps sourceName values to Tailwind border-l color classes */
|
|
84
|
+
readonly sourceColorMap?: SourceColorMap;
|
|
85
|
+
/** Fallback border-l color class when sourceName is not in the map */
|
|
86
|
+
readonly sourceColorFallback?: string;
|
|
72
87
|
}
|
|
73
88
|
/**
|
|
74
89
|
* Collapsible sidebar section for managing collections.
|
|
@@ -64,30 +64,122 @@ exports.CollectionSection = CollectionSection;
|
|
|
64
64
|
* @packageDocumentation
|
|
65
65
|
*/
|
|
66
66
|
const react_1 = __importStar(require("react"));
|
|
67
|
+
const outline_1 = require("@heroicons/react/24/outline");
|
|
68
|
+
const solid_1 = require("@heroicons/react/20/solid");
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Context Menu (internal)
|
|
71
|
+
// ============================================================================
|
|
72
|
+
const LONG_PRESS_MS = 500;
|
|
73
|
+
function CollectionContextMenu(props) {
|
|
74
|
+
const { menu, isHidden, onHide, onShow, onClose } = props;
|
|
75
|
+
const ref = (0, react_1.useRef)(null);
|
|
76
|
+
(0, react_1.useEffect)(() => {
|
|
77
|
+
function handleClickOutside(e) {
|
|
78
|
+
if (ref.current && !ref.current.contains(e.target)) {
|
|
79
|
+
onClose();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
83
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
84
|
+
}, [onClose]);
|
|
85
|
+
const action = isHidden ? onShow : onHide;
|
|
86
|
+
if (!action) {
|
|
87
|
+
return react_1.default.createElement(react_1.default.Fragment, null);
|
|
88
|
+
}
|
|
89
|
+
return (react_1.default.createElement("div", { ref: ref, className: "fixed z-50 bg-surface-raised border border-border rounded shadow-lg py-1 min-w-[140px]", style: { left: menu.x, top: menu.y } },
|
|
90
|
+
react_1.default.createElement("button", { className: "w-full text-left px-3 py-1.5 text-sm text-secondary hover:bg-hover transition-colors", onClick: () => {
|
|
91
|
+
action(menu.collectionId);
|
|
92
|
+
onClose();
|
|
93
|
+
} }, isHidden ? 'Show collection' : 'Hide collection')));
|
|
94
|
+
}
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Long-press hook (internal)
|
|
97
|
+
// ============================================================================
|
|
98
|
+
function useLongPress(onLongPress) {
|
|
99
|
+
const timerRef = (0, react_1.useRef)(undefined);
|
|
100
|
+
const firedRef = (0, react_1.useRef)(false);
|
|
101
|
+
const onTouchStart = (0, react_1.useCallback)((e) => {
|
|
102
|
+
firedRef.current = false;
|
|
103
|
+
timerRef.current = setTimeout(() => {
|
|
104
|
+
firedRef.current = true;
|
|
105
|
+
onLongPress(e);
|
|
106
|
+
}, LONG_PRESS_MS);
|
|
107
|
+
}, [onLongPress]);
|
|
108
|
+
const cancel = (0, react_1.useCallback)(() => {
|
|
109
|
+
if (timerRef.current !== undefined) {
|
|
110
|
+
clearTimeout(timerRef.current);
|
|
111
|
+
timerRef.current = undefined;
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
return { onTouchStart, onTouchEnd: cancel, onTouchMove: cancel };
|
|
115
|
+
}
|
|
67
116
|
// ============================================================================
|
|
68
117
|
// CollectionRow (internal)
|
|
69
118
|
// ============================================================================
|
|
70
119
|
function CollectionRow(props) {
|
|
71
|
-
var _a
|
|
72
|
-
const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge } = props;
|
|
120
|
+
var _a;
|
|
121
|
+
const { collection, onToggleVisibility, onSetDefault, onDelete, onExport, onUnlock, onRename, onMerge, borderColorClass, onContextMenu, isHiddenRow } = props;
|
|
73
122
|
const displayName = (_a = collection.name) !== null && _a !== void 0 ? _a : collection.id;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
(collection.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
const handleContextMenu = (0, react_1.useCallback)((e) => {
|
|
124
|
+
if (onContextMenu) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
onContextMenu(collection.id, e.clientX, e.clientY);
|
|
127
|
+
}
|
|
128
|
+
}, [onContextMenu, collection.id]);
|
|
129
|
+
const handleLongPress = (0, react_1.useCallback)((e) => {
|
|
130
|
+
if (onContextMenu && e.touches.length > 0) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
const touch = e.touches[0];
|
|
133
|
+
onContextMenu(collection.id, touch.clientX, touch.clientY);
|
|
134
|
+
}
|
|
135
|
+
}, [onContextMenu, collection.id]);
|
|
136
|
+
const longPress = useLongPress(handleLongPress);
|
|
137
|
+
return (react_1.default.createElement("div", Object.assign({ onClick: () => onToggleVisibility(collection.id), onContextMenu: handleContextMenu }, longPress, { className: `flex flex-col px-3 py-2.5 text-sm transition-colors hover:bg-hover cursor-pointer ${isHiddenRow
|
|
138
|
+
? 'text-muted opacity-30 line-through'
|
|
139
|
+
: collection.isVisible
|
|
140
|
+
? 'text-secondary'
|
|
141
|
+
: 'text-muted opacity-50'} ${borderColorClass ? `border-l-4 ${borderColorClass}` : ''}`, role: "button", "aria-pressed": collection.isVisible, title: collection.sourceName ? `Source: ${collection.sourceName}` : displayName }),
|
|
142
|
+
react_1.default.createElement("div", { className: "flex items-center gap-1.5" },
|
|
143
|
+
onSetDefault && collection.isMutable && (react_1.default.createElement("button", { onClick: (e) => {
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
onSetDefault(collection.id);
|
|
146
|
+
}, className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${collection.isDefault ? 'text-star hover:text-star' : 'text-faint hover:text-star'}`, title: collection.isDefault
|
|
147
|
+
? 'Default collection for new items'
|
|
148
|
+
: 'Set as default collection for new items', "aria-label": collection.isDefault ? `${displayName} is default` : `Set ${displayName} as default`, "aria-pressed": collection.isDefault }, collection.isDefault ? (react_1.default.createElement(solid_1.StarIcon, { className: "w-4 h-4" })) : (react_1.default.createElement(outline_1.StarIcon, { className: "w-4 h-4" })))),
|
|
149
|
+
collection.hasConflict && (react_1.default.createElement("span", { className: "shrink-0 text-status-warning-strong cursor-default", title: "An encrypted copy of this collection from another storage root has the same ID. Go to Settings \u2192 Storage to resolve the conflict.", "aria-label": `Conflict: encrypted shadow for ${displayName}` },
|
|
150
|
+
react_1.default.createElement(solid_1.ExclamationTriangleIcon, { className: "w-4 h-4" }))),
|
|
151
|
+
!collection.isMutable && (react_1.default.createElement("span", { className: "shrink-0 text-muted", title: "Built-in collection (read-only)" },
|
|
152
|
+
react_1.default.createElement(solid_1.BuildingLibraryIcon, { className: "w-4 h-4" }))),
|
|
153
|
+
collection.isProtected &&
|
|
154
|
+
(collection.isUnlocked || !onUnlock ? (react_1.default.createElement("span", { className: `shrink-0 ${collection.isUnlocked ? 'text-status-success-icon' : 'text-muted'}`, title: collection.isUnlocked ? 'Protected (unlocked)' : 'Protected (locked)' }, collection.isUnlocked ? (react_1.default.createElement(solid_1.ShieldCheckIcon, { className: "w-4 h-4" })) : (react_1.default.createElement(solid_1.ShieldExclamationIcon, { className: "w-4 h-4" })))) : (react_1.default.createElement("button", { onClick: (e) => {
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
onUnlock(collection.id);
|
|
157
|
+
}, className: "shrink-0 text-muted hover:text-star transition-colors", title: "Click to unlock", "aria-label": `Unlock ${displayName}` },
|
|
158
|
+
react_1.default.createElement(solid_1.ShieldExclamationIcon, { className: "w-4 h-4" })))),
|
|
159
|
+
react_1.default.createElement("span", { className: "flex-1 truncate", title: displayName }, displayName),
|
|
160
|
+
react_1.default.createElement("span", { className: "shrink-0 text-xs text-muted" }, collection.itemCount)),
|
|
161
|
+
react_1.default.createElement("div", { className: "flex items-center gap-1 mt-1 ml-[24px]" },
|
|
162
|
+
collection.isMutable && onExport && (react_1.default.createElement("button", { onClick: (e) => {
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
onExport(collection.id);
|
|
165
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Export ${displayName}`, "aria-label": `Export ${displayName}` },
|
|
166
|
+
react_1.default.createElement(solid_1.ArrowDownTrayIcon, { className: "w-4 h-4" }))),
|
|
167
|
+
collection.isMutable && onRename && (react_1.default.createElement("button", { onClick: (e) => {
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
onRename(collection.id);
|
|
170
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Rename ${displayName}`, "aria-label": `Rename ${displayName}` },
|
|
171
|
+
react_1.default.createElement(solid_1.PencilSquareIcon, { className: "w-4 h-4" }))),
|
|
172
|
+
collection.isMutable && onMerge && (react_1.default.createElement("button", { onClick: (e) => {
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
onMerge(collection.id);
|
|
175
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-brand-accent transition-colors", title: `Merge ${displayName} into another collection`, "aria-label": `Merge ${displayName}` },
|
|
176
|
+
react_1.default.createElement(solid_1.ArrowsPointingInIcon, { className: "w-4 h-4" }))),
|
|
177
|
+
collection.isMutable && onDelete && (react_1.default.createElement("button", { onClick: (e) => {
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
onDelete(collection.id);
|
|
180
|
+
}, className: "shrink-0 w-5 h-5 flex items-center justify-center text-faint hover:text-status-error-icon transition-colors", title: `Remove ${displayName}`, "aria-label": `Remove ${displayName}` },
|
|
181
|
+
react_1.default.createElement(solid_1.TrashIcon, { className: "w-4 h-4" }))),
|
|
182
|
+
!collection.isMutable && react_1.default.createElement("span", { className: "text-xs text-muted" }, "(built-in)"))));
|
|
91
183
|
}
|
|
92
184
|
// ============================================================================
|
|
93
185
|
// CollectionSection
|
|
@@ -101,43 +193,65 @@ function CollectionRow(props) {
|
|
|
101
193
|
* @public
|
|
102
194
|
*/
|
|
103
195
|
function CollectionSection(props) {
|
|
104
|
-
|
|
196
|
+
var _a;
|
|
197
|
+
const { collections, onToggleVisibility, onAddDirectory, onCreateCollection, onDeleteCollection, onSetDefaultCollection, onExportCollection, onExportAllAsZip, onImportCollection, onOpenCollectionFromFile, onUnlockCollection, onRenameCollection, onMergeCollection, onHideCollection, onShowCollection, defaultCollapsed = false, sourceColorMap, sourceColorFallback } = props;
|
|
105
198
|
const [collapsed, setCollapsed] = (0, react_1.useState)(defaultCollapsed);
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
const ids = allVisible
|
|
113
|
-
? collections.map((c) => c.id)
|
|
114
|
-
: collections.filter((c) => !c.isVisible).map((c) => c.id);
|
|
115
|
-
ids.forEach((id) => onToggleVisibility(id));
|
|
116
|
-
}
|
|
117
|
-
}, [onToggleAllVisibility, onToggleVisibility, allVisible, collections]);
|
|
199
|
+
const [hiddenExpanded, setHiddenExpanded] = (0, react_1.useState)(false);
|
|
200
|
+
const [contextMenu, setContextMenu] = (0, react_1.useState)(undefined);
|
|
201
|
+
const visibleCollections = collections.filter((c) => !c.isHidden);
|
|
202
|
+
const hiddenCollections = collections.filter((c) => c.isHidden);
|
|
118
203
|
const handleToggleCollapse = (0, react_1.useCallback)(() => {
|
|
119
204
|
setCollapsed((prev) => !prev);
|
|
120
205
|
}, []);
|
|
206
|
+
const handleToggleHiddenExpanded = (0, react_1.useCallback)(() => {
|
|
207
|
+
setHiddenExpanded((prev) => !prev);
|
|
208
|
+
}, []);
|
|
209
|
+
const handleOpenContextMenu = (0, react_1.useCallback)((collectionId, x, y) => {
|
|
210
|
+
setContextMenu({ collectionId, x, y });
|
|
211
|
+
}, []);
|
|
212
|
+
const handleCloseContextMenu = (0, react_1.useCallback)(() => {
|
|
213
|
+
setContextMenu(undefined);
|
|
214
|
+
}, []);
|
|
215
|
+
const getBorderColor = (0, react_1.useCallback)((sourceName) => {
|
|
216
|
+
if (!sourceColorMap)
|
|
217
|
+
return undefined;
|
|
218
|
+
if (sourceName && sourceName in sourceColorMap) {
|
|
219
|
+
return sourceColorMap[sourceName];
|
|
220
|
+
}
|
|
221
|
+
return sourceColorFallback;
|
|
222
|
+
}, [sourceColorMap, sourceColorFallback]);
|
|
223
|
+
const contextMenuCollection = contextMenu
|
|
224
|
+
? collections.find((c) => c.id === contextMenu.collectionId)
|
|
225
|
+
: undefined;
|
|
121
226
|
return (react_1.default.createElement("div", { className: "flex flex-col border-t border-border mt-1" },
|
|
122
227
|
react_1.default.createElement("div", { className: "flex items-center justify-between px-3 py-1.5" },
|
|
123
228
|
react_1.default.createElement("button", { onClick: handleToggleCollapse, className: "flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-secondary transition-colors" },
|
|
124
|
-
react_1.default.createElement(
|
|
229
|
+
react_1.default.createElement(solid_1.ChevronRightIcon, { className: `w-3 h-3 transition-transform ${collapsed ? '' : 'rotate-90'}` }),
|
|
125
230
|
"Collections",
|
|
126
231
|
react_1.default.createElement("span", { className: "text-muted normal-case font-normal" },
|
|
127
232
|
"(",
|
|
128
233
|
collections.length,
|
|
129
234
|
")")),
|
|
130
|
-
collections.length > 1 && (react_1.default.createElement("button", { onClick: handleToggleAllVisibility, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: allVisible ? 'Hide all collections' : 'Show all collections', "aria-label": allVisible ? 'Hide all collections' : 'Show all collections' }, allVisible ? '\u{1F441}\u{FE0F}\u{200D}\u{1F5E8}\u{FE0F}' : '\u{1F441}')),
|
|
131
235
|
react_1.default.createElement("div", { className: "flex items-center gap-1" },
|
|
132
236
|
onAddDirectory && (react_1.default.createElement("button", { onClick: onAddDirectory, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Add directory", "aria-label": "Add directory" },
|
|
133
|
-
"
|
|
134
|
-
'\uD83D\uDCC1')),
|
|
237
|
+
react_1.default.createElement(solid_1.FolderPlusIcon, { className: "w-4 h-4" }))),
|
|
135
238
|
onExportAllAsZip && (react_1.default.createElement("button", { onClick: onExportAllAsZip, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Export all mutable collections as zip", "aria-label": "Export all as zip" },
|
|
136
|
-
"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
onImportCollection && (react_1.default.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
|
|
140
|
-
|
|
141
|
-
|
|
239
|
+
react_1.default.createElement(solid_1.ArchiveBoxArrowDownIcon, { className: "w-4 h-4" }))),
|
|
240
|
+
onOpenCollectionFromFile && (react_1.default.createElement("button", { onClick: onOpenCollectionFromFile, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Open collection file for in-place editing", "aria-label": "Open collection from file" },
|
|
241
|
+
react_1.default.createElement(solid_1.FolderOpenIcon, { className: "w-4 h-4" }))),
|
|
242
|
+
onImportCollection && (react_1.default.createElement("button", { onClick: onImportCollection, className: "text-xs text-muted hover:text-brand-accent transition-colors px-1", title: "Import collection from file (in-memory)", "aria-label": "Import collection from file" },
|
|
243
|
+
react_1.default.createElement(solid_1.ArrowUpTrayIcon, { className: "w-4 h-4" }))),
|
|
244
|
+
onCreateCollection && (react_1.default.createElement("button", { onClick: onCreateCollection, "data-testid": "sidebar-new-collection-button", className: "text-muted hover:text-brand-accent transition-colors px-1", title: "New collection", "aria-label": "New collection" },
|
|
245
|
+
react_1.default.createElement(solid_1.PlusIcon, { className: "w-4 h-4" }))))),
|
|
246
|
+
!collapsed && (react_1.default.createElement("div", { className: "flex flex-col" },
|
|
247
|
+
visibleCollections.length === 0 && hiddenCollections.length === 0 ? (react_1.default.createElement("div", { className: "px-3 py-2 text-xs text-muted" }, "No collections")) : (visibleCollections.map((collection) => (react_1.default.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onHideCollection ? handleOpenContextMenu : undefined })))),
|
|
248
|
+
hiddenCollections.length > 0 && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
249
|
+
react_1.default.createElement("button", { onClick: handleToggleHiddenExpanded, className: "flex items-center gap-1 px-3 py-1 text-xs text-muted hover:text-secondary transition-colors" },
|
|
250
|
+
react_1.default.createElement(solid_1.ChevronRightIcon, { className: `w-3 h-3 transition-transform ${hiddenExpanded ? 'rotate-90' : ''}` }),
|
|
251
|
+
hiddenCollections.length,
|
|
252
|
+
" hidden"),
|
|
253
|
+
hiddenExpanded &&
|
|
254
|
+
hiddenCollections.map((collection) => (react_1.default.createElement(CollectionRow, { key: collection.id, collection: collection, onToggleVisibility: onToggleVisibility, onSetDefault: onSetDefaultCollection, onDelete: onDeleteCollection, onExport: onExportCollection, onUnlock: onUnlockCollection, onRename: onRenameCollection, onMerge: onMergeCollection, borderColorClass: getBorderColor(collection.sourceName), onContextMenu: onShowCollection ? handleOpenContextMenu : undefined, isHiddenRow: true }))))))),
|
|
255
|
+
contextMenu && contextMenuCollection && (react_1.default.createElement(CollectionContextMenu, { menu: contextMenu, isHidden: (_a = contextMenuCollection.isHidden) !== null && _a !== void 0 ? _a : false, onHide: onHideCollection, onShow: onShowCollection, onClose: handleCloseContextMenu }))));
|
|
142
256
|
}
|
|
143
257
|
//# sourceMappingURL=CollectionSection.js.map
|
|
@@ -8,5 +8,5 @@ export { FilterRow, type IFilterRowProps, type IFilterOption } from './FilterRow
|
|
|
8
8
|
export { FilterBar, type IFilterBarProps } from './FilterBar';
|
|
9
9
|
export { EntityList, type IEntityListProps, type IEntityDescriptor, type IEntityStatus, type IEmptyStateConfig, type IEmptyStateAction } from './EntityList';
|
|
10
10
|
export { GroupedEntityList, type IGroupedEntityListProps, type IEntityGroupDescriptor } from './GroupedEntityList';
|
|
11
|
-
export { CollectionSection, type ICollectionSectionProps, type ICollectionRowItem } from './CollectionSection';
|
|
11
|
+
export { CollectionSection, type ICollectionSectionProps, type ICollectionRowItem, type SourceColorMap } from './CollectionSection';
|
|
12
12
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fgv/ts-app-shell",
|
|
3
|
-
"version": "5.1.0-
|
|
3
|
+
"version": "5.1.0-7",
|
|
4
4
|
"description": "Shared React UI primitives for application shells: column cascade, sidebar, toast/log messages, command palette, keybinding registry",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@heroicons/react": "~2.2.0",
|
|
19
19
|
"tslib": "^2.8.1",
|
|
20
|
-
"@fgv/ts-
|
|
21
|
-
"@fgv/ts-
|
|
20
|
+
"@fgv/ts-utils": "5.1.0-7",
|
|
21
|
+
"@fgv/ts-extras": "5.1.0-7"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"react": ">=18.0.0",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@rushstack/heft-jest-plugin": "1.2.6",
|
|
52
52
|
"@testing-library/dom": "^10.4.0",
|
|
53
53
|
"@rushstack/heft-node-rig": "2.11.27",
|
|
54
|
-
"@fgv/
|
|
55
|
-
"@fgv/
|
|
54
|
+
"@fgv/heft-dual-rig": "5.1.0-7",
|
|
55
|
+
"@fgv/ts-utils-jest": "5.1.0-7"
|
|
56
56
|
},
|
|
57
57
|
"exports": {
|
|
58
58
|
".": {
|