A music player that connects to your cloud/distributed storage.
at v4 390 lines 11 kB view raw
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}