DownloadManager.tsx
⎇
Raw
1import { type Accessor, batch, createEffect, createMemo, createSignal, For, type Setter, Show } from "solid-js";
2import "./DownloadManager.css";
3import "./Dialog.css";
4import "toastify-js/src/toastify.css";
5import { type DownloadStatus, downloadStatusMap, type Progress, setDownloadStatusMap } from "../offline";
6import { formatBytes } from "../utils";
7import { ClearSVG, RefreshSVG, StopSVG } from "./SVG";
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={() => {
101 batch(() => {
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 //an accessor, not a snapshot: progress updates replace the whole value object while <For>
141 //keeps this row alive, so reading it once would freeze the byte counter and the status styling
142 const status = () => downloadStatusMap[path] as Progress | undefined;
143 return (
144 <Show when={status()}>
145 {(currentStatus) => (
146 <div
147 classList={{
148 "downloadmanager-item": true,
149 "downloadmanager-item-failed": currentStatus().status === "Failed",
150 "downloadmanager-item-aborted": currentStatus().status === "Aborted",
151 }}
152 >
153 <span>{path}</span>
154 <hr />
155 <div class="downloadmanager-buttons">
156 {typeof currentStatus().status === "number" ? (
157 <>
158 <span>{formatBytes(currentStatus().status as number)}</span>
159 <button
160 title="Stop Download"
161 onClick={() => {
162 const running = currentStatus();
163 if ("abort" in running) running.abort();
164 }}
165 >
166 <StopSVG />
167 </button>
168 </>
169 ) : (
170 <>
171 {(currentStatus().status === "Failed" && (
172 <button title="Show reason" onclick={() => setShowReason(!showReason())}>
173 Reason
174 </button>
175 )) ||
176 "Aborted"}
177 <button
178 title="Retry download"
179 onClick={() => {
180 const stopped = currentStatus();
181 if ("retry" in stopped) stopped.retry();
182 }}
183 >
184 <RefreshSVG />
185 </button>
186 <button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
187 <ClearSVG />
188 </button>
189 </>
190 )}
191 </div>
192 <Show when={showReason()}>
193 {(() => {
194 const reasonStatus = currentStatus();
195 return "reason" in reasonStatus && reasonStatus.reason;
196 })()}
197 </Show>
198 </div>
199 )}
200 </Show>
201 );
202 }}
203 </For>
204 </div>
205 </div>
206 </dialog>
207 );
208}
209