@noya-app/noya-file-explorer 0.0.15 → 0.0.17

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,1141 @@
1
+ /* eslint-disable @shopify/prefer-early-return */
2
+ import {
3
+ ActionMenu,
4
+ Chip,
5
+ CollectionProps,
6
+ CollectionRef,
7
+ CollectionRenderActionProps,
8
+ CollectionThumbnailProps,
9
+ CollectionViewType,
10
+ createSectionedMenu,
11
+ cssVars,
12
+ FileExplorerCollection,
13
+ FileExplorerDetail,
14
+ FileExplorerEmptyState,
15
+ FileExplorerLayout,
16
+ FileExplorerUploadButton,
17
+ formatByteSize,
18
+ ListView,
19
+ MediaThumbnail,
20
+ MediaThumbnailProps,
21
+ RelativeDropPosition,
22
+ useOpenConfirmationDialog,
23
+ } from "@noya-app/noya-designsystem";
24
+ import {
25
+ DownloadIcon,
26
+ FolderIcon,
27
+ InputIcon,
28
+ OpenInNewWindowIcon,
29
+ ResetIcon,
30
+ TrashIcon,
31
+ UpdateIcon,
32
+ UploadIcon,
33
+ } from "@noya-app/noya-icons";
34
+ import {
35
+ MultiplayerPatchMetadata,
36
+ useAsset,
37
+ useAssetManager,
38
+ useAssets,
39
+ } from "@noya-app/noya-multiplayer-react";
40
+ import {
41
+ AssetResourceCreate,
42
+ createAssetResource,
43
+ createDirectoryResource,
44
+ createResourceTree,
45
+ diffResourceMaps,
46
+ PLACEHOLDER_ITEM_NAME,
47
+ Resource,
48
+ ResourceCreateMap,
49
+ ResourceMap,
50
+ ResourceTree,
51
+ rootResource,
52
+ rootResourceName,
53
+ } from "@noya-app/noya-schemas";
54
+ import { Base64, groupBy, isDeepEqual, uuid } from "@noya-app/noya-utils";
55
+ import {
56
+ AutoSizer,
57
+ downloadUrl,
58
+ memoGeneric,
59
+ useControlledOrUncontrolled,
60
+ } from "@noya-app/react-utils";
61
+ import { fileOpen } from "browser-fs-access";
62
+ import {
63
+ forwardRef,
64
+ memo,
65
+ ReactNode,
66
+ useCallback,
67
+ useEffect,
68
+ useImperativeHandle,
69
+ useMemo,
70
+ useRef,
71
+ useState,
72
+ } from "react";
73
+
74
+ import { Size } from "@noya-app/noya-geometry";
75
+ import { path } from "imfs";
76
+ import React from "react";
77
+ import { handleDataTransfer } from "./utils/handleFileDrop";
78
+ import {
79
+ deleteResources,
80
+ ExpandedMap,
81
+ FileKindFilter,
82
+ getDepthMap,
83
+ getVisibleItems,
84
+ moveMediaInsideFolder,
85
+ moveUpAFolder,
86
+ renameResourceAndDescendantPaths,
87
+ updateExpandedMap,
88
+ validateResourceRename,
89
+ } from "./utils/resourceUtils";
90
+
91
+ export const gridThumbnailDimension = {
92
+ width: 800,
93
+ height: 800,
94
+ };
95
+
96
+ export const ResourceThumbnail = memoGeneric(
97
+ ({
98
+ item,
99
+ selected,
100
+ size,
101
+ path: pathProp,
102
+ renderThumbnailIcon,
103
+ className,
104
+ url: urlProp,
105
+ contentType: contentTypeProp,
106
+ viewType,
107
+ }: CollectionThumbnailProps<Resource> & {
108
+ path?: string;
109
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
110
+ className?: string;
111
+ url?: string;
112
+ contentType?: string;
113
+ viewType?: CollectionViewType;
114
+ }) => {
115
+ const asset = useAsset(item.type === "asset" ? item.assetId : undefined);
116
+ const isRoot = item.id === rootResource.id;
117
+ const isFolder = item.type === "directory";
118
+
119
+ let contentType: string | undefined;
120
+ let url: string | undefined;
121
+ // let width: number | undefined;
122
+ // let height: number | undefined;
123
+
124
+ if (asset) {
125
+ contentType = asset.contentType;
126
+ // if (contentType?.startsWith("image/")) {
127
+ url = asset.url;
128
+ // }
129
+ // width = asset.width ?? undefined;
130
+ // height = asset.height ?? undefined;
131
+ } else if (urlProp) {
132
+ url = urlProp;
133
+ contentType = contentTypeProp;
134
+ }
135
+
136
+ const fileName = pathProp ? path.basename(pathProp) : undefined;
137
+ // const dimensions = width && height ? { width, height } : undefined;
138
+
139
+ return (
140
+ <MediaThumbnail
141
+ contentType={contentType}
142
+ iconName={isRoot ? "HomeIcon" : isFolder ? "FolderIcon" : undefined}
143
+ url={url}
144
+ selected={selected}
145
+ size={size}
146
+ fileName={fileName}
147
+ renderThumbnailIcon={renderThumbnailIcon}
148
+ dimensions={viewType === "grid" ? gridThumbnailDimension : undefined}
149
+ className={className}
150
+ />
151
+ );
152
+ }
153
+ );
154
+
155
+ type MenuAction =
156
+ | "preview"
157
+ | "download"
158
+ | "upload"
159
+ | "rename"
160
+ | "replace"
161
+ | "delete"
162
+ | "move"
163
+ | "addFolder";
164
+
165
+ export type ResourceExplorerRef = {
166
+ upload: (selectedId: string) => void;
167
+ delete: (selectedIds: string[]) => void;
168
+ download: (selectedItems: Resource[]) => void;
169
+ rename: (selectedItemId: string) => void;
170
+ addFolder: (currentFolderId: string) => void;
171
+ moveUpAFolder: (selectedIds: string[]) => void;
172
+ replace: (selectedItem: Resource) => void;
173
+ preview: (selectedItems: Resource[]) => void;
174
+ getItemAtIndex: (index: number) => Resource | undefined;
175
+ };
176
+
177
+ type ResourceExplorerProps = {
178
+ parentFileId: string;
179
+ onSelectionChange?: (
180
+ selectedIds: string[],
181
+ event?: ListView.ClickInfo
182
+ ) => void;
183
+ selectedIds?: string[];
184
+ media: ResourceMap;
185
+ setMedia?: (
186
+ metadata: Partial<MultiplayerPatchMetadata>,
187
+ media: ResourceCreateMap
188
+ ) => void;
189
+ /** @default false */
190
+ readOnly?: boolean;
191
+ /** @default "list" */
192
+ viewType?: CollectionViewType;
193
+ /**
194
+ * Whether to show assets or directories or all media items
195
+ *
196
+ * @default "all"
197
+ * */
198
+ fileKindFilter?: FileKindFilter;
199
+ /**
200
+ * Whether to show the root item
201
+ *
202
+ * @default false
203
+ * */
204
+ showRootItem?: boolean;
205
+ /** Whether to expand all directories by default */
206
+ initialExpanded?: ExpandedMap;
207
+ /**
208
+ * Callback for when an item is double-clicked
209
+ */
210
+ onDoubleClickItem?: (resourceItemId: string) => void;
211
+ /**
212
+ * If provided, only show items that are descendants of this folder
213
+ */
214
+ rootItemId?: string;
215
+ title?: ReactNode;
216
+ right?: ReactNode;
217
+ className?: string;
218
+ showUploadButton?: boolean;
219
+ /**
220
+ * If true, show all descendants of all directories within a collection
221
+ * @default true
222
+ * */
223
+ showAllDescendants?: boolean;
224
+ renderAction?: Exclude<
225
+ CollectionProps<Resource, MenuAction>["renderAction"],
226
+ "menu"
227
+ >;
228
+ /** @default false */
229
+ sortable?: boolean;
230
+ renderThumbnailIcon?: MediaThumbnailProps["renderThumbnailIcon"];
231
+ onDidDeleteItems?: (items: [string, Resource][]) => void;
232
+ onAssetsUploaded?: (mediaMap: Record<string, AssetResourceCreate>) => void;
233
+
234
+ publishedResources?: Record<string, Resource>;
235
+ virtualized?: boolean;
236
+
237
+ renderUser?: (resource: Resource) => ReactNode;
238
+ } & Pick<
239
+ CollectionProps<Resource, MenuAction>,
240
+ | "sortableId"
241
+ | "size"
242
+ | "expandable"
243
+ | "renamable"
244
+ | "scrollable"
245
+ | "renderEmptyState"
246
+ | "sharedDragProps"
247
+ | "onClickItem"
248
+ | "itemClassName"
249
+ | "itemStyle"
250
+ >;
251
+
252
+ export const ResourceExplorer = memo(
253
+ forwardRef<ResourceExplorerRef, ResourceExplorerProps>(
254
+ function ResourceExplorer(
255
+ {
256
+ parentFileId,
257
+ sortableId,
258
+ onSelectionChange,
259
+ selectedIds: selectedIdsProp,
260
+ media,
261
+ setMedia: setMediaProp,
262
+ readOnly = false,
263
+ viewType = "list",
264
+ fileKindFilter = "all",
265
+ showRootItem = false,
266
+ initialExpanded,
267
+ expandable = true,
268
+ renamable = true,
269
+ onDoubleClickItem,
270
+ rootItemId = rootResource.id,
271
+ title,
272
+ size = "medium",
273
+ right,
274
+ renderAction: renderActionProp,
275
+ className,
276
+ showUploadButton = true,
277
+ showAllDescendants = true,
278
+ scrollable = false,
279
+ sortable = false,
280
+ renderEmptyState,
281
+ sharedDragProps,
282
+ onClickItem,
283
+ renderThumbnailIcon,
284
+ onDidDeleteItems,
285
+ onAssetsUploaded,
286
+ itemClassName,
287
+ itemStyle,
288
+ publishedResources,
289
+ virtualized = false,
290
+ renderUser,
291
+ },
292
+ ref
293
+ ) {
294
+ const setMedia = useCallback(
295
+ (...args: Parameters<Extract<typeof setMediaProp, Function>>) => {
296
+ setMediaProp?.(...args);
297
+ },
298
+ [setMediaProp]
299
+ );
300
+ const tree = useMemo(() => createResourceTree(media), [media]);
301
+ const [tempItem, setTempItem] = useState<[string, Resource] | undefined>(
302
+ undefined
303
+ );
304
+ const mediaWithTempItem = useMemo(
305
+ () => ({
306
+ ...media,
307
+ ...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
308
+ }),
309
+ [media, tempItem]
310
+ );
311
+ const treeWithTempItem = useMemo(
312
+ () => createResourceTree(mediaWithTempItem),
313
+ [mediaWithTempItem]
314
+ );
315
+ const temp = useMemo(
316
+ () => ({
317
+ media: mediaWithTempItem,
318
+ tree: treeWithTempItem,
319
+ }),
320
+ [mediaWithTempItem, treeWithTempItem]
321
+ );
322
+ const [selectedIds, setSelectedIds] = useControlledOrUncontrolled<
323
+ string[]
324
+ >({
325
+ defaultValue: [],
326
+ value: selectedIdsProp,
327
+ onChange: onSelectionChange,
328
+ });
329
+ const assetManager = useAssetManager();
330
+ const assets = useAssets();
331
+ const [expandedMap, setExpandedMap] = useState<ExpandedMap>({});
332
+ const visibleItems = useMemo(
333
+ () =>
334
+ getVisibleItems({
335
+ expandedMap,
336
+ fileKindFilter: fileKindFilter,
337
+ rootItemId,
338
+ tree: treeWithTempItem,
339
+ showAllDescendants,
340
+ showRootItem,
341
+ }),
342
+ [
343
+ expandedMap,
344
+ fileKindFilter,
345
+ rootItemId,
346
+ treeWithTempItem,
347
+ showAllDescendants,
348
+ showRootItem,
349
+ ]
350
+ );
351
+ const depthMap = useMemo(
352
+ () => getDepthMap(rootResource, treeWithTempItem, showAllDescendants),
353
+ [treeWithTempItem, showAllDescendants]
354
+ );
355
+ const collectionRef = useRef<CollectionRef>(null);
356
+ const selectedResources = useMemo(
357
+ () =>
358
+ treeWithTempItem.resourcesWithRoot.filter((item) =>
359
+ selectedIds.includes(item.id)
360
+ ),
361
+ [treeWithTempItem, selectedIds]
362
+ );
363
+
364
+ const groupedItems = groupBy(selectedResources, (item) => item.type);
365
+ const selectedAssetItems = groupedItems.asset ?? [];
366
+ const selectedFolderItems = groupedItems.folder ?? [];
367
+
368
+ const singleItemSelected = selectedResources.length === 1;
369
+ const onlyAssetsSelected =
370
+ selectedAssetItems.length > 0 &&
371
+ selectedAssetItems.length === selectedResources.length;
372
+ const onlyFoldersSelected =
373
+ selectedFolderItems.length > 0 &&
374
+ selectedFolderItems.length === selectedResources.length;
375
+ const onlySingleFolderSelected =
376
+ onlyFoldersSelected && selectedFolderItems.length === 1;
377
+ const onlySingleAssetSelected =
378
+ onlyAssetsSelected && selectedAssetItems.length === 1;
379
+ const rootSelected = selectedIds.includes(rootResource.id);
380
+ const sameParentSelected = selectedResources.every((item) => {
381
+ const itemPath = tree.idToPathMap.get(item.id);
382
+ const firstSelectedPath = tree.idToPathMap.get(selectedIds[0]);
383
+ if (!itemPath || !firstSelectedPath) return false;
384
+ return itemPath.startsWith(path.dirname(firstSelectedPath));
385
+ });
386
+
387
+ useEffect(() => {
388
+ if (initialExpanded) {
389
+ setExpandedMap(initialExpanded);
390
+ }
391
+ }, [initialExpanded]);
392
+
393
+ const getExpanded = useCallback(
394
+ (item: Resource) => {
395
+ if (!expandable) return undefined;
396
+ if (item.type !== "directory") return undefined;
397
+ if (item.id === rootResource.id) return undefined;
398
+ return expandedMap[item.id] ?? false;
399
+ },
400
+ [expandedMap, expandable]
401
+ );
402
+
403
+ const openConfirmationDialog = useOpenConfirmationDialog();
404
+
405
+ const handleDelete = useCallback(
406
+ async (selectedIds: string[]) => {
407
+ const ok = await openConfirmationDialog({
408
+ title: "Delete items",
409
+ description:
410
+ "Are you sure you want to delete these items? This action cannot be undone.",
411
+ });
412
+
413
+ if (!ok) return;
414
+
415
+ const deletedItems = Object.entries(media).flatMap(
416
+ ([path, item]): [string, Resource][] => {
417
+ if (selectedIds.includes(item.id)) {
418
+ return [[path, item]];
419
+ }
420
+ return [];
421
+ }
422
+ );
423
+
424
+ const newMedia = deleteResources({
425
+ selectedIds,
426
+ media,
427
+ tree,
428
+ });
429
+
430
+ setSelectedIds([rootResource.id]);
431
+ setMedia({ name: "Delete items", timestamp: Date.now() }, newMedia);
432
+
433
+ onDidDeleteItems?.(deletedItems);
434
+ },
435
+ [
436
+ media,
437
+ setMedia,
438
+ setSelectedIds,
439
+ tree,
440
+ onDidDeleteItems,
441
+ openConfirmationDialog,
442
+ ]
443
+ );
444
+
445
+ const onRename = useCallback(
446
+ (selectedItem: Resource, newName: string) => {
447
+ if (!renamable) return;
448
+ const selectedItemPath = treeWithTempItem.idToPathMap.get(
449
+ selectedItem.id
450
+ );
451
+ if (!selectedItemPath) return;
452
+ const renameIsValid = validateResourceRename({
453
+ basename: newName,
454
+ selectedItemPath,
455
+ media: temp.media,
456
+ });
457
+ if (!renameIsValid) {
458
+ setTempItem(undefined);
459
+ return;
460
+ }
461
+ const mediaWithRenamedDescendantPaths =
462
+ renameResourceAndDescendantPaths({
463
+ newName,
464
+ selectedItemPath,
465
+ media: temp.media,
466
+ tree: temp.tree,
467
+ });
468
+ setMedia(
469
+ { name: "Rename media item", timestamp: Date.now() },
470
+ mediaWithRenamedDescendantPaths
471
+ );
472
+ setTempItem(undefined);
473
+ },
474
+ [
475
+ renamable,
476
+ setMedia,
477
+ temp.media,
478
+ temp.tree,
479
+ treeWithTempItem.idToPathMap,
480
+ ]
481
+ );
482
+
483
+ const handleAddFolder = useCallback(
484
+ (currentFolderId: string) => {
485
+ const currentFolderPath = tree.idToPathMap.get(currentFolderId);
486
+ if (!currentFolderPath) return;
487
+ const newFolderPath = path.join(
488
+ currentFolderPath,
489
+ PLACEHOLDER_ITEM_NAME
490
+ );
491
+ const newFolder = createDirectoryResource({
492
+ path: newFolderPath,
493
+ accessibleByFileId: parentFileId,
494
+ id: newFolderPath,
495
+ stableId: uuid(),
496
+ });
497
+ setTempItem([newFolderPath, newFolder]);
498
+ setTimeout(() => {
499
+ collectionRef.current?.editName(newFolder.id);
500
+ }, 50);
501
+ },
502
+ [parentFileId, tree]
503
+ );
504
+
505
+ const handleMoveUpAFolder = useCallback(
506
+ (selectedIds: string[]) => {
507
+ const newMedia = moveUpAFolder({
508
+ tree,
509
+ media,
510
+ selectedIds,
511
+ });
512
+ if (!newMedia) return;
513
+ setMedia({ name: "Move items", timestamp: Date.now() }, newMedia);
514
+ },
515
+ [media, tree, setMedia]
516
+ );
517
+
518
+ const [isUploading, setIsUploading] = useState(false);
519
+
520
+ const handleUpload = useCallback(
521
+ async (selectedId: string) => {
522
+ try {
523
+ const files = await fileOpen({ multiple: true });
524
+
525
+ if (!files || !Array.isArray(files) || files.length === 0) return;
526
+
527
+ const parentPath = tree.idToPathMap.get(selectedId);
528
+
529
+ if (!parentPath) return;
530
+
531
+ const uploadPromises = files.map(async (file) => {
532
+ const assetPath = path.join(parentPath, path.basename(file.name));
533
+
534
+ return {
535
+ assetPath,
536
+ asset: createAssetResource({
537
+ id: uuid(),
538
+ asset: {
539
+ content: Base64.encode(await file.arrayBuffer()),
540
+ contentType: file.type,
541
+ encoding: "base64",
542
+ },
543
+ path: assetPath,
544
+ accessibleByFileId: parentFileId,
545
+ stableId: uuid(),
546
+ }),
547
+ };
548
+ });
549
+
550
+ setIsUploading(true);
551
+
552
+ const uploadedAssets = await Promise.all(uploadPromises);
553
+ const newMediaMap = Object.fromEntries(
554
+ uploadedAssets.map(({ assetPath, asset }) => [assetPath, asset])
555
+ );
556
+
557
+ setMedia(
558
+ { name: "Add media items", timestamp: Date.now() },
559
+ {
560
+ ...media,
561
+ ...newMediaMap,
562
+ }
563
+ );
564
+
565
+ onAssetsUploaded?.(newMediaMap);
566
+ } catch (error) {
567
+ if (error instanceof Error && error.name === "AbortError") {
568
+ // Ignore user-cancelled file picker
569
+ } else {
570
+ console.error("Failed to upload files:", error);
571
+ }
572
+ } finally {
573
+ setIsUploading(false);
574
+ }
575
+ },
576
+ [tree, setMedia, media, onAssetsUploaded, parentFileId]
577
+ );
578
+
579
+ const handleDownload = useCallback(
580
+ async (selectedItems: Resource[]) => {
581
+ const downloadPromises = selectedItems
582
+ .filter((item) => item.type === "asset")
583
+ .map(async (item) => {
584
+ const asset = assets.find((a) => a.id === item.assetId);
585
+ if (!asset?.url) return;
586
+ return downloadUrl(asset.url, tree.getNameForId(item.id));
587
+ });
588
+
589
+ await Promise.all(downloadPromises);
590
+ },
591
+ [assets, tree]
592
+ );
593
+
594
+ const handlePreview = useCallback(
595
+ async (selectedItems: Resource[]) => {
596
+ const previewPromises = selectedItems
597
+ .filter((item) => item.type === "asset")
598
+ .map(async (item) => {
599
+ const asset = assets.find((a) => a.id === item.assetId);
600
+ if (!asset?.url) return;
601
+ return window?.open(asset.url, "_blank");
602
+ });
603
+
604
+ await Promise.all(previewPromises);
605
+ },
606
+ [assets]
607
+ );
608
+
609
+ const handleReplace = useCallback(
610
+ async (selectedItem: Resource) => {
611
+ try {
612
+ const file = await fileOpen();
613
+ if (!file) return;
614
+ // Create the asset
615
+ const asset = await assetManager.create(file);
616
+ const oldFile = selectedItem;
617
+ const oldFilePath = tree.idToPathMap.get(oldFile.id);
618
+ if (!oldFilePath || oldFile.type !== "asset") return;
619
+ setMedia(
620
+ { name: "Replace media file", timestamp: Date.now() },
621
+ {
622
+ ...media,
623
+ [oldFilePath]: createAssetResource({
624
+ ...oldFile,
625
+ assetId: asset.id,
626
+ }),
627
+ }
628
+ );
629
+ } catch (error) {
630
+ if (error instanceof Error && error.name === "AbortError") {
631
+ // Ignore user-cancelled file picker
632
+ } else {
633
+ console.error("Failed to upload files:", error);
634
+ }
635
+ }
636
+ },
637
+ [media, setMedia, assetManager, tree]
638
+ );
639
+
640
+ const handleRename = useCallback(
641
+ (selectedItemId: string) => {
642
+ collectionRef.current?.editName(selectedItemId);
643
+ },
644
+ [collectionRef]
645
+ );
646
+
647
+ const handleMoveMediaInsideFolder = useCallback(
648
+ (sourceItems: Resource[], targetItem: Resource) => {
649
+ const newMedia = moveMediaInsideFolder({
650
+ sourceItemIds: sourceItems.map((item) => item.id),
651
+ targetItemId: targetItem.id,
652
+ media,
653
+ tree,
654
+ });
655
+
656
+ setMedia(
657
+ {
658
+ name: "Move media file inside folder",
659
+ timestamp: Date.now(),
660
+ },
661
+ newMedia
662
+ );
663
+ },
664
+ [media, setMedia, tree]
665
+ );
666
+
667
+ const assetContextMenuItems = useMemo(() => {
668
+ return createSectionedMenu<MenuAction>(
669
+ [
670
+ !rootSelected &&
671
+ singleItemSelected && {
672
+ title: "Rename",
673
+ value: "rename",
674
+ icon: <InputIcon />,
675
+ },
676
+ onlySingleAssetSelected && {
677
+ title: "Replace",
678
+ value: "replace",
679
+ icon: <UpdateIcon />,
680
+ },
681
+ !rootSelected && {
682
+ title: "Delete",
683
+ value: "delete",
684
+ icon: <TrashIcon />,
685
+ },
686
+ ],
687
+ [
688
+ onlySingleFolderSelected && {
689
+ title: "Upload Files",
690
+ value: "upload",
691
+ icon: <UploadIcon />,
692
+ },
693
+ onlySingleFolderSelected && {
694
+ title: "Add Folder",
695
+ value: "addFolder",
696
+ icon: <FolderIcon />,
697
+ },
698
+ onlyAssetsSelected &&
699
+ tree.resourcesWithRoot.length > 0 && {
700
+ title: "Download",
701
+ value: "download",
702
+ icon: <DownloadIcon />,
703
+ },
704
+ onlySingleAssetSelected && {
705
+ title: "Preview",
706
+ value: "preview",
707
+ icon: <OpenInNewWindowIcon />,
708
+ },
709
+ ],
710
+ [
711
+ !rootSelected &&
712
+ sameParentSelected && {
713
+ title: "Move up a folder",
714
+ value: "move",
715
+ icon: <ResetIcon />,
716
+ },
717
+ ]
718
+ );
719
+ }, [
720
+ rootSelected,
721
+ singleItemSelected,
722
+ onlySingleAssetSelected,
723
+ onlySingleFolderSelected,
724
+ onlyAssetsSelected,
725
+ tree.resourcesWithRoot.length,
726
+ sameParentSelected,
727
+ ]);
728
+
729
+ const handleMenuAction = useCallback(
730
+ async (action: MenuAction, selectedItems: Resource[]) => {
731
+ if (selectedItems.length === 0) return;
732
+
733
+ switch (action) {
734
+ case "rename":
735
+ handleRename(selectedItems[0].id);
736
+ return;
737
+ case "delete":
738
+ handleDelete(selectedIds);
739
+ return;
740
+ case "replace":
741
+ handleReplace(selectedItems[0]);
742
+ return;
743
+ case "upload":
744
+ handleUpload(selectedItems[0].id);
745
+ return;
746
+ case "addFolder":
747
+ handleAddFolder(selectedItems[0].id);
748
+ return;
749
+ case "preview":
750
+ handlePreview(selectedItems);
751
+ return;
752
+ case "download":
753
+ handleDownload(selectedItems);
754
+ return;
755
+ case "move":
756
+ handleMoveUpAFolder(selectedIds);
757
+ return;
758
+ }
759
+ },
760
+ [
761
+ handleRename,
762
+ handleDelete,
763
+ selectedIds,
764
+ handleReplace,
765
+ handleUpload,
766
+ handleAddFolder,
767
+ handlePreview,
768
+ handleDownload,
769
+ handleMoveUpAFolder,
770
+ ]
771
+ );
772
+
773
+ const handleSetExpanded = useCallback(
774
+ (item: Resource, expanded: boolean) => {
775
+ setExpandedMap((prev) =>
776
+ updateExpandedMap({
777
+ item,
778
+ expanded,
779
+ expandable,
780
+ expandedMap: prev,
781
+ tree,
782
+ })
783
+ );
784
+ },
785
+ [expandable, tree]
786
+ );
787
+
788
+ const renderAction = useMemo(() => {
789
+ if (renderActionProp) return renderActionProp;
790
+
791
+ return ({
792
+ selected,
793
+ onOpenChange,
794
+ }: CollectionRenderActionProps<Resource>) => (
795
+ <div
796
+ style={{
797
+ minWidth: "75px",
798
+ display: "flex",
799
+ justifyContent: "flex-end",
800
+ }}
801
+ >
802
+ <ActionMenu
803
+ menuItems={assetContextMenuItems}
804
+ onSelect={(action) => handleMenuAction(action, selectedResources)}
805
+ selected={selected}
806
+ onOpenChange={onOpenChange}
807
+ variant={viewType === "grid" ? "normal" : "bare"}
808
+ style={{
809
+ backgroundColor: selected
810
+ ? cssVars.colors.selectedListItemBackground
811
+ : "transparent",
812
+ color: selected
813
+ ? cssVars.colors.selectedListItemText
814
+ : undefined,
815
+ }}
816
+ />
817
+ </div>
818
+ );
819
+ }, [
820
+ assetContextMenuItems,
821
+ handleMenuAction,
822
+ renderActionProp,
823
+ selectedResources,
824
+ viewType,
825
+ ]);
826
+
827
+ useImperativeHandle(ref, () => ({
828
+ upload: (selectedId: string) => handleUpload(selectedId),
829
+ delete: handleDelete,
830
+ download: handleDownload,
831
+ rename: handleRename,
832
+ addFolder: handleAddFolder,
833
+ moveUpAFolder: handleMoveUpAFolder,
834
+ replace: handleReplace,
835
+ preview: handlePreview,
836
+ moveMediaInsideFolder: handleMoveMediaInsideFolder,
837
+ getItemAtIndex: (index) => visibleItems[index],
838
+ }));
839
+
840
+ const diffResources = useMemo(() => {
841
+ if (!publishedResources) return;
842
+
843
+ const diff = diffResourceMaps(publishedResources, media, "stableId");
844
+
845
+ return {
846
+ added: new Set(
847
+ diff.addedResources.map((resource) => resource.stableId)
848
+ ),
849
+ removed: new Set(
850
+ diff.removedResources.map((resource) => resource.stableId)
851
+ ),
852
+ modified: new Set(
853
+ diff.modifiedResources.map((resource) => resource.stableId)
854
+ ),
855
+ };
856
+ }, [media, publishedResources]);
857
+
858
+ const keyExtractor = useCallback((item: Resource) => item.id, []);
859
+
860
+ const renderCollection = (virtualizedSize: Size | undefined) => (
861
+ <FileExplorerCollection<Resource, MenuAction>
862
+ ref={collectionRef}
863
+ sortableId={sortableId}
864
+ scrollable={scrollable}
865
+ sharedDragProps={sharedDragProps}
866
+ items={visibleItems}
867
+ viewType={viewType}
868
+ size={size}
869
+ getId={keyExtractor}
870
+ getName={(file) => {
871
+ if (file.id === tempItem?.[1].id) {
872
+ return "";
873
+ }
874
+ return treeWithTempItem.getNameForId(file.id);
875
+ }}
876
+ expandable={expandable}
877
+ sortable={sortable}
878
+ getPlaceholder={(item) => {
879
+ switch (item.type) {
880
+ case "directory":
881
+ return "Enter folder name";
882
+ case "resource":
883
+ case "asset":
884
+ case "file":
885
+ return "Enter file name";
886
+ }
887
+ }}
888
+ getExpanded={getExpanded}
889
+ setExpanded={handleSetExpanded}
890
+ getRenamable={(item) => {
891
+ if (item.id === rootResource.id) return false;
892
+ return true;
893
+ }}
894
+ getDepth={(item) => depthMap[item.id]}
895
+ menuItems={assetContextMenuItems}
896
+ onSelectMenuItem={handleMenuAction}
897
+ onSelectionChange={setSelectedIds}
898
+ onClickItem={onClickItem}
899
+ onClick={() => {
900
+ setSelectedIds([]);
901
+ }}
902
+ onDoubleClickItem={onDoubleClickItem}
903
+ onRename={onRename}
904
+ renamable={renamable}
905
+ selectedIds={selectedIds}
906
+ itemClassName={itemClassName}
907
+ itemStyle={itemStyle}
908
+ virtualized={virtualizedSize}
909
+ renderThumbnail={(props) => (
910
+ <ResourceThumbnail
911
+ {...props}
912
+ path={tree.idToPathMap.get(props.item.id)}
913
+ renderThumbnailIcon={renderThumbnailIcon}
914
+ viewType={viewType}
915
+ />
916
+ )}
917
+ renderAction={renderAction}
918
+ renderRight={(file) => {
919
+ if (file.type !== "asset") return null;
920
+
921
+ const publishStatus = getPublishStatus({
922
+ resource: file,
923
+ diff: diffResources,
924
+ });
925
+
926
+ return (
927
+ <div
928
+ style={{ display: "flex", alignItems: "center", gap: "1.5px" }}
929
+ >
930
+ {publishStatus && (
931
+ <Chip
932
+ colorScheme={
933
+ publishStatus === "Modified"
934
+ ? "info"
935
+ : publishStatus === "Added"
936
+ ? "secondary"
937
+ : "primary"
938
+ }
939
+ >
940
+ {publishStatus}
941
+ </Chip>
942
+ )}
943
+ {renderUser?.(file)}
944
+ </div>
945
+ );
946
+ }}
947
+ renderDetail={(file, selected) => {
948
+ if (file.type !== "asset") return null;
949
+
950
+ const asset = assets.find((a) => a.id === file.assetId);
951
+
952
+ if (!asset) return null;
953
+
954
+ return (
955
+ <FileExplorerDetail
956
+ selected={selected}
957
+ size={size}
958
+ style={{ minWidth: "75px", textAlign: "right" }}
959
+ >
960
+ {formatByteSize(asset.size)}
961
+ </FileExplorerDetail>
962
+ );
963
+ }}
964
+ renderEmptyState={() =>
965
+ renderEmptyState?.() ?? <FileExplorerEmptyState />
966
+ }
967
+ itemRoleDescription="clickable file item"
968
+ getDropTargetParentIndex={(overIndex) => {
969
+ const item = visibleItems[overIndex];
970
+ const parentIndex = visibleItems.findIndex(
971
+ (i) => i.id === tree.getParentIdForId(item.id)
972
+ );
973
+ return parentIndex === -1 ? undefined : parentIndex;
974
+ }}
975
+ acceptsDrop={({
976
+ sourceIndex,
977
+ targetIndex,
978
+ position,
979
+ sourceListId,
980
+ targetListId,
981
+ }) => {
982
+ if (sourceListId !== targetListId) {
983
+ return false;
984
+ }
985
+
986
+ if (sourceListId !== sortableId || targetListId !== sortableId) {
987
+ return false;
988
+ }
989
+
990
+ const sourceItem = visibleItems[sourceIndex];
991
+ const targetItem = visibleItems[targetIndex];
992
+
993
+ return acceptsResourceDrop({
994
+ position,
995
+ sourceItem,
996
+ targetItem,
997
+ tree,
998
+ });
999
+ }}
1000
+ onMoveItem={({
1001
+ sourceListId,
1002
+ sourceIndex,
1003
+ targetListId,
1004
+ targetIndex,
1005
+ position,
1006
+ }) => {
1007
+ if (
1008
+ sourceListId !== sortableId ||
1009
+ targetListId !== sortableId ||
1010
+ position !== "inside"
1011
+ ) {
1012
+ return;
1013
+ }
1014
+
1015
+ const selectedItems = visibleItems.filter((item) =>
1016
+ selectedIds.includes(item.id)
1017
+ );
1018
+
1019
+ const sourceItem = visibleItems[sourceIndex];
1020
+
1021
+ // if source is selected, move the selected items, otherwise move the source item
1022
+ const itemsToMove = selectedIds.includes(sourceItem.id)
1023
+ ? selectedItems
1024
+ : [sourceItem];
1025
+
1026
+ const targetItem = visibleItems[targetIndex];
1027
+
1028
+ handleMoveMediaInsideFolder(itemsToMove, targetItem);
1029
+ }}
1030
+ onFilesDrop={async (event: React.DragEvent<Element>) => {
1031
+ event.preventDefault();
1032
+
1033
+ const rootItemPath = tree.idToPathMap.get(rootItemId);
1034
+
1035
+ if (!rootItemPath) return;
1036
+
1037
+ const newMedia = await handleDataTransfer({
1038
+ dataTransfer: event.dataTransfer,
1039
+ rootItemPath,
1040
+ resourceMap: media,
1041
+ accessibleByFileId: parentFileId,
1042
+ });
1043
+
1044
+ if (!newMedia) return;
1045
+
1046
+ setMedia(
1047
+ { name: "Add media files", timestamp: Date.now() },
1048
+ newMedia
1049
+ );
1050
+ }}
1051
+ />
1052
+ );
1053
+
1054
+ return (
1055
+ <FileExplorerLayout
1056
+ title={title ?? rootResourceName}
1057
+ right={
1058
+ !readOnly && (
1059
+ <FileExplorerUploadButton
1060
+ showUploadButton={showUploadButton}
1061
+ onUpload={() => handleUpload(rootResource.id)}
1062
+ isUploading={isUploading}
1063
+ >
1064
+ {right}
1065
+ </FileExplorerUploadButton>
1066
+ )
1067
+ }
1068
+ className={className}
1069
+ >
1070
+ {virtualized ? (
1071
+ <AutoSizer>
1072
+ {({ width, height }) =>
1073
+ // add 24px to account for the -mx-3 on the Collection component
1074
+ renderCollection({ width: width + 24, height })
1075
+ }
1076
+ </AutoSizer>
1077
+ ) : (
1078
+ renderCollection(undefined)
1079
+ )}
1080
+ </FileExplorerLayout>
1081
+ );
1082
+ }
1083
+ )
1084
+ );
1085
+
1086
+ export function acceptsResourceDrop(parameters: {
1087
+ position: RelativeDropPosition;
1088
+ sourceItem: Resource;
1089
+ targetItem: Resource;
1090
+ tree: ResourceTree;
1091
+ }) {
1092
+ const { position, sourceItem, targetItem, tree } = parameters;
1093
+
1094
+ if (position !== "inside" || targetItem.type === "asset") {
1095
+ return false;
1096
+ }
1097
+ const sourcePath = tree.findPath(
1098
+ rootResource,
1099
+ (item) => item.id === sourceItem.id
1100
+ );
1101
+ const targetPath = tree.findPath(
1102
+ rootResource,
1103
+ (item) => item.id === targetItem.id
1104
+ );
1105
+
1106
+ // Don't allow dragging into a descendant
1107
+ if (!sourcePath || !targetPath) return false;
1108
+
1109
+ if (isDeepEqual(sourcePath, targetPath.slice(0, sourcePath.length))) {
1110
+ return false;
1111
+ }
1112
+
1113
+ return true;
1114
+ }
1115
+
1116
+ function getPublishStatus({
1117
+ resource,
1118
+ diff,
1119
+ }: {
1120
+ resource: Resource;
1121
+ diff:
1122
+ | {
1123
+ modified: Set<string>;
1124
+ added: Set<string>;
1125
+ removed: Set<string>;
1126
+ }
1127
+ | undefined;
1128
+ }) {
1129
+ // If no diff, nothing has been published, so we don't need to show status
1130
+ if (!diff) return undefined;
1131
+
1132
+ return diff.modified.has(resource.stableId)
1133
+ ? "Modified"
1134
+ : diff.added.has(resource.stableId)
1135
+ ? "Added"
1136
+ : diff.removed.has(resource.stableId)
1137
+ ? "Removed"
1138
+ : // We don't have to check for the specific ID, since if anything had been published
1139
+ // but removed, it will be in the removed set
1140
+ "Published";
1141
+ }