@noya-app/noya-file-explorer 0.0.2 → 0.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-file-explorer",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -20,13 +20,13 @@
20
20
  "dev": "npm run build:main -- --watch"
21
21
  },
22
22
  "dependencies": {
23
- "@noya-app/noya-designsystem": "0.1.50",
24
- "@noya-app/noya-icons": "0.1.7",
25
- "@noya-app/noya-multiplayer-react": "0.1.53",
23
+ "@noya-app/noya-designsystem": "0.1.52",
24
+ "@noya-app/noya-icons": "0.1.8",
25
+ "@noya-app/noya-multiplayer-react": "0.1.55",
26
26
  "@noya-app/noya-keymap": "0.1.3",
27
- "@noya-app/react-utils": "0.1.17",
27
+ "@noya-app/react-utils": "0.1.18",
28
28
  "@noya-app/noya-utils": "0.1.5",
29
- "@noya-app/noya-schemas": "0.1.4",
29
+ "@noya-app/noya-schemas": "0.1.5",
30
30
  "imfs": "^0.1.0",
31
31
  "browser-fs-access": "0.35.0"
32
32
  },
@@ -32,7 +32,7 @@ import {
32
32
  useAssetManager,
33
33
  useAssets,
34
34
  } from "@noya-app/noya-multiplayer-react";
35
- import { MediaItem, MediaMap } from "@noya-app/noya-schemas";
35
+ import { FileMediaItem, MediaItem, MediaMap } from "@noya-app/noya-schemas";
36
36
  import { groupBy, isDeepEqual } from "@noya-app/noya-utils";
