StorageManager.tsx
⎇
Raw
1import {
2 type Accessor,
3 createEffect,
4 createMemo,
5 createSignal,
6 For,
7 onCleanup,
8 type ResourceActions,
9 type Setter,
10 Show,
11} from "solid-js";
12import "./StorageManager.css";
13import "./Dialog.css";
14import "toastify-js/src/toastify.css";
15import type { FlatFileListing } from "../App";
16import { clearAllDownloads, clearLocalData, storageStatsLive } from "../offline";
17import { formatBytes, toast } from "../utils";
18import { ClearSVG } from "./SVG";
19
20interface StorageManagerProps {
21 showStorage: Accessor<boolean>;
22 setShowStorage: Setter<boolean>;
23 fetchFiles: ResourceActions<FlatFileListing>;
24}
25
26//formatBytes(0) returns "Queued...", which fits download progress but not a usage display
27function formatSize(bytes: number): string {
28 return bytes === 0 ? "0 B" : formatBytes(bytes);
29}
30
31//not part of the TS dom lib types yet, only implemented in Chromium
32type StorageEstimateWithDetails = StorageEstimate & { usageDetails?: Record<string, number> };
33
34const USAGE_DETAIL_LABELS: Record<string, string> = {
35 indexeddb: "IndexedDB",
36 caches: "Cache storage",
37 serviceWorkerRegistrations: "Service workers",
38};
39
40//two-step button for destructive actions: the first click arms it for a few seconds,
41//the second actually runs onConfirm
42function ConfirmButton(props: { label: string; title: string; onConfirm: () => void }) {
43 const [armed, setArmed] = createSignal(false);
44 let timer: ReturnType<typeof setTimeout> | undefined;
45 onCleanup(() => clearTimeout(timer));
46
47 return (
48 <button
49 title={props.title}
50 classList={{ "storagemanager-armed": armed() }}
51 onClick={() => {
52 if (armed()) {
53 clearTimeout(timer);
54 setArmed(false);
55 props.onConfirm();
56 } else {
57 setArmed(true);
58 timer = setTimeout(() => setArmed(false), 3000);
59 }
60 }}
61 >
62 {armed() ? "Click again to confirm" : props.label}
63 </button>
64 );
65}
66
67export default function StorageManager(props: StorageManagerProps) {
68 let dialogElement!: HTMLDialogElement;
69
70 const [estimate, setEstimate] = createSignal<StorageEstimateWithDetails>();
71 const [estimateSupported, setEstimateSupported] = createSignal(true);
72 const [persisted, setPersisted] = createSignal<boolean>();
73 const [persistedSupported, setPersistedSupported] = createSignal(true);
74 const [requestingPersist, setRequestingPersist] = createSignal(false);
75 const [resetError, setResetError] = createSignal<string>();
76 const [resetting, setResetting] = createSignal(false);
77
78 async function refreshStorageInfo() {
79 if (navigator.storage?.estimate) {
80 setEstimateSupported(true);
81 setEstimate(await navigator.storage.estimate().catch(() => undefined));
82 } else {
83 setEstimateSupported(false);
84 }
85 if (navigator.storage?.persisted) {
86 setPersistedSupported(true);
87 setPersisted(await navigator.storage.persisted().catch(() => undefined));
88 } else {
89 setPersistedSupported(false);
90 }
91 }
92
93 createEffect(() => {
94 if (props.showStorage()) {
95 dialogElement.showModal();
96 refreshStorageInfo();
97 setResetError(undefined);
98 }
99 });
100
101 function handleClose() {
102 dialogElement.close();
103 props.setShowStorage(false);
104 }
105
106 const usagePercent = createMemo(() => {
107 const current = estimate();
108 if (!current?.quota || !current.usage) return 0;
109 return Math.min(100, (current.usage / current.quota) * 100);
110 });
111
112 //usageDetails is Chromium-only; elsewhere this stays empty and the breakdown is skipped
113 const usageDetails = createMemo(() => Object.entries(estimate()?.usageDetails ?? {}));
114
115 const quotaLabel = createMemo(() => {
116 const quota = estimate()?.quota;
117 return quota === undefined ? "unknown" : formatSize(quota);
118 });
119
120 async function requestPersistence() {
121 setRequestingPersist(true);
122 try {
123 setPersisted(await navigator.storage.persist());
124 } catch (e) {
125 toast("Requesting persistent storage failed", "error", e);
126 }
127 setRequestingPersist(false);
128 }
129
130 async function clearSynced() {
131 try {
132 await clearAllDownloads(props.fetchFiles);
133 toast("Cleared all synced files", "success");
134 } catch (e) {
135 toast("Clearing synced files failed", "error", e);
136 }
137 refreshStorageInfo();
138 }
139
140 async function fullReset() {
141 setResetting(true);
142 setResetError(undefined);
143 const failure = await clearLocalData().catch((e) => e?.toString() ?? "Unknown error");
144 if (failure) {
145 setResetError(failure);
146 setResetting(false);
147 return;
148 }
149 location.hash = "";
150 location.reload();
151 }
152
153 return (
154 <dialog
155 ref={dialogElement}
156 onClose={handleClose}
157 onclick={(e) => e.target.nodeName === "DIALOG" && handleClose()}
158 >
159 <div>
160 <div class="storagemanager-header title">
161 <span>Storage</span>
162 <button title="Close dialog" onClick={handleClose}>
163 <ClearSVG />
164 </button>
165 </div>
166 <hr />
167
168 <Show
169 when={estimateSupported()}
170 fallback={<div class="storagemanager-section">Storage estimate API not available in this browser</div>}
171 >
172 <div class="storagemanager-section">
173 <div class="storagemanager-row">
174 <span>Usage: {formatSize(estimate()?.usage ?? 0)}</span>
175 <span>Quota: {quotaLabel()}</span>
176 </div>
177 <div class="storagemanager-bar" title={`${usagePercent().toFixed(1)}% of quota used`}>
178 <div class="storagemanager-bar-fill" style={{ width: `${usagePercent()}%` }} />
179 <span>{usagePercent().toFixed(1)}%</span>
180 </div>
181 <Show when={usageDetails().length > 0}>
182 <For each={usageDetails()}>
183 {([key, bytes]) => (
184 <div class="storagemanager-row storagemanager-detail">
185 <span>{USAGE_DETAIL_LABELS[key] ?? key}</span>
186 <span>{formatSize(bytes)}</span>
187 </div>
188 )}
189 </For>
190 </Show>
191 </div>
192 </Show>
193
194 <Show when={persistedSupported()}>
195 <div class="storagemanager-section">
196 <div class="storagemanager-row">
197 <span>Persistent storage: {persisted() === undefined ? "unknown" : persisted() ? "yes" : "no"}</span>
198 <Show when={persisted() === false}>
199 <button
200 title="Ask the browser to never evict this site's stored files when space runs low"
201 disabled={requestingPersist()}
202 onClick={requestPersistence}
203 >
204 Request persistent storage
205 </button>
206 </Show>
207 </div>
208 </div>
209 </Show>
210
211 <div class="storagemanager-section">
212 <div class="storagemanager-row">
213 <span>Synced files: {storageStatsLive().fileCount}</span>
214 <span>{formatSize(storageStatsLive().fileBytes)}</span>
215 </div>
216 <div class="storagemanager-row">
217 <span>Covers: {storageStatsLive().coverCount}</span>
218 <span>Playlists: {storageStatsLive().playlistCount}</span>
219 </div>
220 </div>
221
222 <div class="storagemanager-section storagemanager-actions">
223 <ConfirmButton
224 label="Clear synced files"
225 title="Delete all synced files and covers from local storage. Playlists and settings are kept"
226 onConfirm={clearSynced}
227 />
228 <ConfirmButton
229 label={resetting() ? "Resetting..." : "Full reset"}
230 title="Delete everything this app has stored locally - files, covers, playlists, settings and caches - then reload the page"
231 onConfirm={fullReset}
232 />
233 <Show when={resetError()}>
234 <div class="storagemanager-error">{resetError()}</div>
235 </Show>
236 </div>
237 </div>
238 </dialog>
239 );
240}
241