A music player that connects to your cloud/distributed storage.
1import { html, render } from "lit-html";
2import { keyed } from "lit-html/directives/keyed.js";
3import { marked } from "marked";
4import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
5
6import * as FacetCategory from "~/common/facets/category.js";
7import { effect, signal } from "~/common/signal.js";
8import { facetFromURI } from "~/common/facets/utils.js";
9import { nothing } from "~/common/element.js";
10
11import { deleteFacet, saveFacet } from "./crud.js";
12import { output } from "./output.js";
13
14// Signals
15const activeFilter = signal("all");
16
17/**
18 * @import OutputOrchestrator from "~/components/orchestrator/output/element.js";
19 */
20
21const addFromUri = () =>
22 html`
23 <li
24 class="grid-item"
25 style="color: ${activeFilter.value === "all"
26 ? "inherit"
27 : FacetCategory.color(
28 /** @type {any} */ ({ kind: activeFilter.value }),
29 )}; background: oklch(from currentColor l c h / 0.0625);"
30 >
31 <div
32 class="grid-item__contents"
33 style="display: flex; align-items: center; justify-content: center;"
34 >
35 <button
36 class="button--transparent with-icon"
37 style="color: inherit; font-size: var(--fs-sm); font-weight: 600;"
38 @click="${openAddFromURIModal}"
39 >
40 <i class="ph-fill ph-plus-circle"></i>
41 Add from URI
42 </button>
43 </div>
44 </li>
45 `;
46
47const emptyFacetsList = () =>
48 html`
49 <p>
50 <span>
51 You haven't saved anything yet. Add a facet by browsing the <a
52 href="featured/"
53 >featured ones</a> or any of the other categories. You can click the toggle
54 to quickly add or remove from your collection. Alternatively, add one using
55 an URI:
56 </span>
57 </p>
58 `;
59
60////////////////////////////////////////////
61// DIALOG
62////////////////////////////////////////////
63
64function openAddFromURIModal() {
65 let dialog = /** @type {HTMLDialogElement | null} */ (
66 document.getElementById("add-from-uri-dialog")
67 );
68
69 if (!dialog) {
70 dialog = /** @type {HTMLDialogElement} */ (
71 document.createElement("dialog")
72 );
73
74 dialog.id = "add-from-uri-dialog";
75 dialog.style.cssText =
76 "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0;";
77
78 render(
79 html`
80 <form id="add-from-uri-form">
81 <p>
82 <strong>Load a facet from a URI.</strong> Currently supported URI schemes:
83 <code>https</code>, <code>at</code> (AT Protocol) and <code>diffuse</code>
84 (references internal facets).
85 </p>
86
87 <div style="display: flex; flex-direction: column; gap: var(--space-xs)">
88 <div>
89 <label>Name</label>
90 <input
91 id="add-uri-name"
92 type="text"
93 placeholder="My Feature Name"
94 required
95 autocomplete="off"
96 />
97 </div>
98 <div>
99 <label>Kind</label>
100 <select id="add-uri-kind">
101 <option value="interactive">interface</option>
102 <option value="prelude">feature</option>
103 </select>
104 </div>
105 <div>
106 <label>URI</label>
107 <input
108 id="add-uri-uri"
109 type="url"
110 placeholder="at://..."
111 required
112 autocomplete="off"
113 />
114 </div>
115 </div>
116 <div style="display: flex; gap: var(--space-xs); margin-top: var(--space-sm)">
117 <button type="submit">Add</button>
118 <button type="button" id="add-uri-cancel">
119 Cancel
120 </button>
121 </div>
122 </form>
123 `,
124 dialog,
125 );
126
127 document.body.appendChild(dialog);
128
129 dialog.querySelector("#add-uri-cancel")?.addEventListener("click", () => {
130 /** @type {HTMLDialogElement} */ (dialog).close();
131 });
132
133 dialog.querySelector("#add-from-uri-form")?.addEventListener(
134 "submit",
135 async (e) => {
136 e.preventDefault();
137
138 const nameEl = /** @type {HTMLInputElement} */ (
139 dialog?.querySelector("#add-uri-name")
140 );
141
142 const kindEl = /** @type {HTMLSelectElement} */ (
143 dialog?.querySelector("#add-uri-kind")
144 );
145
146 const uriEl = /** @type {HTMLInputElement} */ (
147 dialog?.querySelector("#add-uri-uri")
148 );
149
150 const name = nameEl?.value.trim() ?? "";
151 const kind = kindEl?.value ?? "interactive";
152 const uri = uriEl?.value.trim() ?? "";
153 if (!name || !uri) return;
154
155 const facet = await facetFromURI({ kind, name, uri }, {
156 fetchHTML: false,
157 });
158 await saveFacet(facet);
159
160 /** @type {HTMLDialogElement} */ (dialog).close();
161 },
162 );
163 }
164
165 const nameEl = /** @type {HTMLInputElement} */ (
166 dialog.querySelector("#add-uri-name")
167 );
168 const kindEl = /** @type {HTMLSelectElement} */ (
169 dialog.querySelector("#add-uri-kind")
170 );
171 const uriEl = /** @type {HTMLInputElement} */ (
172 dialog.querySelector("#add-uri-uri")
173 );
174 if (nameEl) nameEl.value = "";
175 if (kindEl) kindEl.value = "interactive";
176 if (uriEl) uriEl.value = "";
177
178 dialog.showModal();
179}
180
181////////////////////////////////////////////
182// LIST
183////////////////////////////////////////////
184
185/** @type {() => void | undefined} */
186let stopMonitor;
187
188/** */
189export async function renderList() {
190 if (stopMonitor) stopMonitor();
191
192 /** @type {HTMLElement | null} */
193 const listEl = document.querySelector("#list");
194 if (!listEl) throw new Error("List element not found");
195
196 if (listEl.getAttribute("data-rendered") === "f") {
197 listEl.innerHTML = "";
198 listEl.removeAttribute("data-rendered");
199 }
200
201 const out = await output();
202
203 stopMonitor = effect(() => {
204 _renderList(out, listEl);
205 });
206}
207
208/**
209 * @param {OutputOrchestrator} output
210 * @param {HTMLElement} listEl
211 */
212function _renderList(output, listEl) {
213 const facetsCol = output.facets.collection();
214
215 if (facetsCol.state !== "loaded") {
216 const loading = html`
217 <div class="with-icon">
218 <i class="ph-bold ph-spinner animate-spin"></i>
219 Loading your software
220 </div>
221 `;
222
223 render(loading, listEl);
224 return;
225 }
226
227 const filter = activeFilter.get();
228
229 const col = facetsCol.state === "loaded"
230 ? [...facetsCol.data]
231 .filter((c) =>
232 filter === "all" ||
233 (filter === "prelude" ? c.kind === "prelude" : c.kind !== "prelude")
234 )
235 .sort((a, b) => {
236 return a.name.toLocaleLowerCase().localeCompare(
237 b.name.toLocaleLowerCase(),
238 );
239 })
240 : [];
241
242 const selected = output.selected();
243 const outputLabel = selected?.label ?? selected?.getAttribute?.("label") ??
244 "Local storage";
245
246 const filterBar = html`
247 <div class="grid-filter">
248 <span class="grid-filter--label">Filter by</span>
249 <button
250 class="button--border button--tiny ${filter === "all"
251 ? ""
252 : "button--transparent"}"
253 @click="${() => activeFilter.set("all")}"
254 >
255 All
256 </button>
257 <button
258 class="button--border button--tiny button--bg-twist-4 button--tr-twist-4 ${filter ===
259 "prelude"
260 ? ""
261 : "button--transparent"}"
262 @click="${() => activeFilter.set("prelude")}"
263 >
264 Features
265 </button>
266 <button
267 class="button--border button--tiny button--bg-twist-2 button--tr-twist-2 ${filter ===
268 "interface"
269 ? ""
270 : "button--transparent"}"
271 @click="${() => activeFilter.set("interface")}"
272 >
273 Interfaces
274 </button>
275
276 <div style="flex: 1"></div>
277
278 <span class="grid-filter--label grid-filter--label-output"
279 >Userdata from</span>
280 <span class="grid-filter--output">${outputLabel}</span>
281 </div>
282 `;
283
284 const h = col.length || filter !== "all"
285 ? html`
286 ${filterBar}
287 <ul class="grid" style="margin: 0">
288 ${col.map((c, index) => {
289 const color = FacetCategory.color(c);
290 const kind = FacetCategory.name(c);
291
292 const title = c.kind === "prelude"
293 ? html`
294 <span style="display: inline-block; padding: var(--space-3xs) 0">
295 ${c.name}
296 </span>
297 `
298 : html`
299 <a
300 href="l/?id=${c
301 .id}"
302 style="display: inline-block; padding: var(--space-3xs) 0"
303 >
304 ${c.name}
305 </a>
306 `;
307
308 return keyed(
309 c.id,
310 html`
311 <li class="grid-item">
312 <div
313 class="grid-item__contents"
314 style="--grid-item-gradient: linear-gradient(to bottom, oklch(from ${color} l c h / 0.075), transparent 65%)"
315 >
316 <div class="grid-item__title" style="color: ${color}">
317 ${title}
318 </div>
319 <div class="list-description">
320 <div>
321 ${c.description?.trim().length
322 ? unsafeHTML(
323 marked.parse(c.description, { async: false }),
324 )
325 : nothing}
326 </div>
327 <div>
328 ${c.uri && !c.html
329 ? html`
330 <span class="with-icon">
331 <i class="ph-fill ph-binoculars"></i>
332 <span>Tracking the original <a href="${c
333 .uri}">URI</a></span>
334 </span>
335 `
336 : html`
337 <span class="with-icon">
338 <i class="ph-fill ph-code-simple"></i>
339 <span>Custom code</span>
340 </span>
341 `}
342 </div>
343 </div>
344 </div>
345
346 <div class="grid-item__menu">
347 <a
348 class="button button--transparent"
349 title="Edit"
350 href="build/?id=${encodeURIComponent(c.id)}"
351 >
352 <i class="ph-fill ph-code-block"></i>
353 </a>
354 <hr />
355 <button
356 class="button--transparent"
357 title="Delete"
358 @click="${deleteFacet({ id: c.id })}"
359 >
360 <i class="ph-fill ph-skull"></i>
361 </button>
362 </div>
363 </li>
364 `,
365 );
366 })}
367 </ul>
368 `
369 : html`
370 ${filterBar} ${emptyFacetsList()}
371 <ul class="grid" style="margin: var(--space-sm) 0 0">
372 ${addFromUri()}
373 </ul>
374 `;
375
376 render(h, listEl);
377
378 setTimeout(() => {
379 /** @type {HTMLElement | null} */
380 const l = listEl.querySelector(".grid-filter--label-output");
381
382 /** @type {HTMLElement | null} */
383 const o = listEl.querySelector(".grid-filter--output");
384
385 if (o && l) {
386 l.style.opacity = "0.4";
387 o.style.opacity = "1";
388 }
389 }, 250);
390}