37
37
  import {
38
38
  downloadUrl,
@@ -71,21 +71,86 @@ import {
71
71
  getVisibleItems,
72
72
  moveMediaInsideFolder,
73
73
  moveUpAFolder,
74
+ renameMediaItemAndDescendantPaths,
74
75
  updateExpandedMap,
75
76
  validateMediaItemRename,
76
77
  } from "./utils/files";
77
78
 
79
+ const extensionToContentType = {
80
+ svg: "image/svg+xml",
81
+ png: "image/png",
82
+ jpeg: "image/jpeg",
83
+ } as const;
84
+
85
+ function encodeFileContentForThumbnail(
86
+ pathProp: string,
87
+ item: FileMediaItem
88
+ ): { contentType: string; url?: string } | undefined {
89
+ const extension = path.extname(pathProp).slice(1);
90
+ const contentType =
91
+ extensionToContentType[extension as keyof typeof extensionToContentType];
92
+
93
+ if (contentType) {
94
+ if (item.encoding === "base64") {
95
+ return {
96
+ contentType,
97
+ url: `data:${contentType};base64,${item.content}`,
98
+ };
99
+ } else {
100
+ try {
101
+ return {
102
+ contentType,
103
+ url: `data:${contentType},${encodeURIComponent(item.content)}`,
104
+ };
105
+ } catch (error) {
106
+ console.warn("Failed to encode content:", error);
107
+ return { contentType };
108
+ }
109
+ }
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
78
115
  const MediaThumbnailInternal = memoGeneric(
79
- ({ item, selected }: CollectionThumbnailProps<MediaItem>) => {
116
+ ({
117
+ item,
118
+ selected,
119
+ size,
120
+ path: pathProp,
121
+ }: CollectionThumbnailProps<MediaItem> & {
122
+ path?: string;
123
+ }) => {
80
124
  const asset = useAsset(item.kind === "asset" ? item.assetId : undefined);
81
125
  const isRoot = item.id === rootMediaItem.id;
82
126
  const isFolder = item.kind === "folder";
127
+ const isFile = item.kind === "file";
128
+
129
+ let contentType: string | undefined;
130
+ let url: string | undefined;
131
+
132
+ if (asset) {
133
+ contentType = asset.contentType;
134
+ url = asset.url;
135
+ } else if (isFile && pathProp) {
136
+ const encoded = encodeFileContentForThumbnail(pathProp, item);
137
+
138
+ if (encoded) {
139
+ contentType = encoded.contentType;
140
+ url = encoded.url;
141
+ }
142
+ }
143
+
144
+ const fileName = pathProp ? path.basename(pathProp) : undefined;
145
+
83
146
  return (
84
147
  <MediaThumbnail
85
- contentType={asset?.contentType}
148
+ contentType={contentType}
86
149
  iconName={isRoot ? "HomeIcon" : isFolder ? "FolderIcon" : undefined}
87
- url={asset?.url}
150
+ url={url}
88
151
  selected={selected}
152
+ size={size}
153
+ fileName={fileName}
89
154
  />
90
155
  );
91
156
  }
@@ -120,10 +185,12 @@ type MediaCollectionProps = {
120
185
  ) => void;
121
186
  selectedIds?: string[];
122
187
  media: MediaMap;
123
- setMedia: (
188
+ setMedia?: (
124
189
  metadata: Partial<MultiplayerPatchMetadata>,
125
190
  media: MediaMap
126
191
  ) => void;
192
+ /** @default false */
193
+ readOnly?: boolean;
127
194
  /** @default "list" */
128
195
  viewType?: CollectionViewType;
129
196
  /**
@@ -174,7 +241,8 @@ export const MediaCollection = memo(
174
241
  onSelectionChange,
175
242
  selectedIds: selectedIdsProp,
176
243
  media,
177
- setMedia,
244
+ setMedia: setMediaProp,
245
+ readOnly = false,
178
246
  viewType = "list",
179
247
  fileKindFilter = "all",
180
248
  showRootItem = false,
@@ -196,18 +264,34 @@ export const MediaCollection = memo(
196
264
  },
197
265
  ref
198
266
  ) {
267
+ const setMedia = useCallback(
268
+ (...args: Parameters<Extract<typeof setMediaProp, Function>>) => {
269
+ setMediaProp?.(...args);
270
+ },
271
+ [setMediaProp]
272
+ );
199
273
  const tree = useMemo(() => createMediaItemTree(media), [media]);
200
274
  const [tempItem, setTempItem] = useState<[string, MediaItem] | undefined>(
201
275
  undefined
202
276
  );
203
- const treeWithTempItem = useMemo(
204
- () =>
205
- createMediaItemTree({
206
- ...media,
207
- ...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
208
- }),
277
+ const mediaWithTempItem = useMemo(
278
+ () => ({
279
+ ...media,
280
+ ...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
281
+ }),
209
282
  [media, tempItem]
210
283
  );
284
+ const treeWithTempItem = useMemo(
285
+ () => createMediaItemTree(mediaWithTempItem),
286
+ [mediaWithTempItem]
287
+ );
288
+ const temp = useMemo(
289
+ () => ({
290
+ media: mediaWithTempItem,
291
+ tree: treeWithTempItem,
292
+ }),
293
+ [mediaWithTempItem, treeWithTempItem]
294
+ );
211
295
  const [selectedIds, setSelectedIds] = useControlledOrUncontrolled<string[]>(
212
296
  {
213
297
  defaultValue: [],
@@ -273,14 +357,6 @@ export const MediaCollection = memo(
273
357
  return itemPath.startsWith(path.dirname(firstSelectedPath));
274
358
  });
275
359
 
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
360
  useEffect(() => {
285
361
  if (initialExpanded) {
286
362
  setExpandedMap(initialExpanded);
@@ -318,31 +394,29 @@ export const MediaCollection = memo(
318
394
  selectedItem.id
319
395
  );
320
396
  if (!selectedItemPath) return;
321
- const mediaWithTempItem = {
322
- ...media,
323
- ...(tempItem ? { [tempItem[0]]: tempItem[1] } : {}),
324
- };
325
397
  const renameIsValid = validateMediaItemRename({
326
398
  basename: newName,
327
399
  selectedItemPath,
328
- media: mediaWithTempItem,
400
+ media: temp.media,
329
401
  });
330
402
  if (!renameIsValid) {
331
403
  setTempItem(undefined);
332
404
  return;
333
405
  }
334
- const mediaClone = { ...media };
335
- delete mediaClone[selectedItemPath];
406
+ const mediaWithRenamedDescendantPaths =
407
+ renameMediaItemAndDescendantPaths({
408
+ newName,
409
+ selectedItemPath,
410
+ media: temp.media,
411
+ tree: temp.tree,
412
+ });
336
413
  setMedia(
337
414
  { name: "Rename media item", timestamp: Date.now() },
338
- {
339
- ...mediaClone,
340
- [path.join(path.dirname(selectedItemPath), newName)]: selectedItem,
341
- }
415
+ mediaWithRenamedDescendantPaths
342
416
  );
343
417
  setTempItem(undefined);
344
418
  },
345
- [media, renamable, setMedia, tempItem, treeWithTempItem.idToPathMap]
419
+ [renamable, setMedia, temp.media, temp.tree, treeWithTempItem.idToPathMap]
346
420
  );
347
421
 
348
422
  const handleAddFolder = useCallback(
@@ -650,10 +724,12 @@ export const MediaCollection = memo(
650
724
  onSelect={(action) => handleMenuAction(action, selectedMediaItems)}
651
725
  selected={selected}
652
726
  onOpenChange={onOpenChange}
727
+ variant={viewType === "grid" ? "normal" : "bare"}
653
728
  style={{
654
729
  backgroundColor: selected
655
- ? cssVars.colors.primaryPastel
730
+ ? cssVars.colors.selectedListItemBackground
656
731
  : "transparent",
732
+ color: selected ? cssVars.colors.selectedListItemText : undefined,
657
733
  }}
658
734
  />
659
735
  );
@@ -662,6 +738,7 @@ export const MediaCollection = memo(
662
738
  handleMenuAction,
663
739
  renderActionProp,
664
740
  selectedMediaItems,
741
+ viewType,
665
742
  ]);
666
743
 
667
744
  useImperativeHandle(ref, () => ({
@@ -682,12 +759,14 @@ export const MediaCollection = memo(
682
759
  <FileExplorerLayout
683
760
  title={title ?? rootMediaItemName}
684
761
  right={
685
- <FileExplorerUploadButton
686
- showUploadButton={showUploadButton}
687
- onUpload={() => handleUpload(rootMediaItem.id)}
688
- >
689
- {right}
690
- </FileExplorerUploadButton>
762
+ !readOnly && (
763
+ <FileExplorerUploadButton
764
+ showUploadButton={showUploadButton}
765
+ onUpload={() => handleUpload(rootMediaItem.id)}
766
+ >
767
+ {right}
768
+ </FileExplorerUploadButton>
769
+ )
691
770
  }
692
771
  className={className}
693
772
  >
@@ -696,7 +775,7 @@ export const MediaCollection = memo(
696
775
  scrollable={scrollable}
697
776
  items={visibleItems}
698
777
  viewType={viewType}
699
- size={gridSize}
778
+ size={size}
700
779
  getId={(file) => file.id}
701
780
  getName={(file) => {
702
781
  if (file.id === tempItem?.[1].id) {
@@ -729,14 +808,19 @@ export const MediaCollection = memo(
729
808
  onRename={onRename}
730
809
  renamable={renamable}
731
810
  selectedIds={selectedIds}
732
- renderThumbnail={(props) => <MediaThumbnailInternal {...props} />}
811
+ renderThumbnail={(props) => (
812
+ <MediaThumbnailInternal
813
+ {...props}
814
+ path={tree.idToPathMap.get(props.item.id)}
815
+ />
816
+ )}
733
817
  renderAction={renderAction}
734
818
  renderDetail={(file, selected) => {
735
819
  if (file.kind !== "asset") return null;
736
820
  const asset = assets.find((a) => a.id === file.assetId);
737
821
  if (!asset) return null;
738
822
  return (
739
- <FileExplorerDetail selected={selected}>
823
+ <FileExplorerDetail selected={selected} size={size}>
740
824
  {(asset.size / 1024).toFixed(1)}KB
741
825
  </FileExplorerDetail>
742
826
  );
@@ -763,11 +847,11 @@ export const MediaCollection = memo(
763
847
  if (position !== "inside" || targetItem.kind === "asset") {
764
848
  return false;
765
849
  }
766
- const sourcePath = tree.findIndexPath(
850
+ const sourcePath = tree.findPath(
767
851
  rootMediaItem,
768
852
  (item) => item.id === sourceItem.id
769
853
  );
770
- const targetPath = tree.findIndexPath(
854
+ const targetPath = tree.findPath(
771
855
  rootMediaItem,
772
856
  (item) => item.id === targetItem.id
773
857
  );
@@ -0,0 +1,139 @@
1
+ import { MediaMap } from "@noya-app/noya-schemas";
2
+ import { expect, it } from "bun:test";
3
+ import { renameMediaItemAndDescendantPaths } from "../utils/files";
4
+ import {
5
+ createMediaAsset,
6
+ createMediaFolder,
7
+ createMediaItemTree,
8
+ } from "../utils/mediaItemTree";
9
+
10
+ it("should rename a folder with no children", () => {
11
+ const dir1 = createMediaFolder();
12
+ const media: MediaMap = { dir1 };
13
+
14
+ const tree = createMediaItemTree(media);
15
+
16
+ const result = renameMediaItemAndDescendantPaths({
17
+ newName: "renamed-dir",
18
+ selectedItemPath: "dir1",
19
+ media,
20
+ tree,
21
+ });
22
+
23
+ expect(result).toEqual({ "renamed-dir": dir1 });
24
+ expect(result).not.toHaveProperty("dir1");
25
+ });
26
+
27
+ it("should rename a folder and update paths of its immediate children", () => {
28
+ const dir1 = createMediaFolder();
29
+ const child1 = createMediaAsset({ assetId: "asset1" });
30
+ const child2 = createMediaFolder();
31
+ const media: MediaMap = {
32
+ dir1,
33
+ "dir1/child1": child1,
34
+ "dir1/child2": child2,
35
+ };
36
+ const tree = createMediaItemTree(media);
37
+
38
+ const result = renameMediaItemAndDescendantPaths({
39
+ newName: "renamed-dir",
40
+ selectedItemPath: "dir1",
41
+ media,
42
+ tree,
43
+ });
44
+
45
+ expect(result).toEqual({
46
+ "renamed-dir": dir1,
47
+ "renamed-dir/child1": child1,
48
+ "renamed-dir/child2": child2,
49
+ });
50
+ expect(result).not.toHaveProperty("dir1");
51
+ expect(result).not.toHaveProperty("dir1/child1");
52
+ expect(result).not.toHaveProperty("dir1/child2");
53
+ });
54
+
55
+ it("should rename a folder and update paths of all nested descendants", () => {
56
+ const dir1 = createMediaFolder();
57
+ const dir2 = createMediaFolder();
58
+ const dir3 = createMediaFolder();
59
+ const asset1 = createMediaAsset({ assetId: "asset1" });
60
+ const media: MediaMap = {
61
+ dir1,
62
+ "dir1/dir2": dir2,
63
+ "dir1/dir2/dir3": dir3,
64
+ "dir1/dir2/dir3/asset1": asset1,
65
+ };
66
+ const tree = createMediaItemTree(media);
67
+
68
+ const result = renameMediaItemAndDescendantPaths({
69
+ newName: "renamed-dir",
70
+ selectedItemPath: "dir1",
71
+ media,
72
+ tree,
73
+ });
74
+
75
+ expect(result).toEqual({
76
+ "renamed-dir": dir1,
77
+ "renamed-dir/dir2": dir2,
78
+ "renamed-dir/dir2/dir3": dir3,
79
+ "renamed-dir/dir2/dir3/asset1": asset1,
80
+ });
81
+ expect(result).not.toHaveProperty("dir1");
82
+ expect(result).not.toHaveProperty("dir1/dir2");
83
+ expect(result).not.toHaveProperty("dir1/dir2/dir3");
84
+ expect(result).not.toHaveProperty("dir1/dir2/dir3/asset1");
85
+ });
86
+
87
+ it("should rename a file (asset) correctly", () => {
88
+ const dir1 = createMediaFolder();
89
+ const asset1 = createMediaAsset({ assetId: "asset1" });
90
+ const asset2 = createMediaAsset({ assetId: "asset2" });
91
+ const media: MediaMap = {
92
+ dir1,
93
+ "dir1/asset1": asset1,
94
+ "dir1/asset2": asset2,
95
+ };
96
+ const tree = createMediaItemTree(media);
97
+
98
+ const result = renameMediaItemAndDescendantPaths({
99
+ newName: "renamed-asset.png",
100
+ selectedItemPath: "dir1/asset1",
101
+ media,
102
+ tree,
103
+ });
104
+
105
+ expect(result).toEqual({
106
+ dir1,
107
+ "dir1/renamed-asset.png": asset1,
108
+ "dir1/asset2": asset2,
109
+ });
110
+ expect(result).not.toHaveProperty("dir1/asset1");
111
+ });
112
+
113
+ it("should preserve other unrelated paths when renaming", () => {
114
+ const dir1 = createMediaFolder();
115
+ const dir2 = createMediaFolder();
116
+ const asset1 = createMediaAsset({ assetId: "asset1" });
117
+ const asset2 = createMediaAsset({ assetId: "asset2" });
118
+ const media: MediaMap = {
119
+ dir1,
120
+ dir2,
121
+ "dir1/asset1": asset1,
122
+ "dir2/asset2": asset2,
123
+ };
124
+ const tree = createMediaItemTree(media);
125
+
126
+ const result = renameMediaItemAndDescendantPaths({
127
+ newName: "renamed-dir",
128
+ selectedItemPath: "dir1",
129
+ media,
130
+ tree,
131
+ });
132
+
133
+ expect(result).toEqual({
134
+ "renamed-dir": dir1,
135
+ "renamed-dir/asset1": asset1,
136
+ dir2,
137
+ "dir2/asset2": asset2,
138
+ });
139
+ });
@@ -90,6 +90,7 @@ export const validateMediaItemRename = ({
90
90
  if (newPathExists) return false;
91
91
  return true;
92
92
  };
93
+
93
94
  export const movePathsIntoTarget = ({
94
95
  media,
95
96
  sourceItemPaths,
@@ -135,6 +136,7 @@ export const movePathsIntoTarget = ({
135
136
  }
136
137
  return mediaClone;
137
138
  };
139
+
138
140
  export const moveUpAFolder = ({
139
141
  tree,
140
142
  media,
@@ -144,7 +146,7 @@ export const moveUpAFolder = ({
144
146
  media: MediaMap;
145
147
  selectedIds: string[];
146
148
  }) => {
147
- const indexPath = tree.findIndexPath(
149
+ const indexPath = tree.findPath(
148
150
  rootMediaItem,
149
151
  (item) => item.id === selectedIds[0]
150
152
  );
@@ -168,6 +170,7 @@ export const moveUpAFolder = ({
168
170
  tree,
169
171
  });
170
172
  };
173
+
171
174
  export const getDepthMap = (
172
175
  item: MediaItem,
173
176
  tree: MediaItemTree,
@@ -183,6 +186,7 @@ export const getDepthMap = (
183
186
  });
184
187
  return depthMap;
185
188
  };
189
+
186
190
  export const updateExpandedMap = ({
187
191
  item,
188
192
  expanded,
@@ -211,6 +215,7 @@ export const updateExpandedMap = ({
211
215
  inner(item, expanded);
212
216
  return newExpandedMap;
213
217
  };
218
+
214
219
  export const deleteMediaItems = ({
215
220
  selectedIds,
216
221
  media,
@@ -235,6 +240,7 @@ export const deleteMediaItems = ({
235
240
  Object.entries(media).filter(([key]) => !itemKeysToDelete.has(key))
236
241
  );
237
242
  };
243
+
238
244
  export const moveMediaInsideFolder = ({
239
245
  sourceItemIds,
240
246
  targetItemId,
@@ -260,10 +266,11 @@ export const moveMediaInsideFolder = ({
260
266
  tree,
261
267
  });
262
268
  };
269
+
263
270
  export const getParentDirectories = (mediaMap: MediaMap, folderId: string) => {
264
271
  const tree = createMediaItemTree(mediaMap);
265
272
 
266
- const indexPath = tree.findIndexPath(
273
+ const indexPath = tree.findPath(
267
274
  rootMediaItem,
268
275
  (item) => item.id === folderId
269
276
  );
@@ -272,3 +279,41 @@ export const getParentDirectories = (mediaMap: MediaMap, folderId: string) => {
272
279
 
273
280
  return tree.accessPath(rootMediaItem, indexPath);
274
281
  };
282
+
283
+ export const renameMediaItemAndDescendantPaths = ({
284
+ newName,
285
+ selectedItemPath,
286
+ media,
287
+ tree,
288
+ }: {
289
+ newName: string;
290
+ selectedItemPath: string;
291
+ media: MediaMap;
292
+ tree: MediaItemTree;
293
+ }) => {
294
+ const mediaClone = { ...media };
295
+ const selectedItem = mediaClone[selectedItemPath];
296
+ if (!selectedItem) return mediaClone;
297
+
298
+ const parentPath = path.dirname(selectedItemPath);
299
+ const newItemPath = path.join(parentPath, newName);
300
+
301
+ const descendants = tree
302
+ .flat(selectedItem)
303
+ .map((item) => tree.idToPathMap.get(item.id));
304
+
305
+ // Update all descendants' paths
306
+ for (const descendantPath of descendants) {
307
+ if (!descendantPath) continue;
308
+
309
+ // Use the same approach as movePathsIntoTarget
310
+ const newDescendantPath = descendantPath.replace(
311
+ selectedItemPath,
312
+ newItemPath
313
+ );
314
+ mediaClone[newDescendantPath] = mediaClone[descendantPath];
315
+ delete mediaClone[descendantPath];
316
+ }
317
+
318
+ return mediaClone;
319
+ };
package/tsup.config.ts CHANGED
@@ -1,4 +1,3 @@
1
- import fixReactVirtualized from "esbuild-plugin-react-virtualized";
2
1
  import { defineConfig } from "tsup";
3
2
 
4
3
  export default defineConfig({
@@ -6,8 +5,7 @@ export default defineConfig({
6
5
  format: ["cjs", "esm"],
7
6
  dts: true,
8
7
  sourcemap: true,
9
- noExternal: ["react-virtualized", "@noya-app/noya-designsystem/index.css"],
10
- esbuildPlugins: [fixReactVirtualized],
8
+ noExternal: ["@noya-app/noya-designsystem/index.css"],
11
9
  // https://github.com/egoist/tsup/issues/619
12
10
  splitting: false,
13
11
  });