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