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 return (
58 <dialog
59 style={{ height: "90%" }}
60 ref={dialogElement}
61 onClose={handleClose}
62 onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
63 >
64 <div style={{ height: "100%" }}>
65 <div class="downloadmanager-header title">
66 Download
67 <div class="spacer" />
68 <button
69 title="Retry all failed/aborted downloads"
70 onClick={() => {
71 batch(() => {
72 for (const path of downloadKeys()) {
73 const status = downloadStatusMap[path] as Progress;
74 if ("retry" in status) status.retry();
75 }
76 });
77 }}
78 >
79 <RefreshSVG />
80 </button>
81 <button
82 title="stop all running downloads"
83 onClick={async () => {
84 batch(async () => {
85 for (const path of downloadKeys()) {
86 const status = downloadStatusMap[path] as Progress;
87 if (typeof status.status === "number") {
88 status.abort();
89 }
90 }
91 });
92 }}
93 >
94 <StopSVG />
95 </button>
96 <button
97 title="Clear all failed/aborted downloads"
98 onClick={() => {
99 batch(() => {
100 for (const path of downloadKeys()) {
101 const status = downloadStatusMap[path] as Progress;
102 if (status.status === "Aborted" || status.status === "Failed") setDownloadStatusMap(path, undefined);
103 }
104 });
105 }}
106 >
107 Clear
108 </button>
109 <button title="Close dialog" onClick={handleClose}>
110 <ClearSVG />
111 </button>
112 </div>
113 <hr />
114 <div class="downloadmanager-content">
115 <For each={downloadKeys()}>
116 {(path) => {
117 const [showReason, setShowReason] = createSignal(false);
118 const status = downloadStatusMap[path] as Progress;
119 return (
120 <div
121 classList={{
122 "downloadmanager-item": true,
123 "downloadmanager-item-failed": status.status === "Failed",
124 "downloadmanager-item-aborted": status.status === "Aborted",
125 }}
126 >
127 <span>{path}</span>
128 <hr />
129 <div class="downloadmanager-buttons">
130 {status && typeof status.status === "number" ? (
131 <>
132 <span>{formatBytes(status.status)}</span>
133 <button title="Stop Download" onClick={() => status.abort()}>
134 <StopSVG />
135 </button>
136 </>
137 ) : (
138 <>
139 {(status.status === "Failed" && (
140 <button title="Show reason" onclick={() => setShowReason(!showReason())}>
141 Reason
142 </button>
143 )) ||
144 "Aborted"}
145 <button title="Retry download" onClick={() => status.retry()}>
146 <RefreshSVG />
147 </button>
148 <button title="Clear Download" onClick={() => setDownloadStatusMap(path, undefined)}>
149 <ClearSVG />
150 </button>
151 </>
152 )}
153 </div>
154 <Show when={showReason()}>{"reason" in status && status.reason}</Show>
155 </div>
156 );
157 }}
158 </For>
159 </div>
160 </div>
161 </dialog>
162 );
163}
164