Skip to content

Commit f6f98f2

Browse files
committed
chore: refactor useDownload hook
1 parent 8f9349e commit f6f98f2

File tree

3 files changed

+114
-96
lines changed

3 files changed

+114
-96
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useState } from "react";
2+
3+
/**
4+
* This hook is used to download a file from a given URL.
5+
* It handles the download progress and errors.
6+
*
7+
* @params fileUrl: The URL of the file to download.
8+
* @params fileName: The name of the file to download.
9+
* @params response: A function that returns a promise that resolves to a Response object.
10+
*/
11+
export const useDownload = (fileUrl: string, fileName: string, response?: () => Promise<Response>) => {
12+
const [error, setError] = useState<Error | unknown | null>(null);
13+
const [isDownloading, setIsDownloading] = useState<boolean>(false);
14+
const [progress, setProgress] = useState<number | null>(null);
15+
16+
const handleResponse = async (response: Response): Promise<string> => {
17+
if (!response.ok) {
18+
throw new Error("Could not download file");
19+
}
20+
21+
const contentLength = response.headers.get("content-length");
22+
const reader = response.body?.getReader();
23+
24+
if (!contentLength || !reader) {
25+
const blob = await response.blob();
26+
27+
return createBlobURL(blob);
28+
}
29+
30+
const stream = await getStream(contentLength, reader);
31+
const newResponse = new Response(stream);
32+
const blob = await newResponse.blob();
33+
34+
return createBlobURL(blob);
35+
};
36+
37+
const getStream = async (contentLength: string, reader: ReadableStreamDefaultReader<Uint8Array>): Promise<ReadableStream<Uint8Array>> => {
38+
let loaded = 0;
39+
const total = parseInt(contentLength, 10);
40+
41+
return new ReadableStream<Uint8Array>({
42+
async start(controller) {
43+
try {
44+
for (;;) {
45+
const { done, value } = await reader.read();
46+
47+
if (done) break;
48+
49+
loaded += value.byteLength;
50+
const percentage = Math.trunc((loaded / total) * 100);
51+
setProgress(percentage);
52+
controller.enqueue(value);
53+
}
54+
} catch (error) {
55+
controller.error(error);
56+
throw error;
57+
} finally {
58+
controller.close();
59+
}
60+
},
61+
});
62+
};
63+
64+
const createBlobURL = (blob: Blob): string => {
65+
return window.URL.createObjectURL(blob);
66+
};
67+
68+
const handleDownload = (fileName: string, url: string) => {
69+
const link = document.createElement("a");
70+
71+
link.href = url;
72+
link.setAttribute("download", fileName);
73+
document.body.appendChild(link);
74+
link.click();
75+
document.body.removeChild(link);
76+
window.URL.revokeObjectURL(url);
77+
};
78+
79+
const downloadFile = async () => {
80+
setIsDownloading(true);
81+
setError(null);
82+
setProgress(null);
83+
84+
try {
85+
const res = response ? response() : fetch(fileUrl);
86+
const url = await handleResponse(await res);
87+
88+
handleDownload(fileName, url);
89+
} catch (error) {
90+
setError(error);
91+
} finally {
92+
setIsDownloading(false);
93+
}
94+
};
95+
96+
return {
97+
error,
98+
isDownloading,
99+
progress,
100+
downloadFile,
101+
};
102+
};

apps/frontend/src/routes/_auth/administration/export/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Button } from "@quassel/ui";
22
import { createFileRoute } from "@tanstack/react-router";
3-
import { $api } from "../../../../stores/api";
3+
import { useDownload } from "../../../../hooks/useDownload";
44

55
function AdministrationExportIndex() {
6-
const { downloadFile } = $api.useDownload();
6+
const { downloadFile } = useDownload("/export", "dump.sql");
77
return (
88
<div>
9-
<Button onClick={() => downloadFile("dump.sql", "/export")}>Download</Button>
9+
<Button onClick={() => downloadFile()}>Download</Button>
1010
</div>
1111
);
1212
}

apps/frontend/src/stores/api.ts

