DownloadManager.tsx
⎇
Raw
1import { batch, createEffect, createMemo, createSignal, For, Show, type Accessor, type Setter } from "solid-js";
2import "./DownloadManager.css";
3import "./Dialog.css";
4import "toastify-js/src/toastify.css";
5import { formatBytes } from "../utils";
6import { ClearSVG, RefreshSVG, StopSVG } from "./SVG";
7import { type DownloadStatus, downloadStatusMap, setDownloadStatusMap, type Progress } from "../offline";
8
9interface DownloadManagerProps {
10 showDownloadManager: Accessor<boolean>;
11 setShowDownloadManager: Setter<boolean>;
12}
13
14export default function DownloadManager(props: DownloadManagerProps) {
15 let dialogElement!: HTMLDialogElement;
16
17 createEffect(() => {
18 if (props.showDownloadManager()) {
19 dialogElement.showModal();
20 }
21 });
22
23 function handleClose() {
24 dialogElement.close();
25 props.setShowDownloadManager(false);
26 }
27
28 const downloadKeys = createMemo(() => {
29 const keys = Object.keys(downloadStatusMap);
30 const progressKeys = keys.filter((key) => isProgress(downloadStatusMap[key]));
31
32 // Sort the keys: Running first (by path), then Failed (by path), then Aborted (by path)
33 progressKeys.sort((pathA, pathB) => {
34 const statusA = downloadStatusMap[pathA] as Progress;
35 const statusB = downloadStatusMap[pathB] as Progress;
36
37 // 0: Running, 1: Failed, 2: Aborted
38 const typeA =
39 typeof statusA.status === "number" ? (statusA.status > 0 ? 0 : 3) : statusA.status === "Failed" ? 1 : 2;
40 const typeB =
41 typeof statusB.status === "number" ? (statusB.status > 0 ? 0 : 3) : statusB.status === "Failed" ? 1 : 2;
42
43 if (typeA !== typeB) {
44 return typeA - typeB; // Sort by type first
45 }
46
47 // If types are the same, sort by path numerically/alphabetically
48 return pathA.localeCompare(pathB, undefined, { numeric: true });
49 });
50 return progressKeys;
51 });
52
53 function isProgress(status: DownloadStatus | undefined): status is Progress {
54 return status !== undefined && typeof status === "object" && "status" in status;
55 }
56
57 const [downloading, setDownloading] = createSignal(0);
58 const [aborted, setAborted] = createSignal(0);
59 const [failed, setFailed] = createSignal(0);
60 createEffect(() => {
61 batch(() => {
62 setDownloading(0);
63 setAborted(0);
64 setFailed(0);
65 for (const key of downloadKeys()) {
66 const status = downloadStatusMap[key];
67 if (!isProgress(status)) continue;
68 if (typeof status.status === "number") setDownloading((prev) => prev + 1);
69 if (status.status === "Aborted") setAborted((prev) => prev + 1);
70 if (status.status === "Failed") setFailed((prev) => prev + 1);
71 }
72 });
73 });
74
75 return (
76 <dialog
77 style={{ height: "90%" }}
78 ref={dialogElement}
79 onClose={handleClose}
80 onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
81 >
82 <div style={{ height: "100%" }}>
83 <div class="downloadmanager-header title">
84 <span>Downloads</span>
85 <button
86 title="Retry all failed/aborted downloads"
87 onClick={() => {
88 batch(() => {
89 for (const path of downloadKeys()) {
90 const status = downloadStatusMap[path] as Progress;
91 if ("retry" in status) status.retry();
92 }
93 });
94 }}
95 >
96 <RefreshSVG />
97 </button>
98 <button
99 title="stop all running downloads"
100 onClick={async () => {
101 batch(async () => {
102 for (const path of downloadKeys()) {
103 const status = downloadStatusMap[path] as Progress;
104 if (typeof status.status === "number") {
105 status.abort();
106 }
107 }
108 });
109 }}
110 >
111 <StopSVG />
112 </button>
113 <button
114 title="Clear all failed/aborted downloads"
115 onClick={() => {
116 batch(() => {
117 for (const path of downloadKeys()) {
118 const status = downloadStatusMap[path] as Progress;
119 if (status.status === "Aborted" || status.status === "Failed") setDownloadStatusMap(path, undefined);
120 }
121 });
122 }}
123 >
124 Clear
125 </button>
126 <button title="Close dialog" onClick={handleClose}>
127 <ClearSVG />
128 </button>
129 </div>
130 <hr />
131 <div class="downloadmanager-counters">
132 <span>In progress: {downloading()}</span>
133 <span>Aborted: {aborted()}</span>
134 <span>Failed: {failed()}</span>
135 </div>
136 <div class="downloadmanager-content">
137 <For each={downloadKeys()}>
138 {(path) => {
139 const [showReason, setShowReason] = createSignal(false);
140 const status = downloadStatusMap[path] as Progress;
141 return (
142 <div
143 classList={{
144 "downloadmanager-item": true,
145 "downloadmanager-item-failed": status.status === "Failed",
146 "downloadmanager-item-aborted": status.status === "Aborted",
147 }}
148 >
149 <span>{path}</span>
150 <hr />
151 <div class="downloadmanager-buttons">
152 {status && typeof status.status === "number" ? (
153 <>
154 <span>{formatBytes(status.status)}</span>
155 <button title="Stop Download" onClick={() => status.abort()}>
156 <StopSVG />
157 </button>
158 </>
159 ) : (
160 <>
161 {(status.status === "Failed" && (
162 <button title="Show reason" onclick={() => setShowReason(!showReason())}>
163 Reason
164 </button>
165 )) ||
166 "Aborted"}
167 <button title="Retry download" onClick={() => status.retry()}>
168 <RefreshSVG />
169 </button>
170 <button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
171 <ClearSVG />
172 </button>
173 </>
174 )}
175 </div>
176 <Show when={showReason()}>{"reason" in status && status.reason}</Show>
177 </div>
178 );
179 }}
180 </For>
181 </div>
182 </div>
183 </dialog>
184 );
185}
186