|
1 | 1 | "use client"; |
2 | 2 |
|
3 | 3 | import { useRef, useEffect } from "react"; |
4 | | -import { overwriteFile, UrlString } from "@inrupt/solid-client"; |
| 4 | +import { overwriteFile, createContainerAt, UrlString } from "@inrupt/solid-client"; |
5 | 5 | import toast from "react-hot-toast"; |
6 | | -import { getAuthenticatedSession } from "../lib/helpers"; |
| 6 | +import { getAuthenticatedSession, sanitizeResourceName, ensureTrailingSlash } from "../lib/helpers"; |
7 | 7 |
|
8 | 8 | interface FileUploadHandlerProps { |
9 | 9 | currentContainerUrl: string | null; |
10 | 10 | onUploadComplete?: () => void; |
11 | 11 | triggerUpload?: number; |
| 12 | + triggerFolderUpload?: number; |
12 | 13 | } |
13 | 14 |
|
14 | 15 | export default function FileUploadHandler({ |
15 | 16 | currentContainerUrl, |
16 | 17 | onUploadComplete, |
17 | 18 | triggerUpload, |
| 19 | + triggerFolderUpload, |
18 | 20 | }: FileUploadHandlerProps) { |
19 | 21 | const fileInputRef = useRef<HTMLInputElement>(null); |
| 22 | + const folderInputRef = useRef<HTMLInputElement>(null); |
20 | 23 |
|
21 | 24 | useEffect(() => { |
22 | 25 | if (triggerUpload && triggerUpload > 0 && fileInputRef.current) { |
23 | 26 | fileInputRef.current.click(); |
24 | 27 | } |
25 | 28 | }, [triggerUpload]); |
26 | 29 |
|
| 30 | + useEffect(() => { |
| 31 | + if (triggerFolderUpload && triggerFolderUpload > 0 && folderInputRef.current) { |
| 32 | + folderInputRef.current.click(); |
| 33 | + } |
| 34 | + }, [triggerFolderUpload]); |
| 35 | + |
27 | 36 | const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { |
28 | 37 | const files = e.target.files; |
29 | 38 | if (!files || files.length === 0) return; |
@@ -103,14 +112,150 @@ export default function FileUploadHandler({ |
103 | 112 | } |
104 | 113 | }; |
105 | 114 |
|
| 115 | + const handleFolderChange = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| 116 | + const files = e.target.files; |
| 117 | + if (!files || files.length === 0) return; |
| 118 | + |
| 119 | + if (!currentContainerUrl) { |
| 120 | + toast.error("Please select a storage first"); |
| 121 | + e.target.value = ""; |
| 122 | + return; |
| 123 | + } |
| 124 | + |
| 125 | + let fetchFn: typeof fetch; |
| 126 | + try { |
| 127 | + ({ fetch: fetchFn } = getAuthenticatedSession()); |
| 128 | + } catch (error) { |
| 129 | + toast.error("Not authenticated"); |
| 130 | + e.target.value = ""; |
| 131 | + return; |
| 132 | + } |
| 133 | + |
| 134 | + const uploadPromises: Promise<void>[] = []; |
| 135 | + const uploadedFiles: string[] = []; |
| 136 | + const failedFiles: string[] = []; |
| 137 | + const createdFolders = new Set<string>(); |
| 138 | + |
| 139 | + // Process files maintaining folder structure |
| 140 | + for (let i = 0; i < files.length; i++) { |
| 141 | + const file = files[i]; |
| 142 | + |
| 143 | + const relativePath = (file as any).webkitRelativePath || file.name; |
| 144 | + const pathParts = relativePath.split("/").filter(Boolean); |
| 145 | + |
| 146 | + if (pathParts.length === 0) continue; |
| 147 | + |
| 148 | + // The first part is the folder name, rest is the path inside |
| 149 | + const folderName = sanitizeResourceName(pathParts[0]); |
| 150 | + const filePath = pathParts.slice(1); // Path inside the folder |
| 151 | + |
| 152 | + // Create the base folder container if it doesn't exist |
| 153 | + const baseFolderUrl = ensureTrailingSlash( |
| 154 | + currentContainerUrl.endsWith("/") |
| 155 | + ? `${currentContainerUrl}${encodeURIComponent(folderName)}` |
| 156 | + : `${currentContainerUrl}/${encodeURIComponent(folderName)}` |
| 157 | + ); |
| 158 | + |
| 159 | + if (!createdFolders.has(baseFolderUrl)) { |
| 160 | + try { |
| 161 | + await createContainerAt(baseFolderUrl as UrlString, { fetch: fetchFn }); |
| 162 | + createdFolders.add(baseFolderUrl); |
| 163 | + } catch (error) { |
| 164 | + console.error(`Failed to create folder ${folderName}:`, error); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + // Build the full path for this file |
| 169 | + let currentPath = baseFolderUrl; |
| 170 | + |
| 171 | + // Create intermediate folders if needed |
| 172 | + for (let j = 0; j < filePath.length - 1; j++) { |
| 173 | + const folderPart = sanitizeResourceName(filePath[j]); |
| 174 | + const encodedFolderPart = encodeURIComponent(folderPart); |
| 175 | + currentPath = ensureTrailingSlash(`${currentPath}${encodedFolderPart}`); |
| 176 | + |
| 177 | + if (!createdFolders.has(currentPath)) { |
| 178 | + try { |
| 179 | + await createContainerAt(currentPath as UrlString, { fetch: fetchFn }); |
| 180 | + createdFolders.add(currentPath); |
| 181 | + } catch (error) { |
| 182 | + console.error(`Failed to create subfolder ${folderPart}:`, error); |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + // Upload the file |
| 188 | + const fileName = sanitizeResourceName(filePath[filePath.length - 1]); |
| 189 | + const fileUrl = `${currentPath}${encodeURIComponent(fileName)}`; |
| 190 | + |
| 191 | + const uploadPromise = overwriteFile( |
| 192 | + fileUrl as UrlString, |
| 193 | + file, |
| 194 | + { |
| 195 | + contentType: file.type || "application/octet-stream", |
| 196 | + fetch: fetchFn, |
| 197 | + } |
| 198 | + ) |
| 199 | + .then(() => { |
| 200 | + uploadedFiles.push(relativePath); |
| 201 | + }) |
| 202 | + .catch((error) => { |
| 203 | + console.error(`Failed to upload ${relativePath}:`, error); |
| 204 | + failedFiles.push(relativePath); |
| 205 | + }); |
| 206 | + |
| 207 | + uploadPromises.push(uploadPromise); |
| 208 | + } |
| 209 | + |
| 210 | + try { |
| 211 | + await Promise.all(uploadPromises); |
| 212 | + |
| 213 | + if (uploadedFiles.length > 0) { |
| 214 | + const message = |
| 215 | + uploadedFiles.length === 1 |
| 216 | + ? `File uploaded successfully` |
| 217 | + : `${uploadedFiles.length} files uploaded successfully`; |
| 218 | + toast.success(message); |
| 219 | + } |
| 220 | + |
| 221 | + if (failedFiles.length > 0) { |
| 222 | + const message = |
| 223 | + failedFiles.length === 1 |
| 224 | + ? `Failed to upload "${failedFiles[0]}"` |
| 225 | + : `Failed to upload ${failedFiles.length} files`; |
| 226 | + toast.error(message); |
| 227 | + } |
| 228 | + |
| 229 | + if (uploadedFiles.length > 0 && onUploadComplete) { |
| 230 | + await new Promise((resolve) => setTimeout(resolve, 200)); |
| 231 | + onUploadComplete(); |
| 232 | + } |
| 233 | + } catch (error) { |
| 234 | + console.error("Upload error:", error); |
| 235 | + toast.error("Failed to upload folder"); |
| 236 | + } finally { |
| 237 | + e.target.value = ""; |
| 238 | + } |
| 239 | + }; |
| 240 | + |
106 | 241 | return ( |
107 | | - <input |
108 | | - ref={fileInputRef} |
109 | | - type="file" |
110 | | - multiple |
111 | | - className="hidden" |
112 | | - onChange={handleFileChange} |
113 | | - /> |
| 242 | + <> |
| 243 | + <input |
| 244 | + ref={fileInputRef} |
| 245 | + type="file" |
| 246 | + multiple |
| 247 | + className="hidden" |
| 248 | + onChange={handleFileChange} |
| 249 | + /> |
| 250 | + <input |
| 251 | + ref={folderInputRef} |
| 252 | + type="file" |
| 253 | + {...({ webkitdirectory: "" } as any)} |
| 254 | + multiple |
| 255 | + className="hidden" |
| 256 | + onChange={handleFolderChange} |
| 257 | + /> |
| 258 | + </> |
114 | 259 | ); |
115 | 260 | } |
116 | 261 |
|
0 commit comments