@noya-app/noya-file-explorer 0.0.2
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/.eslintrc.js +11 -0
- package/.turbo/turbo-build.log +24 -0
- package/CHANGELOG.md +14 -0
- package/README.md +3 -0
- package/dist/index.css +1811 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +23279 -0
- package/dist/index.d.ts +23279 -0
- package/dist/index.js +1854 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1857 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/MediaCollection.tsx +839 -0
- package/src/__tests__/deleteMediaItems.test.ts +125 -0
- package/src/__tests__/getDepthMap.test.ts +84 -0
- package/src/__tests__/getParentDirectories.test.ts +68 -0
- package/src/__tests__/getVisibleItems.test.ts +184 -0
- package/src/__tests__/moveMediaInsideFolder.test.ts +348 -0
- package/src/__tests__/movePathsIntoTarget.test.ts +229 -0
- package/src/__tests__/moveUpAFolder.test.ts +179 -0
- package/src/__tests__/updateExpandedMap.test.ts +157 -0
- package/src/__tests__/validateMediaItemRename.test.ts +200 -0
- package/src/index.css +1 -0
- package/src/index.ts +4 -0
- package/src/utils/files.ts +274 -0
- package/src/utils/mediaItemTree.ts +110 -0
- package/tsconfig.json +3 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/* eslint-disable @shopify/prefer-early-return */
|
|
2
|
+
import {
|
|
3
|
+
ActionMenu,
|
|
4
|
+
CollectionProps,
|
|
5
|
+
CollectionRef,
|
|
6
|
+
CollectionRenderActionProps,
|
|
7
|
+
CollectionThumbnailProps,
|
|
8
|
+
CollectionViewType,
|
|
9
|
+
createSectionedMenu,
|
|
10
|
+
cssVars,
|
|
11
|
+
FileExplorerCollection,
|
|
12
|
+
FileExplorerDetail,
|
|
13
|
+
FileExplorerEmptyState,
|
|
14
|
+
FileExplorerLayout,
|
|
15
|
+
FileExplorerUploadButton,
|
|
16
|
+
ListView,
|
|
17
|
+
MediaThumbnail,
|
|
18
|
+
} from "@noya-app/noya-designsystem";
|
|
19
|
+
import {
|
|
20
|
+
DownloadIcon,
|
|
21
|
+
FolderIcon,
|
|
22
|
+
InputIcon,
|
|
23
|
+
OpenInNewWindowIcon,
|
|
24
|
+
ResetIcon,
|
|
25
|
+
TrashIcon,
|
|
26
|
+
UpdateIcon,
|
|
27
|
+
UploadIcon,
|
|
28
|
+
} from "@noya-app/noya-icons";
|
|
29
|
+
import {
|
|
30
|
+
MultiplayerPatchMetadata,
|
|
31
|
+
useAsset,
|
|
32
|
+
useAssetManager,
|
|
33
|
+
useAssets,
|
|
34
|
+
} from "@noya-app/noya-multiplayer-react";
|
|
35
|
+
import { MediaItem, MediaMap } from "@noya-app/noya-schemas";
|
|
36
|
+
import { groupBy, isDeepEqual } from "@noya-app/noya-utils";
|
|
37
|
+
import {
|
|
38
|
+
downloadUrl,
|
|
39
|
+
memoGeneric,
|
|
40
|
+
useControlledOrUncontrolled,
|
|
41
|
+
} from "@noya-app/react-utils";
|
|
42
|
+
import { fileOpen } from "browser-fs-access";
|
|
43
|
+
import {
|
|
44
|
+
forwardRef,
|
|
45
|
+
memo,
|
|
46
|
+
ReactNode,
|
|
47
|
+
useCallback,
|
|
48
|
+
useEffect,
|
|
49
|
+
useImperativeHandle,
|
|
50
|
+
useMemo,
|
|
51
|
+
useRef,
|
|
52
|
+
useState,
|
|
53
|
+
} from "react";
|
|
54
|
+
import {
|
|
55
|
+
createMediaAsset,
|
|
56
|
+
createMediaFile,
|
|
57
|
+
createMediaFolder,
|
|
58
|
+
createMediaItemTree,
|
|
59
|
+
PLACEHOLDER_ITEM_NAME,
|
|
60
|
+
rootMediaItem,
|
|
61
|
+
rootMediaItemName,
|
|
62
|
+
} from "./utils/mediaItemTree";
|
|
63
|
+
|
|
64
|
+
import { path } from "imfs";
|
|
65
|
+
import React from "react";
|
|
66
|
+
import {
|
|
67
|
+
deleteMediaItems,
|
|
68
|
+
ExpandedMap,
|
|
69
|
+
FileKindFilter,
|
|
70
|
+
getDepthMap,
|
|
71
|
+
getVisibleItems,
|
|
72
|
+
moveMediaInsideFolder,
|
|
73
|
+
moveUpAFolder,
|
|
74
|
+
updateExpandedMap,
|
|
75
|
+
validateMediaItemRename,
|
|
76
|
+
} from "./utils/files";
|
|
77
|
+
|
|
78
|
+
const MediaThumbnailInternal = memoGeneric(
|
|
79
|
+
({ item, selected }: CollectionThumbnailProps<MediaItem>) => {
|
|
80
|
+
const asset = useAsset(item.kind === "asset" ? item.assetId : undefined);
|
|
81
|
+
const isRoot = item.id === rootMediaItem.id;
|
|
82
|
+
const isFolder = item.kind === "folder";
|
|
83
|
+
return (
|
|
84
|
+
<MediaThumbnail
|
|
85
|
+
contentType={asset?.contentType}
|
|
86
|
+
iconName={isRoot ? "HomeIcon" : isFolder ? "FolderIcon" : undefined}
|
|
87
|
+
url={asset?.url}
|
|
88
|
+
selected={selected}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
type MenuAction =
|
|
95
|
+
| "preview"
|
|
96
|
+
| "download"
|
|
97
|
+
| "upload"
|
|
98
|
+
| "rename"
|
|
99
|
+
| "replace"
|
|
100
|
+
| "delete"
|
|
101
|
+
| "move"
|
|
102
|
+
| "addFolder";
|
|
103
|
+
|
|
104
|
+
export type MediaCollectionRef = {
|
|
105
|
+
upload: (selectedId: string) => void;
|
|
106
|
+
delete: (selectedIds: string[]) => void;
|
|
107
|
+
download: (selectedItems: MediaItem[]) => void;
|
|
108
|
+
rename: (selectedItemId: string) => void;
|
|
109
|
+
addFolder: (currentFolderId: string) => void;
|
|
110
|
+
addFile: (currentFolderId?: string) => void;
|
|
111
|
+
moveUpAFolder: (selectedIds: string[]) => void;
|
|
112
|
+
replace: (selectedItem: MediaItem) => void;
|
|
113
|
+
preview: (selectedItems: MediaItem[]) => void;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type MediaCollectionProps = {
|
|
117
|
+
onSelectionChange?: (
|
|
118
|
+
selectedIds: string[],
|
|
119
|
+
event?: ListView.ClickInfo
|
|
120
|
+
) => void;
|
|
121
|
+
selectedIds?: string[];
|
|
122
|
+
media: MediaMap;
|
|
123
|
+
setMedia: (
|
|
124
|
+
metadata: Partial<MultiplayerPatchMetadata>,
|
|
125
|
+
media: MediaMap
|
|
126
|
+
) => void;
|
|
127
|
+
/** @default "list" */
|
|
128
|
+
viewType?: CollectionViewType;
|
|
129
|
+
/**
|
|
130
|
+
* Whether to show assets or directories or all media items
|
|
131
|
+
*
|
|
132
|
+
* @default "all"
|
|
133
|
+
* */
|
|
134
|
+
fileKindFilter?: FileKindFilter;
|
|
135
|
+
/**
|
|
136
|
+
* Whether to show the root item
|
|
137
|
+
*
|
|
138
|
+
* @default false
|
|
139
|
+
* */
|
|
140
|
+
showRootItem?: boolean;
|
|
141
|
+
/** Whether to expand all directories by default */
|
|
142
|
+
initialExpanded?: ExpandedMap;
|
|
143
|
+
/**
|
|
144
|
+
* Callback for when an item is double-clicked
|
|
145
|
+
*/
|
|
146
|
+
onDoubleClickItem?: (mediaItemId: string) => void;
|
|
147
|
+
/**
|
|
148
|
+
* If provided, only show items that are descendants of this folder
|
|
149
|
+
*/
|
|
150
|
+
rootItemId?: string;
|
|
151
|
+
title?: ReactNode;
|
|
152
|
+
right?: ReactNode;
|
|
153
|
+
className?: string;
|
|
154
|
+
showUploadButton?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* If true, show all descendants of all directories within a collection
|
|
157
|
+
* @default true
|
|
158
|
+
* */
|
|
159
|
+
showAllDescendants?: boolean;
|
|
160
|
+
renderAction?: Exclude<
|
|
161
|
+
CollectionProps<MediaItem, MenuAction>["renderAction"],
|
|
162
|
+
"menu"
|
|
163
|
+
>;
|
|
164
|
+
/** @default false */
|
|
165
|
+
sortable?: boolean;
|
|
166
|
+
} & Pick<
|
|
167
|
+
CollectionProps<MediaItem, MenuAction>,
|
|
168
|
+
"size" | "expandable" | "renamable" | "scrollable" | "renderEmptyState"
|
|
169
|
+
>;
|
|
170
|
+
|
|
171
|
+
export const MediaCollection = memo(
|
|
172
|
+
forwardRef<MediaCollectionRef, MediaCollectionProps>(function MediaCollection(
|
|
173
|
+
{
|
|
174
|
+
onSelectionChange,
|
|
175
|
+
selectedIds: selectedIdsProp,
|
|
176
|
+
media,
|
|
177
|
+
setMedia,
|
|
178
|
+
viewType = "list",
|
|
179
|
+
fileKindFilter = "all",
|
|
180
|
+
showRootItem = false,
|
|
181
|
+
initialExpanded,
|
|
182
|
+
expandable = true,
|
|
183
|
+
renamable = true,
|
|
184
|
+
onDoubleClickItem,
|
|
185
|
+
rootItemId = rootMediaItem.id,
|
|
186
|
+
title,
|
|
187
|
+
size = "medium",
|
|
188
|
+
right,
|
|
189
|
+
renderAction: renderActionProp,
|
|
190
|
+
className,
|
|
191
|
+
showUploadButton = true,
|
|
192
|
+
showAllDescendants = true,
|
|
193
|
+
scrollable = false,
|
|
194
|
+
sortable = false,
|
|
195
|
+
renderEmptyState,
|
|
196
|
+
},
|
|
197
|
+
ref
|
|
198
|
+
) {
|
|
199
|
+
const tree = useMemo(() => createMediaItemTree(media), [media]);
|
|
200
|
+
const [tempItem, setTempItem] = useState<[string, MediaItem] | undefined>(
|
|
201
|
+
undefined
|
|
202
|
+
);
|
|
203
|
+
const treeWithTempItem = useMemo(
|
|
204
|
+
() =>
|
|
205
|
+
createMediaItemTree({
|
|
206
|
+
...media,
|
|
207
|
+
...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
|
|
208
|
+
}),
|
|
209
|
+
[media, tempItem]
|
|
210
|
+
);
|
|
211
|
+
const [selectedIds, setSelectedIds] = useControlledOrUncontrolled<string[]>(
|
|
212
|
+
{
|
|
213
|
+
defaultValue: [],
|
|
214
|
+
value: selectedIdsProp,
|
|
215
|
+
onChange: onSelectionChange,
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
const assetManager = useAssetManager();
|
|
219
|
+
const assets = useAssets();
|
|
220
|
+
const [expandedMap, setExpandedMap] = useState<ExpandedMap>({});
|
|
221
|
+
const visibleItems = useMemo(
|
|
222
|
+
() =>
|
|
223
|
+
getVisibleItems({
|
|
224
|
+
expandedMap,
|
|
225
|
+
fileKindFilter: fileKindFilter,
|
|
226
|
+
rootItemId,
|
|
227
|
+
tree: treeWithTempItem,
|
|
228
|
+
showAllDescendants,
|
|
229
|
+
showRootItem,
|
|
230
|
+
}),
|
|
231
|
+
[
|
|
232
|
+
expandedMap,
|
|
233
|
+
fileKindFilter,
|
|
234
|
+
rootItemId,
|
|
235
|
+
treeWithTempItem,
|
|
236
|
+
showAllDescendants,
|
|
237
|
+
showRootItem,
|
|
238
|
+
]
|
|
239
|
+
);
|
|
240
|
+
const depthMap = useMemo(
|
|
241
|
+
() => getDepthMap(rootMediaItem, treeWithTempItem, showAllDescendants),
|
|
242
|
+
[treeWithTempItem, showAllDescendants]
|
|
243
|
+
);
|
|
244
|
+
const collectionRef = useRef<CollectionRef>(null);
|
|
245
|
+
const selectedMediaItems = useMemo(
|
|
246
|
+
() =>
|
|
247
|
+
treeWithTempItem.mediaItemsWithRoot.filter((item) =>
|
|
248
|
+
selectedIds.includes(item.id)
|
|
249
|
+
),
|
|
250
|
+
[treeWithTempItem, selectedIds]
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const groupedItems = groupBy(selectedMediaItems, (item) => item.kind);
|
|
254
|
+
const selectedAssetItems = groupedItems.asset ?? [];
|
|
255
|
+
const selectedFolderItems = groupedItems.folder ?? [];
|
|
256
|
+
|
|
257
|
+
const singleItemSelected = selectedMediaItems.length === 1;
|
|
258
|
+
const onlyAssetsSelected =
|
|
259
|
+
selectedAssetItems.length > 0 &&
|
|
260
|
+
selectedAssetItems.length === selectedMediaItems.length;
|
|
261
|
+
const onlyFoldersSelected =
|
|
262
|
+
selectedFolderItems.length > 0 &&
|
|
263
|
+
selectedFolderItems.length === selectedMediaItems.length;
|
|
264
|
+
const onlySingleFolderSelected =
|
|
265
|
+
onlyFoldersSelected && selectedFolderItems.length === 1;
|
|
266
|
+
const onlySingleAssetSelected =
|
|
267
|
+
onlyAssetsSelected && selectedAssetItems.length === 1;
|
|
268
|
+
const rootSelected = selectedIds.includes(rootMediaItem.id);
|
|
269
|
+
const sameParentSelected = selectedMediaItems.every((item) => {
|
|
270
|
+
const itemPath = tree.idToPathMap.get(item.id);
|
|
271
|
+
const firstSelectedPath = tree.idToPathMap.get(selectedIds[0]);
|
|
272
|
+
if (!itemPath || !firstSelectedPath) return false;
|
|
273
|
+
return itemPath.startsWith(path.dirname(firstSelectedPath));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Convert size prop to grid size if in grid view
|
|
277
|
+
const gridSize = useMemo(() => {
|
|
278
|
+
if (viewType === "grid") {
|
|
279
|
+
return size;
|
|
280
|
+
}
|
|
281
|
+
return "medium";
|
|
282
|
+
}, [viewType, size]);
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (initialExpanded) {
|
|
286
|
+
setExpandedMap(initialExpanded);
|
|
287
|
+
}
|
|
288
|
+
}, [initialExpanded]);
|
|
289
|
+
|
|
290
|
+
// Handle expansion state
|
|
291
|
+
const handleExpanded = useCallback(
|
|
292
|
+
(item: MediaItem) => {
|
|
293
|
+
if (!expandable) return undefined;
|
|
294
|
+
if (item.kind !== "folder") return undefined;
|
|
295
|
+
if (item.id === rootMediaItem.id) return undefined;
|
|
296
|
+
return expandedMap[item.id] ?? false;
|
|
297
|
+
},
|
|
298
|
+
[expandedMap, expandable]
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const handleDelete = useCallback(
|
|
302
|
+
(selectedIds: string[]) => {
|
|
303
|
+
const newMedia = deleteMediaItems({
|
|
304
|
+
selectedIds,
|
|
305
|
+
media,
|
|
306
|
+
tree,
|
|
307
|
+
});
|
|
308
|
+
setSelectedIds([rootMediaItem.id]);
|
|
309
|
+
setMedia({ name: "Delete items", timestamp: Date.now() }, newMedia);
|
|
310
|
+
},
|
|
311
|
+
[media, setMedia, setSelectedIds, tree]
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const onRename = useCallback(
|
|
315
|
+
(selectedItem: MediaItem, newName: string) => {
|
|
316
|
+
if (!renamable) return;
|
|
317
|
+
const selectedItemPath = treeWithTempItem.idToPathMap.get(
|
|
318
|
+
selectedItem.id
|
|
319
|
+
);
|
|
320
|
+
if (!selectedItemPath) return;
|
|
321
|
+
const mediaWithTempItem = {
|
|
322
|
+
...media,
|
|
323
|
+
...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
|
|
324
|
+
};
|
|
325
|
+
const renameIsValid = validateMediaItemRename({
|
|
326
|
+
basename: newName,
|
|
327
|
+
selectedItemPath,
|
|
328
|
+
media: mediaWithTempItem,
|
|
329
|
+
});
|
|
330
|
+
if (!renameIsValid) {
|
|
331
|
+
setTempItem(undefined);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const mediaClone = { ...media };
|
|
335
|
+
delete mediaClone[selectedItemPath];
|
|
336
|
+
setMedia(
|
|
337
|
+
{ name: "Rename media item", timestamp: Date.now() },
|
|
338
|
+
{
|
|
339
|
+
...mediaClone,
|
|
340
|
+
[path.join(path.dirname(selectedItemPath), newName)]: selectedItem,
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
setTempItem(undefined);
|
|
344
|
+
},
|
|
345
|
+
[media, renamable, setMedia, tempItem, treeWithTempItem.idToPathMap]
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const handleAddFolder = useCallback(
|
|
349
|
+
(currentFolderId: string) => {
|
|
350
|
+
const newFolder = createMediaFolder();
|
|
351
|
+
const currentFolderPath = tree.idToPathMap.get(currentFolderId);
|
|
352
|
+
if (!currentFolderPath) return;
|
|
353
|
+
setTempItem([
|
|
354
|
+
path.join(currentFolderPath, PLACEHOLDER_ITEM_NAME),
|
|
355
|
+
newFolder,
|
|
356
|
+
]);
|
|
357
|
+
setTimeout(() => {
|
|
358
|
+
collectionRef.current?.editName(newFolder.id);
|
|
359
|
+
}, 50);
|
|
360
|
+
},
|
|
361
|
+
[tree]
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const handleAddFile = useCallback(
|
|
365
|
+
(currentFolderId?: string) => {
|
|
366
|
+
const newFile = createMediaFile({
|
|
367
|
+
content: "",
|
|
368
|
+
encoding: "utf-8",
|
|
369
|
+
});
|
|
370
|
+
const currentFolderPath = tree.idToPathMap.get(
|
|
371
|
+
currentFolderId ?? rootMediaItem.id
|
|
372
|
+
);
|
|
373
|
+
if (!currentFolderPath) return;
|
|
374
|
+
setTempItem([
|
|
375
|
+
path.join(currentFolderPath, PLACEHOLDER_ITEM_NAME),
|
|
376
|
+
newFile,
|
|
377
|
+
]);
|
|
378
|
+
setTimeout(() => {
|
|
379
|
+
collectionRef.current?.editName(newFile.id);
|
|
380
|
+
}, 50);
|
|
381
|
+
},
|
|
382
|
+
[tree.idToPathMap]
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const handleMoveUpAFolder = useCallback(
|
|
386
|
+
(selectedIds: string[]) => {
|
|
387
|
+
const newMedia = moveUpAFolder({
|
|
388
|
+
tree,
|
|
389
|
+
media,
|
|
390
|
+
selectedIds,
|
|
391
|
+
});
|
|
392
|
+
if (!newMedia) return;
|
|
393
|
+
setMedia({ name: "Move items", timestamp: Date.now() }, newMedia);
|
|
394
|
+
},
|
|
395
|
+
[media, tree, setMedia]
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const handleUpload = useCallback(
|
|
399
|
+
async (selectedId: string) => {
|
|
400
|
+
try {
|
|
401
|
+
const files = await fileOpen({ multiple: true });
|
|
402
|
+
if (!files || !Array.isArray(files) || files.length === 0) return;
|
|
403
|
+
|
|
404
|
+
const parentPath = tree.idToPathMap.get(selectedId);
|
|
405
|
+
if (!parentPath) return;
|
|
406
|
+
|
|
407
|
+
// Create assets in parallel for better performance
|
|
408
|
+
const uploadPromises = files.map(async (file) => {
|
|
409
|
+
const asset = await assetManager.create(file);
|
|
410
|
+
const assetPath = path.join(parentPath, path.basename(file.name));
|
|
411
|
+
return {
|
|
412
|
+
assetPath,
|
|
413
|
+
asset: createMediaAsset({ assetId: asset.id }),
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const newMediaMap = await Promise.all(uploadPromises);
|
|
418
|
+
|
|
419
|
+
setMedia(
|
|
420
|
+
{ name: "Add media items", timestamp: Date.now() },
|
|
421
|
+
{
|
|
422
|
+
...media,
|
|
423
|
+
...Object.fromEntries(
|
|
424
|
+
newMediaMap.map(({ assetPath, asset }) => [assetPath, asset])
|
|
425
|
+
),
|
|
426
|
+
}
|
|
427
|
+
);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error("Failed to upload files:", error);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
[tree.idToPathMap, setMedia, media, assetManager]
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const handleDownload = useCallback(
|
|
436
|
+
async (selectedItems: MediaItem[]) => {
|
|
437
|
+
const downloadPromises = selectedItems
|
|
438
|
+
.filter((item) => item.kind === "asset")
|
|
439
|
+
.map(async (item) => {
|
|
440
|
+
const asset = assets.find((a) => a.id === item.assetId);
|
|
441
|
+
if (!asset?.url) return;
|
|
442
|
+
return downloadUrl(asset.url, tree.getNameForId(item.id));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await Promise.all(downloadPromises);
|
|
446
|
+
},
|
|
447
|
+
[assets, tree]
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const handlePreview = useCallback(
|
|
451
|
+
async (selectedItems: MediaItem[]) => {
|
|
452
|
+
const previewPromises = selectedItems
|
|
453
|
+
.filter((item) => item.kind === "asset")
|
|
454
|
+
.map(async (item) => {
|
|
455
|
+
const asset = assets.find((a) => a.id === item.assetId);
|
|
456
|
+
if (!asset?.url) return;
|
|
457
|
+
return window?.open(asset.url, "_blank");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await Promise.all(previewPromises);
|
|
461
|
+
},
|
|
462
|
+
[assets]
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const handleReplace = useCallback(
|
|
466
|
+
async (selectedItem: MediaItem) => {
|
|
467
|
+
try {
|
|
468
|
+
const file = await fileOpen();
|
|
469
|
+
if (!file) return;
|
|
470
|
+
// Create the asset
|
|
471
|
+
const asset = await assetManager.create(file);
|
|
472
|
+
const oldFile = selectedItem;
|
|
473
|
+
const oldFilePath = tree.idToPathMap.get(oldFile.id);
|
|
474
|
+
if (!oldFilePath || oldFile.kind !== "asset") return;
|
|
475
|
+
setMedia(
|
|
476
|
+
{ name: "Replace media file", timestamp: Date.now() },
|
|
477
|
+
{
|
|
478
|
+
...media,
|
|
479
|
+
[oldFilePath]: createMediaAsset({
|
|
480
|
+
...oldFile,
|
|
481
|
+
assetId: asset.id,
|
|
482
|
+
}),
|
|
483
|
+
}
|
|
484
|
+
);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error("Failed to upload file:", error);
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
[media, setMedia, assetManager, tree]
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const handleRename = useCallback(
|
|
493
|
+
(selectedItemId: string) => {
|
|
494
|
+
collectionRef.current?.editName(selectedItemId);
|
|
495
|
+
},
|
|
496
|
+
[collectionRef]
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const handleMoveMediaInsideFolder = useCallback(
|
|
500
|
+
(sourceItem: MediaItem, targetItem: MediaItem) => {
|
|
501
|
+
const sourceItemPath = tree.idToPathMap.get(sourceItem.id);
|
|
502
|
+
const targetItemPath = tree.idToPathMap.get(targetItem.id);
|
|
503
|
+
if (!sourceItemPath || !targetItemPath) return;
|
|
504
|
+
const newMedia = moveMediaInsideFolder({
|
|
505
|
+
sourceItemIds: [sourceItem.id],
|
|
506
|
+
targetItemId: targetItem.id,
|
|
507
|
+
media,
|
|
508
|
+
tree,
|
|
509
|
+
});
|
|
510
|
+
setMedia(
|
|
511
|
+
{
|
|
512
|
+
name: "Move media file inside folder",
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
},
|
|
515
|
+
newMedia
|
|
516
|
+
);
|
|
517
|
+
},
|
|
518
|
+
[media, setMedia, tree]
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const assetContextMenuItems = useMemo(() => {
|
|
522
|
+
return createSectionedMenu<MenuAction>(
|
|
523
|
+
[
|
|
524
|
+
!rootSelected &&
|
|
525
|
+
singleItemSelected && {
|
|
526
|
+
title: "Rename",
|
|
527
|
+
value: "rename",
|
|
528
|
+
icon: <InputIcon />,
|
|
529
|
+
},
|
|
530
|
+
onlySingleAssetSelected && {
|
|
531
|
+
title: "Replace",
|
|
532
|
+
value: "replace",
|
|
533
|
+
icon: <UpdateIcon />,
|
|
534
|
+
},
|
|
535
|
+
!rootSelected && {
|
|
536
|
+
title: "Delete",
|
|
537
|
+
value: "delete",
|
|
538
|
+
icon: <TrashIcon />,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
[
|
|
542
|
+
onlySingleFolderSelected && {
|
|
543
|
+
title: "Add media",
|
|
544
|
+
value: "upload",
|
|
545
|
+
icon: <UploadIcon />,
|
|
546
|
+
},
|
|
547
|
+
onlySingleFolderSelected && {
|
|
548
|
+
title: "Add a Folder",
|
|
549
|
+
value: "addFolder",
|
|
550
|
+
icon: <FolderIcon />,
|
|
551
|
+
},
|
|
552
|
+
onlyAssetsSelected &&
|
|
553
|
+
tree.mediaItemsWithRoot.length > 0 && {
|
|
554
|
+
title: "Download",
|
|
555
|
+
value: "download",
|
|
556
|
+
icon: <DownloadIcon />,
|
|
557
|
+
},
|
|
558
|
+
onlySingleAssetSelected && {
|
|
559
|
+
title: "Preview",
|
|
560
|
+
value: "preview",
|
|
561
|
+
icon: <OpenInNewWindowIcon />,
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
[
|
|
565
|
+
!rootSelected &&
|
|
566
|
+
sameParentSelected && {
|
|
567
|
+
title: "Move up a folder",
|
|
568
|
+
value: "move",
|
|
569
|
+
icon: <ResetIcon />,
|
|
570
|
+
},
|
|
571
|
+
]
|
|
572
|
+
);
|
|
573
|
+
}, [
|
|
574
|
+
rootSelected,
|
|
575
|
+
singleItemSelected,
|
|
576
|
+
onlySingleAssetSelected,
|
|
577
|
+
onlySingleFolderSelected,
|
|
578
|
+
onlyAssetsSelected,
|
|
579
|
+
tree.mediaItemsWithRoot.length,
|
|
580
|
+
sameParentSelected,
|
|
581
|
+
]);
|
|
582
|
+
|
|
583
|
+
const handleMenuAction = useCallback(
|
|
584
|
+
async (action: MenuAction, selectedItems: MediaItem[]) => {
|
|
585
|
+
if (selectedItems.length === 0) return;
|
|
586
|
+
|
|
587
|
+
switch (action) {
|
|
588
|
+
case "rename":
|
|
589
|
+
handleRename(selectedItems[0].id);
|
|
590
|
+
return;
|
|
591
|
+
case "delete":
|
|
592
|
+
handleDelete(selectedIds);
|
|
593
|
+
return;
|
|
594
|
+
case "replace":
|
|
595
|
+
handleReplace(selectedItems[0]);
|
|
596
|
+
return;
|
|
597
|
+
case "upload":
|
|
598
|
+
handleUpload(selectedItems[0].id);
|
|
599
|
+
return;
|
|
600
|
+
case "addFolder":
|
|
601
|
+
handleAddFolder(selectedItems[0].id);
|
|
602
|
+
return;
|
|
603
|
+
case "preview":
|
|
604
|
+
handlePreview(selectedItems);
|
|
605
|
+
return;
|
|
606
|
+
case "download":
|
|
607
|
+
handleDownload(selectedItems);
|
|
608
|
+
return;
|
|
609
|
+
case "move":
|
|
610
|
+
handleMoveUpAFolder(selectedIds);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
[
|
|
615
|
+
handleRename,
|
|
616
|
+
handleDelete,
|
|
617
|
+
selectedIds,
|
|
618
|
+
handleReplace,
|
|
619
|
+
handleUpload,
|
|
620
|
+
handleAddFolder,
|
|
621
|
+
handlePreview,
|
|
622
|
+
handleDownload,
|
|
623
|
+
handleMoveUpAFolder,
|
|
624
|
+
]
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const handleSetExpanded = useCallback(
|
|
628
|
+
(item: MediaItem, expanded: boolean) => {
|
|
629
|
+
setExpandedMap((prev) =>
|
|
630
|
+
updateExpandedMap({
|
|
631
|
+
item,
|
|
632
|
+
expanded,
|
|
633
|
+
expandable,
|
|
634
|
+
expandedMap: prev,
|
|
635
|
+
tree,
|
|
636
|
+
})
|
|
637
|
+
);
|
|
638
|
+
},
|
|
639
|
+
[expandable, tree]
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const renderAction = useMemo(() => {
|
|
643
|
+
if (renderActionProp) return renderActionProp;
|
|
644
|
+
return ({
|
|
645
|
+
selected,
|
|
646
|
+
onOpenChange,
|
|
647
|
+
}: CollectionRenderActionProps<MediaItem>) => (
|
|
648
|
+
<ActionMenu
|
|
649
|
+
menuItems={assetContextMenuItems}
|
|
650
|
+
onSelect={(action) => handleMenuAction(action, selectedMediaItems)}
|
|
651
|
+
selected={selected}
|
|
652
|
+
onOpenChange={onOpenChange}
|
|
653
|
+
style={{
|
|
654
|
+
backgroundColor: selected
|
|
655
|
+
? cssVars.colors.primaryPastel
|
|
656
|
+
: "transparent",
|
|
657
|
+
}}
|
|
658
|
+
/>
|
|
659
|
+
);
|
|
660
|
+
}, [
|
|
661
|
+
assetContextMenuItems,
|
|
662
|
+
handleMenuAction,
|
|
663
|
+
renderActionProp,
|
|
664
|
+
selectedMediaItems,
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
useImperativeHandle(ref, () => ({
|
|
668
|
+
upload: (selectedId: string) => handleUpload(selectedId),
|
|
669
|
+
delete: handleDelete,
|
|
670
|
+
download: handleDownload,
|
|
671
|
+
rename: handleRename,
|
|
672
|
+
addFolder: handleAddFolder,
|
|
673
|
+
addFile: handleAddFile,
|
|
674
|
+
moveUpAFolder: handleMoveUpAFolder,
|
|
675
|
+
replace: handleReplace,
|
|
676
|
+
preview: handlePreview,
|
|
677
|
+
moveMediaInsideFolder: handleMoveMediaInsideFolder,
|
|
678
|
+
}));
|
|
679
|
+
|
|
680
|
+
return (
|
|
681
|
+
<>
|
|
682
|
+
<FileExplorerLayout
|
|
683
|
+
title={title ?? rootMediaItemName}
|
|
684
|
+
right={
|
|
685
|
+
<FileExplorerUploadButton
|
|
686
|
+
showUploadButton={showUploadButton}
|
|
687
|
+
onUpload={() => handleUpload(rootMediaItem.id)}
|
|
688
|
+
>
|
|
689
|
+
{right}
|
|
690
|
+
</FileExplorerUploadButton>
|
|
691
|
+
}
|
|
692
|
+
className={className}
|
|
693
|
+
>
|
|
694
|
+
<FileExplorerCollection<MediaItem, MenuAction>
|
|
695
|
+
ref={collectionRef}
|
|
696
|
+
scrollable={scrollable}
|
|
697
|
+
items={visibleItems}
|
|
698
|
+
viewType={viewType}
|
|
699
|
+
size={gridSize}
|
|
700
|
+
getId={(file) => file.id}
|
|
701
|
+
getName={(file) => {
|
|
702
|
+
if (file.id === tempItem?.[1].id) {
|
|
703
|
+
return "";
|
|
704
|
+
}
|
|
705
|
+
return treeWithTempItem.getNameForId(file.id);
|
|
706
|
+
}}
|
|
707
|
+
expandable={expandable}
|
|
708
|
+
sortable={sortable}
|
|
709
|
+
getPlaceholder={(item) => {
|
|
710
|
+
switch (item.kind) {
|
|
711
|
+
case "folder":
|
|
712
|
+
return "Enter folder name";
|
|
713
|
+
case "asset":
|
|
714
|
+
case "file":
|
|
715
|
+
return "Enter file name";
|
|
716
|
+
}
|
|
717
|
+
}}
|
|
718
|
+
getExpanded={handleExpanded}
|
|
719
|
+
setExpanded={handleSetExpanded}
|
|
720
|
+
getRenamable={(item) => {
|
|
721
|
+
if (item.id === rootMediaItem.id) return false;
|
|
722
|
+
return true;
|
|
723
|
+
}}
|
|
724
|
+
getDepth={(item) => depthMap[item.id]}
|
|
725
|
+
menuItems={assetContextMenuItems}
|
|
726
|
+
onSelectMenuItem={handleMenuAction}
|
|
727
|
+
onSelectionChange={setSelectedIds}
|
|
728
|
+
onDoubleClickItem={onDoubleClickItem}
|
|
729
|
+
onRename={onRename}
|
|
730
|
+
renamable={renamable}
|
|
731
|
+
selectedIds={selectedIds}
|
|
732
|
+
renderThumbnail={(props) => <MediaThumbnailInternal {...props} />}
|
|
733
|
+
renderAction={renderAction}
|
|
734
|
+
renderDetail={(file, selected) => {
|
|
735
|
+
if (file.kind !== "asset") return null;
|
|
736
|
+
const asset = assets.find((a) => a.id === file.assetId);
|
|
737
|
+
if (!asset) return null;
|
|
738
|
+
return (
|
|
739
|
+
<FileExplorerDetail selected={selected}>
|
|
740
|
+
{(asset.size / 1024).toFixed(1)}KB
|
|
741
|
+
</FileExplorerDetail>
|
|
742
|
+
);
|
|
743
|
+
}}
|
|
744
|
+
renderEmptyState={() =>
|
|
745
|
+
renderEmptyState?.() ?? <FileExplorerEmptyState />
|
|
746
|
+
}
|
|
747
|
+
itemRoleDescription="clickable file item"
|
|
748
|
+
getDropTargetParentIndex={(overIndex: number) => {
|
|
749
|
+
const item = visibleItems[overIndex];
|
|
750
|
+
const parentIndex = visibleItems.findIndex(
|
|
751
|
+
(i) => i.id === tree.getParentIdForId(item.id)
|
|
752
|
+
);
|
|
753
|
+
return parentIndex === -1 ? undefined : parentIndex;
|
|
754
|
+
}}
|
|
755
|
+
acceptsDrop={(
|
|
756
|
+
sourceIndex: number,
|
|
757
|
+
targetIndex: number,
|
|
758
|
+
position: string
|
|
759
|
+
) => {
|
|
760
|
+
const sourceItem = visibleItems[sourceIndex];
|
|
761
|
+
const targetItem = visibleItems[targetIndex];
|
|
762
|
+
|
|
763
|
+
if (position !== "inside" || targetItem.kind === "asset") {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
const sourcePath = tree.findIndexPath(
|
|
767
|
+
rootMediaItem,
|
|
768
|
+
(item) => item.id === sourceItem.id
|
|
769
|
+
);
|
|
770
|
+
const targetPath = tree.findIndexPath(
|
|
771
|
+
rootMediaItem,
|
|
772
|
+
(item) => item.id === targetItem.id
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
// Don't allow dragging into a descendant
|
|
776
|
+
if (!sourcePath || !targetPath) return false;
|
|
777
|
+
if (
|
|
778
|
+
isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))
|
|
779
|
+
) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return true;
|
|
784
|
+
}}
|
|
785
|
+
onMoveItem={(
|
|
786
|
+
sourceIndex: number,
|
|
787
|
+
targetIndex: number,
|
|
788
|
+
position: string
|
|
789
|
+
) => {
|
|
790
|
+
const sourceItem = visibleItems[sourceIndex];
|
|
791
|
+
const targetItem = visibleItems[targetIndex];
|
|
792
|
+
if (position === "inside") {
|
|
793
|
+
handleMoveMediaInsideFolder(sourceItem, targetItem);
|
|
794
|
+
}
|
|
795
|
+
}}
|
|
796
|
+
onFilesDrop={async (event: React.DragEvent<Element>) => {
|
|
797
|
+
event.preventDefault();
|
|
798
|
+
|
|
799
|
+
const files = Array.from(event.dataTransfer.files);
|
|
800
|
+
if (files.length === 0) return;
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
// Upload all dropped files
|
|
804
|
+
const uploadPromises = files.map(async (file) => {
|
|
805
|
+
const asset = await assetManager.create(file);
|
|
806
|
+
return {
|
|
807
|
+
asset: createMediaAsset({
|
|
808
|
+
assetId: asset.id,
|
|
809
|
+
}),
|
|
810
|
+
name: file.name,
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const newMediaItems = await Promise.all(uploadPromises);
|
|
815
|
+
const rootItemPath = tree.idToPathMap.get(rootItemId);
|
|
816
|
+
if (!rootItemPath) return;
|
|
817
|
+
// Add all the media file references to our state
|
|
818
|
+
setMedia(
|
|
819
|
+
{ name: "Add media files", timestamp: Date.now() },
|
|
820
|
+
{
|
|
821
|
+
...media,
|
|
822
|
+
...Object.fromEntries(
|
|
823
|
+
newMediaItems.map((item) => [
|
|
824
|
+
path.join(rootItemPath, item.name),
|
|
825
|
+
item.asset,
|
|
826
|
+
])
|
|
827
|
+
),
|
|
828
|
+
}
|
|
829
|
+
);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.error("Failed to upload dropped files:", error);
|
|
832
|
+
}
|
|
833
|
+
}}
|
|
834
|
+
/>
|
|
835
|
+
</FileExplorerLayout>
|
|
836
|
+
</>
|
|
837
|
+
);
|
|
838
|
+
})
|
|
839
|
+
);
|