22
33import { useState , useEffect } from "react" ;
44import toast from "react-hot-toast" ;
5- import {
6- getFile ,
7- overwriteFile ,
8- createContainerAt ,
9- getSolidDataset ,
10- getContainedResourceUrlAll ,
11- UrlString ,
12- } from "@inrupt/solid-client" ;
135import { useSearchParams , useRouter } from "next/navigation" ;
146import AuthWrapper from "./AuthWrapper" ;
157import Header from "./Header" ;
@@ -20,6 +12,7 @@ import PermissionsDialog, { Permission } from "./PermissionsDialog";
2012import NewFolderDialog from "./NewFolderDialog" ;
2113import RenameDialog from "./RenameDialog" ;
2214import PreviewModal from "./PreviewModal" ;
15+ import MoveDialog from "./MoveDialog" ;
2316import FileUploadHandler from "./FileUploadHandler" ;
2417import { FileItemData } from "./FileItem" ;
2518import LoadingSpinner from "./shared/LoadingSpinner" ;
@@ -28,172 +21,10 @@ import { useSolidStorages, useBrowseStorage } from "../lib/hooks";
2821import {
2922 buildBreadcrumbItems ,
3023 getAuthenticatedSession ,
31- getDisplayNameFromMeta ,
32- updateMetaFile ,
24+ copyFileResource ,
25+ copyFolderResource ,
3326} from "../lib/helpers" ;
3427
35- const INVALID_NAME_CHARS = / [ < > : " / \\ | ? * ] / g;
36-
37- const sanitizeResourceName = ( name : string ) : string => {
38- const sanitized = name . replace ( INVALID_NAME_CHARS , "" ) . trim ( ) ;
39- return sanitized || "Untitled" ;
40- } ;
41-
42- const decodeResourceNameFromUrl = ( resourceUrl : string ) : string => {
43- try {
44- const urlObj = new URL ( resourceUrl ) ;
45- const segments = urlObj . pathname . split ( "/" ) . filter ( Boolean ) ;
46- if ( segments . length === 0 ) {
47- return urlObj . hostname ;
48- }
49- const lastSegment = resourceUrl . endsWith ( "/" ) ? segments [ segments . length - 1 ] : segments [ segments . length - 1 ] ;
50- return decodeURIComponent ( lastSegment ) ;
51- } catch {
52- return resourceUrl ;
53- }
54- } ;
55-
56- const ensureTrailingSlash = ( url : string ) : string => ( url . endsWith ( "/" ) ? url : `${ url } /` ) ;
57-
58- const getParentContainerUrl = ( resourceUrl : string ) : string => {
59- try {
60- const urlObj = new URL ( resourceUrl ) ;
61- const segments = urlObj . pathname . split ( "/" ) . filter ( Boolean ) ;
62- if ( segments . length === 0 ) {
63- return `${ urlObj . origin } /` ;
64- }
65- if ( ! resourceUrl . endsWith ( "/" ) ) {
66- segments . pop ( ) ;
67- } else if ( segments . length > 0 ) {
68- segments . pop ( ) ;
69- }
70- const parentPath = segments . length ? `/${ segments . join ( "/" ) } /` : "/" ;
71- return `${ urlObj . origin } ${ parentPath } ` ;
72- } catch {
73- return resourceUrl ;
74- }
75- } ;
76-
77- const shouldSkipResourceCopy = ( resourceUrl : string ) : boolean => {
78- return resourceUrl . endsWith ( ".meta" ) || resourceUrl . endsWith ( ".acl" ) ;
79- } ;
80-
81- const resourceExists = async ( url : string , fetchFn : typeof fetch ) : Promise < boolean > => {
82- try {
83- const response = await fetchFn ( url , { method : "HEAD" } ) ;
84- if ( response . status === 404 ) {
85- return false ;
86- }
87- if ( response . status >= 200 && response . status < 300 ) {
88- return true ;
89- }
90- // For other statuses (401, 403, 405, etc.) assume the resource exists to avoid collisions
91- return true ;
92- } catch {
93- return false ;
94- }
95- } ;
96-
97- const generateCopyTarget = async (
98- parentUrl : string ,
99- desiredName : string ,
100- isContainer : boolean ,
101- fetchFn : typeof fetch
102- ) : Promise < { targetUrl : string ; displayName : string } > => {
103- const parentWithSlash = ensureTrailingSlash ( parentUrl ) ;
104- let attempt = 0 ;
105-
106- while ( attempt < 100 ) {
107- const candidateDisplayName = attempt === 0 ? desiredName : `${ desiredName } (${ attempt } )` ;
108- const candidatePathName = sanitizeResourceName ( candidateDisplayName ) ;
109- const encodedName = encodeURIComponent ( candidatePathName ) ;
110- const candidateUrl = isContainer ? `${ parentWithSlash } ${ encodedName } /` : `${ parentWithSlash } ${ encodedName } ` ;
111- const exists = await resourceExists ( candidateUrl , fetchFn ) ;
112- if ( ! exists ) {
113- return { targetUrl : candidateUrl , displayName : candidateDisplayName } ;
114- }
115- attempt += 1 ;
116- }
117-
118- throw new Error ( "Unable to generate a unique name for the copy" ) ;
119- } ;
120-
121- const copyFileFromSource = async (
122- sourceUrl : string ,
123- targetUrl : string ,
124- displayName : string ,
125- fetchFn : typeof fetch ,
126- mimeTypeHint ?: string
127- ) : Promise < void > => {
128- const fileBlob = await getFile ( sourceUrl as UrlString , { fetch : fetchFn } ) ;
129- const contentType = fileBlob . type || mimeTypeHint || "application/octet-stream" ;
130- await overwriteFile ( targetUrl as UrlString , fileBlob , {
131- fetch : fetchFn ,
132- contentType,
133- } ) ;
134- await updateMetaFile ( targetUrl as UrlString , displayName , fetchFn ) ;
135- } ;
136-
137- const copyFolderContents = async (
138- sourceFolderUrl : string ,
139- destinationFolderUrl : string ,
140- fetchFn : typeof fetch
141- ) : Promise < void > => {
142- const dataset = await getSolidDataset ( sourceFolderUrl , { fetch : fetchFn } ) ;
143- const containedResources = getContainedResourceUrlAll ( dataset ) ;
144-
145- for ( const resourceUrl of containedResources ) {
146- if ( shouldSkipResourceCopy ( resourceUrl ) ) {
147- continue ;
148- }
149-
150- if ( resourceUrl . endsWith ( "/" ) ) {
151- const childName = decodeResourceNameFromUrl ( resourceUrl ) ;
152- const encodedChildName = encodeURIComponent ( childName ) ;
153- const childDestination = `${ ensureTrailingSlash ( destinationFolderUrl ) } ${ encodedChildName } /` ;
154-
155- await createContainerAt ( childDestination as UrlString , { fetch : fetchFn } ) ;
156- const childDisplayName =
157- ( await getDisplayNameFromMeta ( resourceUrl , fetchFn ) ) ?? childName ;
158- await updateMetaFile ( childDestination as UrlString , childDisplayName , fetchFn ) ;
159-
160- await copyFolderContents ( resourceUrl , childDestination , fetchFn ) ;
161- } else {
162- const childName = decodeResourceNameFromUrl ( resourceUrl ) ;
163- const encodedChildName = encodeURIComponent ( childName ) ;
164- const childDestination = `${ ensureTrailingSlash ( destinationFolderUrl ) } ${ encodedChildName } ` ;
165- const childDisplayName =
166- ( await getDisplayNameFromMeta ( resourceUrl , fetchFn ) ) ?? childName ;
167- await copyFileFromSource ( resourceUrl , childDestination , childDisplayName , fetchFn ) ;
168- }
169- }
170- } ;
171-
172- const copyFileResource = async ( file : FileItemData , fetchFn : typeof fetch ) : Promise < void > => {
173- const originalLabel =
174- ( await getDisplayNameFromMeta ( file . url , fetchFn ) ) ??
175- file . name ??
176- decodeResourceNameFromUrl ( file . url ) ;
177- const parentUrl = getParentContainerUrl ( file . url ) ;
178- const desiredName = `Copy of ${ originalLabel } ` ;
179- const { targetUrl, displayName } = await generateCopyTarget ( parentUrl , desiredName , false , fetchFn ) ;
180- await copyFileFromSource ( file . url , targetUrl , displayName , fetchFn , file . mimeType ) ;
181- } ;
182-
183- const copyFolderResource = async ( folder : FileItemData , fetchFn : typeof fetch ) : Promise < void > => {
184- const originalLabel =
185- ( await getDisplayNameFromMeta ( folder . url , fetchFn ) ) ??
186- folder . name ??
187- decodeResourceNameFromUrl ( folder . url ) ;
188- const parentUrl = getParentContainerUrl ( folder . url ) ;
189- const desiredName = `Copy of ${ originalLabel } ` ;
190- const { targetUrl, displayName } = await generateCopyTarget ( parentUrl , desiredName , true , fetchFn ) ;
191-
192- await createContainerAt ( targetUrl as UrlString , { fetch : fetchFn } ) ;
193- await updateMetaFile ( targetUrl as UrlString , displayName , fetchFn ) ;
194- await copyFolderContents ( folder . url , targetUrl , fetchFn ) ;
195- } ;
196-
19728
19829export default function FileManager ( ) {
19930 const searchParams = useSearchParams ( ) ;
@@ -216,6 +47,8 @@ export default function FileManager() {
21647 const [ fileToRename , setFileToRename ] = useState < FileItemData | null > ( null ) ;
21748 const [ showPreviewModal , setShowPreviewModal ] = useState ( false ) ;
21849 const [ fileToPreview , setFileToPreview ] = useState < FileItemData | null > ( null ) ;
50+ const [ showMoveDialog , setShowMoveDialog ] = useState ( false ) ;
51+ const [ fileToMove , setFileToMove ] = useState < FileItemData | null > ( null ) ;
21952
22053 const [ savedUrl , setSavedUrl ] = useState < string | null > ( ( ) => {
22154 if ( typeof window === "undefined" ) return null ;
@@ -420,6 +253,15 @@ export default function FileManager() {
420253 setShowPreviewModal ( true ) ;
421254 } ;
422255
256+ const handleMove = ( file : FileItemData ) => {
257+ setFileToMove ( file ) ;
258+ setShowMoveDialog ( true ) ;
259+ } ;
260+
261+ const handleMoved = ( ) => {
262+ setRefreshKey ( ( prev ) => prev + 1 ) ;
263+ } ;
264+
423265 const storageFiles : FileItemData [ ] = storages . map ( ( storage ) => ( {
424266 id : storage . id ,
425267 name : storage . name ,
@@ -429,6 +271,24 @@ export default function FileManager() {
429271
430272 const displayFiles = selectedStorageId ? browsedFiles : storageFiles ;
431273
274+ // Get all available folders for move dialog (storages + browsed folders)
275+ const availableFolders : FileItemData [ ] = [
276+ ...storageFiles ,
277+ ...( selectedStorageId ? browsedFiles . filter ( ( f ) => f . type === "folder" ) : [ ] ) ,
278+ ] ;
279+
280+ // Get current location URL for move dialog
281+ const getCurrentLocationUrl = ( ) : string => {
282+ if ( ! selectedStorageId ) {
283+ return "" ;
284+ }
285+ if ( currentPath === "/" ) {
286+ const storage = storages . find ( ( s ) => s . id === selectedStorageId ) ;
287+ return storage ?. url || "" ;
288+ }
289+ return currentPath ;
290+ } ;
291+
432292 const selectedStorage = storages . find ( ( s ) => s . id === selectedStorageId ) ;
433293 const breadcrumbItems = buildBreadcrumbItems (
434294 selectedStorageId ,
@@ -606,6 +466,7 @@ export default function FileManager() {
606466 onFileRename = { handleRename }
607467 onFilePreview = { handlePreview }
608468 onFileCopy = { handleCopy }
469+ onFileMove = { handleMove }
609470 selectedFileIds = { selectedFileIds }
610471 />
611472 </ div >
@@ -649,6 +510,17 @@ export default function FileManager() {
649510 } }
650511 file = { fileToPreview }
651512 />
513+ < MoveDialog
514+ isOpen = { showMoveDialog }
515+ onClose = { ( ) => {
516+ setShowMoveDialog ( false ) ;
517+ setFileToMove ( null ) ;
518+ } }
519+ file = { fileToMove }
520+ availableFolders = { availableFolders }
521+ currentLocationUrl = { getCurrentLocationUrl ( ) }
522+ onMoved = { handleMoved }
523+ />
652524 < FileUploadHandler
653525 currentContainerUrl = { containerUrlToBrowse }
654526 onUploadComplete = { handleFileUploaded }
0 commit comments