Lines changed: 9 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import createFetchClient from "openapi-fetch";
22
import createClient from "openapi-react-query";
33
import type { paths } from "../api.gen";
44
import { C } from "../configuration";
5-
import { useState } from "react";
5+
import { useDownload } from "../hooks/useDownload";
66
import { PathsWithMethod } from "openapi-typescript-helpers";
77

88
export const fetchClient = createFetchClient<paths>({
@@ -11,100 +11,16 @@ export const fetchClient = createFetchClient<paths>({
1111
});
1212
const apiClient = createClient(fetchClient);
1313

14-
export const useDownload = () => {
15-
const [error, setError] = useState<Error | unknown | null>(null);
16-
const [isDownloading, setIsDownloading] = useState<boolean>(false);
17-
const [progress, setProgress] = useState<number | null>(null);
18-
19-
const handleResponse = async (response: Response): Promise<string> => {
20-
if (!response.ok) {
21-
throw new Error("Could not download file");
22-
}
23-
24-
const contentLength = response.headers.get("content-length");
25-
const reader = response.body?.getReader();
26-
27-
if (!contentLength || !reader) {
28-
const blob = await response.blob();
29-
30-
return createBlobURL(blob);
31-
}
32-
33-
const stream = await getStream(contentLength, reader);
34-
const newResponse = new Response(stream);
35-
const blob = await newResponse.blob();
36-
37-
return createBlobURL(blob);
38-
};
39-
40-
const getStream = async (contentLength: string, reader: ReadableStreamDefaultReader<Uint8Array>): Promise<ReadableStream<Uint8Array>> => {
41-
let loaded = 0;
42-
const total = parseInt(contentLength, 10);
43-
44-
return new ReadableStream<Uint8Array>({
45-
async start(controller) {
46-
try {
47-
for (;;) {
48-
const { done, value } = await reader.read();
49-
50-
if (done) break;
51-
52-
loaded += value.byteLength;
53-
const percentage = Math.trunc((loaded / total) * 100);
54-
setProgress(percentage);
55-
controller.enqueue(value);
56-
}
57-
} catch (error) {
58-
controller.error(error);
59-
throw error;
60-
} finally {
61-
controller.close();
62-
}
63-
},
64-
});
65-
};
66-
67-
const createBlobURL = (blob: Blob): string => {
68-
return window.URL.createObjectURL(blob);
69-
};
70-
71-
const handleDownload = (fileName: string, url: string) => {
72-
const link = document.createElement("a");
73-
74-
link.href = url;
75-
link.setAttribute("download", fileName);
76-
document.body.appendChild(link);
77-
link.click();
78-
document.body.removeChild(link);
79-
window.URL.revokeObjectURL(url);
80-
};
81-
82-
const downloadFile = async (fileName: string, fileUrl: PathsWithMethod<paths, "get">) => {
83-
setIsDownloading(true);
84-
setError(null);
85-
setProgress(null);
86-
87-
try {
88-
const req = await fetchClient.GET(fileUrl, { parseAs: "stream" });
89-
const url = await handleResponse(req.response);
90-
91-
handleDownload(fileName, url);
92-
} catch (error) {
93-
setError(error);
94-
} finally {
95-
setIsDownloading(false);
96-
}
97-
};
98-
99-
return {
100-
error,
101-
isDownloading,
102-
progress,
103-
downloadFile,
104-
};
14+
// Wrap the useDownload hook to download a file from the API
15+
const useApiDownload = (fileUrl: PathsWithMethod<paths, "get">, fileName: string) => {
16+
return useDownload(fileUrl, fileName, async () => {
17+
// Fetch the file as a stream so the fetch client doesn't try to parse it as JSON and we can track the download progress
18+
const get = await fetchClient.GET(fileUrl, { parseAs: "stream" });
19+
return get.response;
20+
});
10521
};
10622

10723
export const $api = {
10824
...apiClient,
109-
useDownload,
25+
useDownload: useApiDownload,
11026
};

0 commit comments

Comments
 (0)