A music player that connects to your cloud/distributed storage.
1import { basicSetup, EditorView } from "codemirror";
2import { css as langCss } from "@codemirror/lang-css";
3import { html as langHtml } from "@codemirror/lang-html";
4import { javascript as langJs } from "@codemirror/lang-javascript";
5import { autocompletion } from "@codemirror/autocomplete";
6
7import * as TID from "@atcute/tid";
8
9import * as CID from "~/common/cid.js";
10import * as Output from "~/common/output.js";
11import { facetFromURI } from "~/common/facets/utils.js";
12import { loadURI } from "~/common/loader.js";
13import { signal } from "~/common/signal.js";
14
15import { saveFacet } from "./crud.js";
16import { output } from "./output.js";
17
18/**
19 * @import {Facet} from "~/definitions/types.d.ts"
20 */
21
22const $editor = signal(/** @type {EditorView | null} */ (null));
23const $editingFacet = signal(/** @type {Facet | null} */ (null));
24
25////////////////////////////////////////////
26// LOADING
27////////////////////////////////////////////
28
29const LOADING_EL_ID = "editor-loading";
30
31/**
32 * @param {boolean} loading
33 */
34function setEditorLoading(loading) {
35 const container = /** @type {HTMLElement | null} */ (
36 document.querySelector("#html-input-container")
37 );
38 if (!container) return;
39
40 if (loading) {
41 if (document.getElementById(LOADING_EL_ID)) return;
42 const el = document.createElement("div");
43 el.id = LOADING_EL_ID;
44 el.className = "with-icon";
45 el.style.fontSize = "var(--fs-sm)";
46 el.innerHTML = '<i class="ph-bold ph-spinner animate-spin"></i> Loading…';
47 container.before(el);
48 container.hidden = true;
49 } else {
50 document.getElementById(LOADING_EL_ID)?.remove();
51 container.hidden = false;
52 }
53}
54
55////////////////////////////////////////////
56// EDITOR
57////////////////////////////////////////////
58
59export function renderEditor() {
60 // Code editor
61 const editorContainer = document.body.querySelector("#html-input-container");
62 if (!editorContainer) throw new Error("Editor container not found");
63
64 const editor = new EditorView({
65 parent: editorContainer,
66 doc: `
67<style>
68 @import "./styles/base.css";
69</style>
70
71<script type="module">
72 import foundation from "~/common/foundation.js";
73</script>
74 `.trim(),
75 extensions: [
76 basicSetup,
77 langHtml(),
78 langCss(),
79 langJs(),
80 autocompletion(),
81 ],
82 });
83
84 $editor.value = editor;
85 return editor;
86}
87
88////////////////////////////////////////////
89// FORM
90////////////////////////////////////////////
91
92/**
93 * @param {EditorView} editor
94 */
95const onBuildSubmit = (editor) =>
96/**
97 * @param {Event} event
98 */
99async (event) => {
100 event.preventDefault();
101
102 const nameEl = /** @type {HTMLInputElement | null} */ (document.querySelector(
103 "#name-input",
104 ));
105
106 const descriptionEl = /** @type {HTMLTextAreaElement | null} */ (
107 document.querySelector("#description-input")
108 );
109
110 const kindEl = /** @type {HTMLSelectElement | null} */ (
111 document.querySelector("#kind-input")
112 );
113
114 const html = editor.state.doc.toString();
115 const cid = await CID.create(0x55, new TextEncoder().encode(html));
116 const name = nameEl?.value ?? "nameless";
117 const description = descriptionEl?.value ?? "";
118 const kind =
119 /** @type {"interactive" | "prelude"} */ (kindEl?.value ?? "interactive");
120
121 /** @type {Facet} */
122 const facet = $editingFacet.value
123 ? {
124 ...$editingFacet.value,
125 cid,
126 description,
127 html,
128 kind,
129 name,
130 }
131 : {
132 $type: "sh.diffuse.output.facet",
133 id: TID.now(),
134 cid,
135 description,
136 html,
137 kind,
138 name,
139 };
140
141 switch (/** @type {any} */ (event).submitter.name) {
142 case "save":
143 await saveFacet(facet);
144 break;
145 case "save+open":
146 await saveFacet(facet);
147 globalThis.open(`./l/?id=${facet.id}`, "blank");
148 break;
149 }
150};
151
152/**
153 * @param {Facet} ogFacet
154 */
155async function editFacet(ogFacet) {
156 const facet = { ...ogFacet };
157 const nameEl = /** @type {HTMLInputElement | null} */ (document.querySelector(
158 "#name-input",
159 ));
160
161 const descriptionEl = /** @type {HTMLTextAreaElement | null} */ (
162 document.querySelector("#description-input")
163 );
164
165 const kindEl = /** @type {HTMLSelectElement | null} */ (
166 document.querySelector("#kind-input")
167 );
168
169 if (!nameEl) return;
170
171 // Reset url — remove `id` param if not matching the facet
172 const url = new URL(location.href);
173 const id = url.searchParams.get("id");
174
175 if (id && facet.id !== id) {
176 url.searchParams.delete("id");
177 history.replaceState(null, "", url);
178 }
179
180 // Scroll to builder
181 document.querySelector("#code")?.scrollIntoView();
182
183 // Make sure HTML is loaded
184 if (!facet.html && facet.uri) {
185 setEditorLoading(true);
186 const html = await loadURI(facet.uri);
187 const cid = await CID.create(0x55, new TextEncoder().encode(html));
188 setEditorLoading(false);
189
190 facet.html = html;
191 facet.cid = cid;
192 }
193
194 $editingFacet.value = facet;
195 nameEl.value = facet.name;
196
197 if (kindEl) {
198 kindEl.value = facet.kind ?? "interactive";
199 }
200
201 if (descriptionEl) {
202 descriptionEl.value = facet.description ?? "";
203 }
204
205 const editor = $editor.value;
206 editor?.dispatch({
207 changes: { from: 0, to: editor.state.doc.length, insert: facet.html },
208 });
209}
210
211export function handleBuildFormSubmit() {
212 const editor = $editor.value;
213 if (!editor) return;
214
215 document.querySelector("#code-form")?.addEventListener(
216 "submit",
217 onBuildSubmit(editor),
218 );
219}
220
221////////////////////////////////////////////
222// EDIT EXAMPLES
223////////////////////////////////////////////
224
225let isListening = false;
226
227export function listenForExamplesEdit() {
228 if (isListening) return;
229 isListening = true;
230
231 document.body.addEventListener(
232 "click",
233 /**
234 * @param {MouseEvent} event
235 */
236 async (event) => {
237 const target = /** @type {HTMLElement} */ (event.target);
238 const rel = target.getAttribute("rel");
239 if (!rel) return;
240
241 const uri = target.closest("li")?.getAttribute("data-uri");
242 if (!uri) return;
243
244 const name = target.closest("li")?.getAttribute("data-name");
245 if (!name) return;
246
247 const kind = target.closest("li")?.getAttribute("data-kind") ?? undefined;
248
249 switch (rel) {
250 case "edit": {
251 setEditorLoading(true);
252 const facet = await facetFromURI({ kind, name, uri }, {
253 fetchHTML: true,
254 });
255 setEditorLoading(false);
256 editFacet(facet);
257 document.querySelector("#code")?.scrollIntoView();
258 break;
259 }
260 }
261 },
262 );
263}
264
265////////////////////////////////////////////
266// EDIT FACET FROM URL
267////////////////////////////////////////////
268
269export async function editFacetFromURL() {
270 const params = new URLSearchParams(location.search);
271 const idParam = params.get("id");
272 const uriParam = params.get("uri");
273
274 setEditorLoading(true);
275 try {
276 if (idParam) {
277 const out = await output();
278 const col = await Output.data(out.facets);
279 const facet = col.find((f) => f.id === idParam);
280 if (facet) await editFacet(facet);
281 } else if (uriParam) {
282 const facet = await facetFromURI({
283 uri: uriParam,
284 name: params.get("name") ?? "",
285 kind: /** @type {any} */ (params.get("kind") ?? undefined),
286 description: params.get("description") ?? undefined,
287 }, { fetchHTML: true });
288 await editFacet(facet);
289 }
290 } finally {
291 setEditorLoading(false);
292 }
293}