11"use client" ;
22
33import { useState , useEffect } from "react" ;
4+ import toast from "react-hot-toast" ;
5+ import {
6+ getFile ,
7+ overwriteFile ,
8+ createContainerAt ,
9+ getSolidDataset ,
10+ getContainedResourceUrlAll ,
11+ UrlString ,
12+ } from "@inrupt/solid-client" ;
413import { useSearchParams , useRouter } from "next/navigation" ;
514import AuthWrapper from "./AuthWrapper" ;
615import Header from "./Header" ;
@@ -13,10 +22,178 @@ import RenameDialog from "./RenameDialog";
1322import PreviewModal from "./PreviewModal" ;
1423import FileUploadHandler from "./FileUploadHandler" ;
1524import { FileItemData } from "./FileItem" ;
16- import { useSolidStorages , useBrowseStorage } from "../lib/hooks" ;
17- import { filterProfileItems , buildBreadcrumbItems } from "../lib/helpers" ;
1825import LoadingSpinner from "./shared/LoadingSpinner" ;
1926import ErrorDisplay from "./shared/ErrorDisplay" ;
27+ import { useSolidStorages , useBrowseStorage } from "../lib/hooks" ;
28+ import {
29+ buildBreadcrumbItems ,
30+ getAuthenticatedSession ,
31+ getDisplayNameFromMeta ,
32+ updateMetaFile ,
33+ } from "../lib/helpers" ;
34+
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+
20197
21198export default function FileManager ( ) {
22199 const searchParams = useSearchParams ( ) ;
@@ -26,6 +203,20 @@ export default function FileManager() {
26203 const [ currentPath , setCurrentPath ] = useState < string > ( "/" ) ;
27204 const [ selectedFileIds , setSelectedFileIds ] = useState < string [ ] > ( [ ] ) ;
28205 const [ isInitialized , setIsInitialized ] = useState ( false ) ;
206+ const [ refreshKey , setRefreshKey ] = useState ( 0 ) ;
207+
208+ const [ permissionsDialogOpen , setPermissionsDialogOpen ] = useState ( false ) ;
209+ const [ selectedFileForPermissions , setSelectedFileForPermissions ] =
210+ useState < FileItemData | null > ( null ) ;
211+ const [ permissions , setPermissions ] = useState < Permission [ ] > ( [ ] ) ;
212+ const [ sidebarOpen , setSidebarOpen ] = useState ( false ) ;
213+ const [ showNewFolderDialog , setShowNewFolderDialog ] = useState ( false ) ;
214+ const [ fileUploadTrigger , setFileUploadTrigger ] = useState ( 0 ) ;
215+ const [ showRenameDialog , setShowRenameDialog ] = useState ( false ) ;
216+ const [ fileToRename , setFileToRename ] = useState < FileItemData | null > ( null ) ;
217+ const [ showPreviewModal , setShowPreviewModal ] = useState ( false ) ;
218+ const [ fileToPreview , setFileToPreview ] = useState < FileItemData | null > ( null ) ;
219+
29220 const [ savedUrl , setSavedUrl ] = useState < string | null > ( ( ) => {
30221 if ( typeof window === "undefined" ) return null ;
31222
@@ -180,19 +371,8 @@ export default function FileManager() {
180371 : currentPath
181372 : null ;
182373
183- const [ refreshKey , setRefreshKey ] = useState ( 0 ) ;
184374 const { files : browsedFiles , isLoading : isLoadingFiles , error : browseError } = useBrowseStorage ( containerUrlToBrowse , refreshKey ) ;
185- const [ permissionsDialogOpen , setPermissionsDialogOpen ] = useState ( false ) ;
186- const [ selectedFileForPermissions , setSelectedFileForPermissions ] =
187- useState < FileItemData | null > ( null ) ;
188- const [ permissions , setPermissions ] = useState < Permission [ ] > ( [ ] ) ;
189- const [ sidebarOpen , setSidebarOpen ] = useState ( false ) ;
190- const [ showNewFolderDialog , setShowNewFolderDialog ] = useState ( false ) ;
191- const [ fileUploadTrigger , setFileUploadTrigger ] = useState ( 0 ) ;
192- const [ showRenameDialog , setShowRenameDialog ] = useState ( false ) ;
193- const [ fileToRename , setFileToRename ] = useState < FileItemData | null > ( null ) ;
194- const [ showPreviewModal , setShowPreviewModal ] = useState ( false ) ;
195- const [ fileToPreview , setFileToPreview ] = useState < FileItemData | null > ( null ) ;
375+
196376
197377 const handleFolderCreated = ( ) => {
198378 setRefreshKey ( ( prev ) => prev + 1 ) ;
@@ -211,24 +391,42 @@ export default function FileManager() {
211391 setRefreshKey ( ( prev ) => prev + 1 ) ;
212392 } ;
213393
394+ const handleCopy = async ( file : FileItemData ) => {
395+ if ( ! file ) {
396+ return ;
397+ }
398+
399+ const toastId = toast . loading ( `Copying "${ file . name } "...` ) ;
400+ try {
401+ const { fetch : fetchFn } = getAuthenticatedSession ( ) ;
402+ if ( file . type === "folder" ) {
403+ await copyFolderResource ( file , fetchFn ) ;
404+ } else {
405+ await copyFileResource ( file , fetchFn ) ;
406+ }
407+ toast . success ( `Copied "${ file . name } "` , { id : toastId } ) ;
408+ setRefreshKey ( ( prev ) => prev + 1 ) ;
409+ } catch ( error ) {
410+ console . error ( "Failed to copy resource:" , error ) ;
411+ toast . error (
412+ error instanceof Error ? `Failed to copy: ${ error . message } ` : "Failed to copy resource" ,
413+ { id : toastId }
414+ ) ;
415+ }
416+ } ;
417+
214418 const handlePreview = ( file : FileItemData ) => {
215419 setFileToPreview ( file ) ;
216420 setShowPreviewModal ( true ) ;
217421 } ;
218422
219- console . log ( "storages:" , storages ) ;
220-
221-
222- // const storageFiles: FileItemData[] = filterProfileItems(storages).map((storage) => ({
223423 const storageFiles : FileItemData [ ] = storages . map ( ( storage ) => ( {
224424 id : storage . id ,
225425 name : storage . name ,
226426 type : "folder" as const ,
227427 url : storage . url ,
228428 } ) ) ;
229- console . log ( "storageFiles:" , storageFiles ) ;
230429
231- // const filteredFiles = filterProfileItems(browsedFiles);
232430 const displayFiles = selectedStorageId ? browsedFiles : storageFiles ;
233431
234432 const selectedStorage = storages . find ( ( s ) => s . id === selectedStorageId ) ;
@@ -391,21 +589,26 @@ export default function FileManager() {
391589 onFileUploadClick = { ( ) => setFileUploadTrigger ( ( prev ) => prev + 1 ) }
392590 />
393591 < main className = "flex flex-1 flex-col overflow-hidden" >
394- < Breadcrumb items = { breadcrumbItems } onNavigate = { handleBreadcrumbNavigate } />
592+ < div className = "flex-shrink-0" >
593+ < Breadcrumb items = { breadcrumbItems } onNavigate = { handleBreadcrumbNavigate } />
594+ </ div >
395595 { isBrowsing ? (
396596 < div className = "flex flex-1 items-center justify-center" >
397597 < LoadingSpinner size = "md" text = "Loading folder contents..." />
398598 </ div >
399599 ) : (
400- < FileList
401- files = { displayFiles }
402- currentPath = { currentPath }
403- onFileSelect = { handleFileSelect }
404- onFileDoubleClick = { handleFileDoubleClick }
405- onFileRename = { handleRename }
406- onFilePreview = { handlePreview }
407- selectedFileIds = { selectedFileIds }
408- />
600+ < div className = "flex-1 min-h-0 overflow-hidden" >
601+ < FileList
602+ files = { displayFiles }
603+ currentPath = { currentPath }
604+ onFileSelect = { handleFileSelect }
605+ onFileDoubleClick = { handleFileDoubleClick }
606+ onFileRename = { handleRename }
607+ onFilePreview = { handlePreview }
608+ onFileCopy = { handleCopy }
609+ selectedFileIds = { selectedFileIds }
610+ />
611+ </ div >
409612 ) }
410613 </ main >
411614 </ div >
@@ -455,4 +658,3 @@ export default function FileManager() {
455658 </ AuthWrapper >
456659 ) ;
457660}
458-
0 commit comments