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