Blog/Components/Pages/Storage.razor.js 9.7 K · 288 lines · raw · history

1 import {getById, writeError, writeDebug, writeInfo, h } from "/common.module.js";
2
3 /** @member {IDBDatabase} db */
4 let db;
5
6 /**
7 * The stores open on the page, by name, each with what closes it again.
8 * @type {Map<string, () => void>}
9 */
10 const opened = new Map();
11
12 // The store named in the fragment is opened once the store list is known. Only then: showStores
13 // runs again whenever the database changes, and must not reopen a store someone just closed.
14 let openFromHash = true;
15
16 function InitDB(upgradeCallback) {
17 writeDebug(`db = ${db?.version}`)
18 if (db) {
19 writeDebug("Closing database");
20 db.close();
21 }
22 writeDebug(`db = ${db?.version}`)
23 const DBOpenRequest = db === null || db === undefined
24 ? window.indexedDB.open('mb-storage')
25 : window.indexedDB.open('mb-storage', db.version + 1);
26
27 DBOpenRequest.onupgradeneeded = (e) => {
28 writeDebug(`Upgrading from ${e.oldVersion} to ${e.newVersion}`);
29 if (upgradeCallback) upgradeCallback(e);
30 };
31
32 DBOpenRequest.onerror = () => {
33 writeError('Error loading database.');
34 };
35
36 DBOpenRequest.onsuccess = () => {
37 db = DBOpenRequest.result;
38 writeDebug(`db = ${db.version}`)
39 writeDebug("Database opened successfully.");
40 db.onversionchange = () => {
41 // Another tab is making or deleting a store, such as Note saving its first note. Let
42 // it, and open whatever version it leaves behind.
43 writeInfo(`Version changed in another tab, reopening database.`);
44 db.close();
45 db = null;
46 InitDB(null);
47 }
48 showStores();
49 };
50
51 DBOpenRequest.onblocked = () => {
52 writeError(`Please close all other tabs with this site open!`);
53 }
54
55 }
56
57 try {
58 db?.close();
59 db = null;
60 InitDB(null);
61
62 // A link to another store from this page changes only the fragment, which reloads nothing.
63 window.addEventListener('hashchange', () => {
64 openFromHash = true;
65 if (db) showStores();
66 });
67
68 const newStoreButton = getById('newStoreButton');
69 newStoreButton.addEventListener('click', async () => {
70 const newStoreName = await getInput("New store", "Store name");
71
72 if (newStoreName) {
73 writeDebug("New store submitted.");
74 InitDB((event) => {
75 writeDebug(`Creating new store: ${newStoreName}, version: ${event.newVersion}`);
76 event.target.result.createObjectStore(newStoreName, {autoIncrement: true});
77 writeDebug("New store created: " + newStoreName);
78 });
79 }
80 });
81
82 } catch (e) {
83 writeError(e.message);
84 throw e;
85 }
86
87 function showStores() {
88 const storeList = getById("storeList");
89 storeList.replaceChildren();
90
91 let storeNames = db.objectStoreNames;
92
93 if (openFromHash) {
94 openFromHash = false;
95 const storeName = decodeURIComponent(window.location.hash.substring(1));
96 if (storeNames.contains(storeName) && !opened.has(storeName)) {
97 openStore(storeName);
98 }
99 }
100
101 for (const storeName of storeNames) {
102 const deleteButton = h("button", {
103 onClick: async () => {
104 if (await askForConfirmation(`Are you sure you want to delete store '${storeName}'?`)) {
105 opened.get(storeName)?.();
106 InitDB((event) => {
107 writeDebug(`Deleting store: ${storeName}, version: ${event.newVersion}`);
108 event.target.result.deleteObjectStore(storeName);
109 writeDebug("Deleted store: " + storeName);
110 });
111 }
112 }
113 }, "Delete");
114
115 const openButton = h("button", {
116 onClick: () => {
117 if (opened.has(storeName)) {
118 opened.get(storeName)();
119 openButton.textContent = "Open";
120 } else {
121 openStore(storeName);
122 openButton.textContent = "Close";
123 }
124 }
125 }, opened.has(storeName) ? "Close" : "Open");
126
127 const storeElement = h("span", storeName);
128 storeList.appendChild(storeElement);
129 storeList.appendChild(openButton);
130 storeList.appendChild(deleteButton);
131 }
132 }
133
134 function openStore(storeName) {
135 const openedStores = getById("openedStores");
136
137 const addButton = h("button", "Add");
138 const storeElement = h("fieldset", {class: "panel"}, [h("legend", [storeName + " ", addButton])]);
139 opened.set(storeName, () => {
140 openedStores.removeChild(storeElement);
141 opened.delete(storeName);
142 });
143
144 openedStores.appendChild(storeElement);
145 const elementList = h("div", {class:"grid-stores"});
146 storeElement.appendChild(elementList);
147
148 function showItems() {
149
150 /** @member {Node[]} newChildren*/
151 const newChildren = [];
152 const store = db.transaction(storeName).objectStore(storeName);
153 store.openCursor().onsuccess = (event) => {
154 /** @member {IDBCursorWithValue} cursor*/
155 const cursor = event.target.result;
156 if (!cursor) {
157 elementList.replaceChildren(...newChildren);
158 return;
159 }
160 const value = cursor.value;
161 const key = cursor.key;
162 const editItemButton = h("button", {
163 onClick: async () => {
164 const value = await getInput("Edit value", "new value");
165
166 if (value) {
167 const transaction = db.transaction(storeName, 'readwrite');
168 transaction.oncomplete = () => {
169 showItems();
170 }
171 const store = transaction.objectStore(storeName);
172 store.put(value, key);
173 }
174 }
175 }, "Edit");
176
177 const deleteItemButton = h("button", {
178 onClick: () => {
179 const transaction = db.transaction(storeName, 'readwrite');
180 transaction.oncomplete = () => {
181 showItems();
182 }
183 const store = transaction.objectStore(storeName);
184 store.delete(key);
185 }
186 }, "Delete");
187
188 newChildren.push(showValue(value));
189 newChildren.push(editItemButton);
190 newChildren.push(deleteItemButton);
191
192 cursor.continue();
193 }
194 }
195
196 showItems();
197 storeElement.scrollIntoView({block: "nearest"});
198
199 addButton.addEventListener('click', async () => {
200 const value = await getInput("New value", "value");
201 if (value) {
202 const transaction = db.transaction(storeName, "readwrite");
203 transaction.oncomplete = () => {
204 showItems();
205 }
206 const store = transaction.objectStore(storeName);
207 store.add(value);
208 }
209 })
210 }
211
212 /**
213 * A web address is a link to it, and `{title, url}` (what Note saves) is a link labelled with
214 * its title. Anything else is its text. Only http(s): a value can be anything that was typed
215 * into it, and a javascript: link would run it.
216 * @param {unknown} value
217 * @returns {HTMLElement}
218 */
219 function showValue(value) {
220 const isLabelled = typeof value?.url === "string" && typeof value?.title === "string";
221 const text = isLabelled ? value.url : String(value);
222 const url = URL.canParse(text) ? new URL(text) : null;
223 if (url && (url.protocol === "https:" || url.protocol === "http:")) {
224 return h("a", {href: url.href, class: "store-link"}, isLabelled ? value.title : text);
225 }
226 return h("span", text);
227 }
228
229 /**
230 * @param {string} title
231 * @param {string} placeholder
232 * @return {Promise<string>}
233 */
234 function getInput(title, placeholder) {
235 return new Promise((resolve) => {
236 const inputDialog = getById("inputDialog");
237 const cancelInputButton = getById('cancelInputButton');
238 const inputDialogInput = getById('inputDialogInput');
239 const inputForm = getById('inputForm');
240
241 inputDialogInput.placeholder = placeholder;
242 getById("inputTitle").textContent = title;
243
244 inputDialog.showModal();
245
246 const cancelCallback = () => {
247 inputDialog.close("cancelInput");
248 cancelInputButton.removeEventListener("click", cancelCallback);
249 inputForm.removeEventListener("submit", submitCallback);
250 resolve(null);
251 };
252
253 cancelInputButton.addEventListener('click', cancelCallback);
254
255 const submitCallback = () => {
256 const inputValue = inputDialogInput.value;
257 inputDialogInput.value = '';
258 inputForm.removeEventListener("submit", submitCallback);
259 cancelInputButton.removeEventListener("click", cancelCallback);
260 resolve(inputValue);
261 };
262
263 inputForm.addEventListener('submit', submitCallback);
264 })
265 }
266
267 function askForConfirmation(question) {
268 return new Promise((resolve) => {
269 const confirmDialog = getById("confirmDialog");
270 getById("confirmMessage").textContent = question;
271 confirmDialog.showModal();
272
273 const cancelConfirmButton = getById("cancelConfirmButton");
274 const closeCancelled = () => {
275 confirmDialog.close("cancelConfirmation");
276 resolve(false)
277 };
278 cancelConfirmButton.addEventListener('click', closeCancelled);
279
280 const confirmForm = getById("confirmForm");
281 const submitConfirmation = () => {
282 cancelConfirmButton.removeEventListener('click', closeCancelled);
283 confirmForm.removeEventListener('submit', submitConfirmation);
284 resolve(true);
285 };
286 confirmForm.addEventListener('submit', submitConfirmation);
287 })
288 }