Skip to content

Commit e31ab0b

Browse files
Merge pull request #17 from solid/feat/my-storages
Feature: Implement file and folder download
2 parents 61f1286 + 92da289 commit e31ab0b

7 files changed

Lines changed: 276 additions & 2 deletions

File tree

app/components/FileItem.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface FileItemProps {
2525
onPreview?: (file: FileItemData) => void;
2626
onCopy?: (file: FileItemData) => void;
2727
onMove?: (file: FileItemData) => void;
28+
onDownload?: (file: FileItemData) => void;
2829
isSelected?: boolean;
2930
}
3031

@@ -37,6 +38,7 @@ export default function FileItem({
3738
onPreview,
3839
onCopy,
3940
onMove,
41+
onDownload,
4042
isSelected = false,
4143
}: FileItemProps) {
4244
const [isHovered, setIsHovered] = useState(false);
@@ -131,7 +133,7 @@ export default function FileItem({
131133
position="top-right"
132134
onRename={onRename}
133135
onPreview={onPreview}
134-
onDownload={(f) => console.log("Download:", f.name)}
136+
onDownload={onDownload}
135137
onCopy={onCopy}
136138
onMove={onMove}
137139
onDelete={(f) => console.log("Delete:", f.name)}
@@ -179,7 +181,7 @@ export default function FileItem({
179181
position="right"
180182
onRename={onRename}
181183
onPreview={onPreview}
182-
onDownload={(f) => console.log("Download:", f.name)}
184+
onDownload={onDownload}
183185
onCopy={onCopy}
184186
onMove={onMove}
185187
onDelete={(f) => console.log("Delete:", f.name)}

app/components/FileList.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface FileListProps {
1414
onFilePreview?: (file: FileItemData) => void;
1515
onFileCopy?: (file: FileItemData) => void;
1616
onFileMove?: (file: FileItemData) => void;
17+
onFileDownload?: (file: FileItemData) => void;
1718
selectedFileIds: string[];
1819
}
1920

@@ -28,6 +29,7 @@ export default function FileList({
2829
onFilePreview,
2930
onFileCopy,
3031
onFileMove,
32+
onFileDownload,
3133
selectedFileIds,
3234
}: FileListProps) {
3335
const [view, setView] = useState<"grid" | "list">(() => {
@@ -65,6 +67,7 @@ export default function FileList({
6567
onPreview={onFilePreview}
6668
onCopy={onFileCopy}
6769
onMove={onFileMove}
70+
onDownload={onFileDownload}
6871
isSelected={selectedFileIds.includes(file.id)}
6972
/>
7073
))}
@@ -82,6 +85,7 @@ export default function FileList({
8285
onPreview={onFilePreview}
8386
onCopy={onFileCopy}
8487
onMove={onFileMove}
88+
onDownload={onFileDownload}
8589
isSelected={selectedFileIds.includes(file.id)}
8690
/>
8791
))}

app/components/FileManager.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
getAuthenticatedSession,
2424
copyFileResource,
2525
copyFolderResource,
26+
downloadFile,
27+
downloadFolderAsZip,
2628
} from "../lib/helpers";
2729

2830

@@ -262,6 +264,36 @@ export default function FileManager() {
262264
setRefreshKey((prev) => prev + 1);
263265
};
264266

267+
const handleDownload = async (file: FileItemData) => {
268+
if (!file) {
269+
return;
270+
}
271+
272+
const toastId = toast.loading(
273+
file.type === "folder" ? `Preparing "${file.name}" for download...` : `Downloading "${file.name}"...`
274+
);
275+
276+
try {
277+
const { fetch: fetchFn } = getAuthenticatedSession();
278+
279+
if (file.type === "folder") {
280+
await downloadFolderAsZip(file.url, file.name, fetchFn);
281+
toast.success(`Downloaded "${file.name}.zip"`, { id: toastId });
282+
} else {
283+
await downloadFile(file.url, file.name, fetchFn);
284+
toast.success(`Downloaded "${file.name}"`, { id: toastId });
285+
}
286+
} catch (error) {
287+
console.error("Failed to download resource:", error);
288+
toast.error(
289+
error instanceof Error
290+
? `Failed to download: ${error.message}`
291+
: "Failed to download resource",
292+
{ id: toastId }
293+
);
294+
}
295+
};
296+
265297
const storageFiles: FileItemData[] = storages.map((storage) => ({
266298
id: storage.id,
267299
name: storage.name,
@@ -467,6 +499,7 @@ export default function FileManager() {
467499
onFilePreview={handlePreview}
468500
onFileCopy={handleCopy}
469501
onFileMove={handleMove}
502+
onFileDownload={handleDownload}
470503
selectedFileIds={selectedFileIds}
471504
/>
472505
</div>

app/lib/helpers/downloadUtils.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import JSZip from "jszip";
2+
import { getSolidDataset, getContainedResourceUrlAll, UrlString } from "@inrupt/solid-client";
3+
import { decodeResourceNameFromUrl, ensureTrailingSlash } from "./copyUtils";
4+
5+
/**
6+
* Downloads a single file
7+
*/
8+
export async function downloadFile(
9+
fileUrl: string,
10+
fileName: string,
11+
fetchFn: typeof fetch
12+
): Promise<void> {
13+
try {
14+
console.log("downloadFile: Starting fetch for", fileUrl);
15+
const response = await fetchFn(fileUrl);
16+
17+
if (!response.ok) {
18+
const errorText = await response.text().catch(() => response.statusText);
19+
throw new Error(`Failed to fetch file: ${response.status} ${errorText}`);
20+
}
21+
22+
const blob = await response.blob();
23+
24+
// Create a download link
25+
const url = URL.createObjectURL(blob);
26+
const link = document.createElement("a");
27+
link.href = url;
28+
link.download = fileName;
29+
link.style.display = "none";
30+
document.body.appendChild(link);
31+
32+
// Trigger download immediately
33+
link.click();
34+
35+
// Clean up after a delay to ensure download starts
36+
setTimeout(() => {
37+
document.body.removeChild(link);
38+
URL.revokeObjectURL(url);
39+
}, 100);
40+
} catch (error) {
41+
console.error("Failed to download file:", error);
42+
throw new Error(`Failed to download file: ${error instanceof Error ? error.message : "Unknown error"}`);
43+
}
44+
}
45+
46+
/**
47+
* Recursively collects all files in a folder
48+
*/
49+
async function collectFolderFiles(
50+
folderUrl: string,
51+
fetchFn: typeof fetch,
52+
zip: JSZip,
53+
basePath: string = "",
54+
visited: Set<string> = new Set()
55+
): Promise<void> {
56+
const normalizedUrl = ensureTrailingSlash(folderUrl);
57+
58+
// Prevent infinite loops
59+
if (visited.has(normalizedUrl)) {
60+
return;
61+
}
62+
visited.add(normalizedUrl);
63+
64+
try {
65+
const dataset = await getSolidDataset(normalizedUrl as UrlString, { fetch: fetchFn });
66+
const containedResources = getContainedResourceUrlAll(dataset);
67+
68+
for (const resourceUrl of containedResources) {
69+
if (resourceUrl.endsWith("/")) {
70+
// It's a folder - recurse
71+
const folderName = decodeResourceNameFromUrl(resourceUrl);
72+
const folderPath = basePath ? `${basePath}/${folderName}` : folderName;
73+
await collectFolderFiles(resourceUrl, fetchFn, zip, folderPath, visited);
74+
} else {
75+
// It's a file - add to zip
76+
try {
77+
const response = await fetchFn(resourceUrl);
78+
79+
if (!response.ok) {
80+
console.warn(`Failed to fetch file ${resourceUrl}: ${response.status} ${response.statusText}`);
81+
continue;
82+
}
83+
84+
const blob = await response.blob();
85+
const fileName = decodeResourceNameFromUrl(resourceUrl);
86+
const filePath = basePath ? `${basePath}/${fileName}` : fileName;
87+
88+
zip.file(filePath, blob);
89+
} catch (error) {
90+
console.warn(`Failed to add file ${resourceUrl} to zip:`, error);
91+
92+
}
93+
}
94+
}
95+
} catch (error) {
96+
console.error(`Failed to access folder ${folderUrl}:`, error);
97+
throw error;
98+
}
99+
}
100+
101+
/**
102+
* Downloads a folder as a ZIP file
103+
*/
104+
export async function downloadFolderAsZip(
105+
folderUrl: string,
106+
folderName: string,
107+
fetchFn: typeof fetch
108+
): Promise<void> {
109+
try {
110+
const zip = new JSZip();
111+
112+
// Collect all files recursively
113+
await collectFolderFiles(folderUrl, fetchFn, zip);
114+
115+
// Generate the ZIP file
116+
const zipBlob = await zip.generateAsync({ type: "blob" });
117+
118+
// Create a download link
119+
const url = URL.createObjectURL(zipBlob);
120+
const link = document.createElement("a");
121+
link.href = url;
122+
link.download = `${folderName}.zip`;
123+
link.style.display = "none";
124+
document.body.appendChild(link);
125+
126+
// Trigger download immediately
127+
link.click();
128+
129+
// Clean up after a delay to ensure download starts
130+
setTimeout(() => {
131+
document.body.removeChild(link);
132+
URL.revokeObjectURL(url);
133+
}, 100);
134+
} catch (error) {
135+
console.error("Failed to download folder as zip:", error);
136+
throw new Error(`Failed to download folder: ${error instanceof Error ? error.message : "Unknown error"}`);
137+
}
138+
}
139+

app/lib/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export * from "./profileUtils";
99
export * from "./sessionUtils";
1010
export * from "./metaFileUtils";
1111
export * from "./copyUtils";
12+
export * from "./downloadUtils";
1213

0 commit comments

Comments
 (0)