@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.
@@ -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
+ );