Skip to content

Commit 751de7c

Browse files
feat: file upload
1 parent 43869b4 commit 751de7c

5 files changed

Lines changed: 275 additions & 18 deletions

File tree

app/components/FileManager.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default function FileManager() {
4848
const [sidebarOpen, setSidebarOpen] = useState(false);
4949
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
5050
const [fileUploadTrigger, setFileUploadTrigger] = useState(0);
51+
const [folderUploadTrigger, setFolderUploadTrigger] = useState(0);
5152
const [showRenameDialog, setShowRenameDialog] = useState(false);
5253
const [fileToRename, setFileToRename] = useState<FileItemData | null>(null);
5354
const [showPreviewModal, setShowPreviewModal] = useState(false);
@@ -532,6 +533,7 @@ export default function FileManager() {
532533
currentContainerUrl={containerUrlToBrowse}
533534
onNewFolderClick={() => setShowNewFolderDialog(true)}
534535
onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)}
536+
onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)}
535537
/>
536538
<main className="flex flex-1 flex-col overflow-hidden">
537539
<div className="flex-shrink-0">
@@ -622,6 +624,7 @@ export default function FileManager() {
622624
currentContainerUrl={containerUrlToBrowse}
623625
onUploadComplete={handleFileUploaded}
624626
triggerUpload={fileUploadTrigger}
627+
triggerFolderUpload={folderUploadTrigger}
625628
/>
626629
</div>
627630
</AuthWrapper>

app/components/FileUploadHandler.tsx

Lines changed: 154 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,38 @@
11
"use client";
22

33
import { useRef, useEffect } from "react";
4-
import { overwriteFile, UrlString } from "@inrupt/solid-client";
4+
import { overwriteFile, createContainerAt, UrlString } from "@inrupt/solid-client";
55
import toast from "react-hot-toast";
6-
import { getAuthenticatedSession } from "../lib/helpers";
6+
import { getAuthenticatedSession, sanitizeResourceName, ensureTrailingSlash } from "../lib/helpers";
77

88
interface FileUploadHandlerProps {
99
currentContainerUrl: string | null;
1010
onUploadComplete?: () => void;
1111
triggerUpload?: number;
12+
triggerFolderUpload?: number;
1213
}
1314

1415
export default function FileUploadHandler({
1516
currentContainerUrl,
1617
onUploadComplete,
1718
triggerUpload,
19+
triggerFolderUpload,
1820
}: FileUploadHandlerProps) {
1921
const fileInputRef = useRef<HTMLInputElement>(null);
22+
const folderInputRef = useRef<HTMLInputElement>(null);
2023

2124
useEffect(() => {
2225
if (triggerUpload && triggerUpload > 0 && fileInputRef.current) {
2326
fileInputRef.current.click();
2427
}
2528
}, [triggerUpload]);
2629

30+
useEffect(() => {
31+
if (triggerFolderUpload && triggerFolderUpload > 0 && folderInputRef.current) {
32+
folderInputRef.current.click();
33+
}
34+
}, [triggerFolderUpload]);
35+
2736
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
2837
const files = e.target.files;
2938
if (!files || files.length === 0) return;
@@ -103,14 +112,150 @@ export default function FileUploadHandler({
103112
}
104113
};
105114

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+
106241
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+
</>
114259
);
115260
}
116261

app/components/NewMenuButton.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ interface NewMenuButtonProps {
99
currentContainerUrl: string | null;
1010
onNewFolderClick?: () => void;
1111
onFileUploadClick?: () => void;
12+
onFolderUploadClick?: () => void;
1213
}
1314

1415
export default function NewMenuButton({
1516
currentContainerUrl,
1617
onNewFolderClick,
1718
onFileUploadClick,
19+
onFolderUploadClick,
1820
}: NewMenuButtonProps) {
1921
const [showNewMenu, setShowNewMenu] = useState(false);
2022
const newMenuRef = useRef<HTMLDivElement>(null);
@@ -40,6 +42,13 @@ export default function NewMenuButton({
4042
}
4143
};
4244

45+
const handleFolderUpload = () => {
46+
setShowNewMenu(false);
47+
if (onFolderUploadClick) {
48+
onFolderUploadClick();
49+
}
50+
};
51+
4352
return (
4453
<div className="relative mb-4 px-2">
4554
<Button
@@ -77,12 +86,21 @@ export default function NewMenuButton({
7786
<button
7887
type="button"
7988
onClick={handleFileUpload}
80-
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer"
89+
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-100 transition-colors border-b border-gray-100 cursor-pointer"
8190
role="menuitem"
8291
>
8392
<ArrowUpTrayIcon className="h-5 w-5 text-gray-500" />
8493
<span>File Upload</span>
8594
</button>
95+
<button
96+
type="button"
97+
onClick={handleFolderUpload}
98+
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer"
99+
role="menuitem"
100+
>
101+
<FolderPlusIcon className="h-5 w-5 text-gray-500" />
102+
<span>Folder Upload</span>
103+
</button>
86104
</div>
87105
)}
88106
</div>

0 commit comments

Comments
 (0)