Experiment to rebuild Diffuse using web applets.
1import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js";
2import { type ElementConfigurator, h, repeat, text } from "spellcaster/hyperscript.js";
3
4import { applet, hs, reactive } from "@scripts/applet/common";
5import { CUSTOM_KEY } from "./constants";
6import { active, setActive } from "./signals";
7import { connection } from "./connections";
8import { context } from "./context";
9import type { List, ListItem, Method } from "./types";
10import { setContextData } from "./events";
11
12// const h = (
13// tag: string,
14// props?: Record<string, any> | Signal<Record<string, any>>,
15// configure?: ElementConfigurator,
16// ) => hs(tag, scope, props, configure);
17
18////////////////////////////////////////////
19// EFFECTS
20////////////////////////////////////////////
21reactive(
22 context.scope,
23 (data) => data.tracks.cacheId,
24 () => {
25 // Export data URI
26 const dl = document.querySelector("#download");
27 if (dl) {
28 const json = JSON.stringify(context.data.tracks.collection, null, 2);
29 const href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
30 dl.setAttribute("href", href);
31 }
32 },
33);
34
35// Mount + Unmount
36async function mountStorageMethod(method: Method) {
37 switch (method) {
38 case "custom":
39 setModalIsOpen(true);
40 break;
41 default:
42 const conn = await connection(method);
43 try {
44 await conn.sendAction("mount", undefined, { timeoutDuration: 60000 });
45 setActive(method);
46 } catch (err) {
47 const msg: string =
48 err && typeof err === "object" && "message" in err ? `${err.message}` : `${err}`;
49 if (msg.startsWith("[user] ")) alert(msg.slice(7));
50 }
51 break;
52 }
53}
54
55async function unmountStorageMethod(method: Method) {
56 const conn = await connection(method);
57 conn.removeEventListener("data", setContextData);
58 await conn.sendAction("unmount", undefined, { timeoutDuration: 60000 });
59}
60
61////////////////////////////////////////////
62// LIST
63////////////////////////////////////////////
64const list = computed<List>(() => {
65 const a = active();
66
67 return new Map([
68 [
69 `browser-${a === "browser"}`,
70 {
71 title: "Browser storage",
72 icon: "iconoir-app-window",
73 method: "browser",
74 activated: a === "browser",
75 },
76 ],
77 [
78 `device-${a === "device"}`,
79 {
80 title: "Device storage",
81 icon: "iconoir-laptop",
82 method: "device",
83 activated: a === "device",
84 },
85 ],
86 [
87 `custom-${a === "custom"}`,
88 {
89 title: "Custom applet",
90 icon: "iconoir-globe",
91 method: "custom",
92 activated: a === "custom",
93 },
94 ],
95 ]);
96});
97
98const Item = (signal: Signal<ListItem<Method>>) => {
99 const item = signal();
100
101 const colorClass = item.activated ? "pico-color-jade-500" : "pico-color-grey-500";
102 const icon = item.activated ? "iconoir-check-circle-solid" : "iconoir-check-circle";
103
104 return h(
105 "p",
106 {
107 onclick: clickHandler(item.method),
108 style: "cursor: pointer",
109 },
110 [
111 h("span", { className: "with-icon" }, [
112 h("i", { className: item.icon }),
113 h("strong", {}, text(item.title)),
114 ]),
115 h("br"),
116 h("span", { className: `with-icon ${colorClass}` }, [
117 h("i", { className: icon }),
118 h("span", {}, text(item.activated ? "Active" : "Select")),
119 ]),
120 ],
121 );
122};
123
124function clickHandler(method: Method) {
125 return async () => {
126 const currentlyActive = active();
127 if (currentlyActive === method && currentlyActive !== "custom") return;
128 if (currentlyActive) unmountStorageMethod(currentlyActive);
129 await mountStorageMethod(method);
130 };
131}
132
133const Options = computed(() => {
134 return h("div", { id: "options" }, repeat(list, Item));
135});
136
137// Add to DOM
138document.getElementById("options")?.replaceWith(Options());
139
140////////////////////////////////////////////
141// CUSTOM APPLET
142////////////////////////////////////////////
143type CustomAppletState = "waiting" | "connecting" | { error: string } | "connected";
144
145const [modalIsOpen, setModalIsOpen] = signal(false);
146const [customState, setCustomState] = signal<CustomAppletState>("waiting");
147
148const Modal = () => {
149 const Header = h("header", {}, [
150 h("button", {
151 attrs: { rel: "prev" },
152 ariaLabel: "Close",
153 onclick: close,
154 }),
155 h("p", {}, [
156 h("strong", {}, [
157 h("span", { className: "with-icon" }, [
158 h("i", { className: "iconoir-globe" }),
159 h("span", {}, text("Load a custom applet")),
160 ]),
161 ]),
162 ]),
163 ]);
164
165 const Content = h("form", { onsubmit: submit }, [
166 h("fieldset", { role: "group" }, [
167 h("input", {
168 type: "url",
169 name: "url",
170 placeholder: "https://applets.diffuse.sh/storage/output/indexed-db/",
171 required: true,
172 value: localStorage.getItem(CUSTOM_KEY) || "",
173 }),
174 h("input", { type: "submit", value: "Connect" }),
175 ]),
176 h("p", {}, [
177 h("small", { className: "with-icon" }, (element) => {
178 const comp = computed(() => {
179 const s = customState();
180
181 if (s === "connecting") {
182 return [
183 h("i", { className: "iconoir-ev-plug-charging" }),
184 h("span", {}, text("Connecting ...")),
185 ];
186 } else if (typeof s !== "string") {
187 return [
188 h("i", { className: "iconoir-warning-circle" }),
189 h("span", {}, text(`Error: ${s.error}`)),
190 ];
191 }
192
193 return [h("span", {}, text("Enter the URL to the applet."))];
194 });
195
196 effect(() => {
197 element.replaceChildren(...comp());
198 });
199 }),
200 ]),
201 ]);
202
203 return h(
204 "dialog",
205 computed(() => ({ open: modalIsOpen() })),
206 [h("article", {}, [Header, Content])],
207 );
208};
209
210// Events
211function close() {
212 setModalIsOpen(false);
213}
214
215async function submit(event: SubmitEvent) {
216 event.preventDefault();
217
218 const input: HTMLInputElement | null = (event.target as HTMLFormElement).querySelector(
219 `input[type="url"]`,
220 );
221
222 if (!input) return;
223
224 const url = input.value;
225 setCustomState("connecting");
226
227 const apl = await applet(url).catch((err) => {
228 setCustomState({ error: "Failed to connect" });
229 throw err;
230 });
231
232 let missingAction;
233
234 ["tracks", "mount", "unmount"].forEach((method) => {
235 if (!apl.manifest.actions?.[method]) missingAction = method;
236 });
237
238 if (missingAction) {
239 setCustomState({ error: `Applet is missing a required action: "${missingAction}"` });
240 return;
241 }
242
243 localStorage.setItem(CUSTOM_KEY, url);
244 await apl.sendAction("mount", undefined, { timeoutDuration: 60000 });
245
246 setActive("custom");
247 setModalIsOpen(false);
248 setCustomState("waiting");
249}
250
251// Add to DOM
252document.querySelector("main")?.appendChild(Modal());