A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: theme loader

+455 -3
+34
src/common/themes/utils.js
··· 1 + import { Temporal } from "@js-temporal/polyfill"; 2 + import * as CID from "../cid.js"; 3 + 4 + /** 5 + * @import {Theme} from "@definitions/types.d.ts" 6 + */ 7 + 8 + /** 9 + * @param {{ name: string; url: string }} _args 10 + * @param {{ fetchHTML: boolean }} options 11 + */ 12 + export async function themeFromUrl({ name, url }, { fetchHTML }) { 13 + const html = fetchHTML 14 + ? await fetch(url).then((res) => res.text()) 15 + : undefined; 16 + const cid = html 17 + ? await CID.create(0x55, new TextEncoder().encode(html)) 18 + : undefined; 19 + const timestamp = Temporal.Now.zonedDateTimeISO().toString(); 20 + 21 + /** @type {Theme} */ 22 + const theme = { 23 + $type: "sh.diffuse.output.theme", 24 + createdAt: timestamp, 25 + id: crypto.randomUUID(), 26 + cid, 27 + html, 28 + name, 29 + updatedAt: timestamp, 30 + url, 31 + }; 32 + 33 + return theme; 34 + }
+2 -2
src/facets/index.vto
··· 54 54 <ul class="table-of-contents"> 55 55 <li><a href="facets/#built-in">Built-in</a></li> 56 56 <li><a href="facets/#community">Community</a></li> 57 - <li><a href="facets/#saved">Your collection</a></li> 57 + <li><a href="facets/#collection">Your collection</a></li> 58 58 <li><a href="facets/#examples">Examples</a></li> 59 59 <li><a href="facets/#build">Build</a></li> 60 60 <li><a href="facets/#foundation">Foundation</a></li> ··· 85 85 <!-- LIST + EXAMPLES --> 86 86 <div class="columns"> 87 87 <section class="flex"> 88 - <h2 id="saved">Your collection</h2> 88 + <h2 id="collection">Your collection</h2> 89 89 <div id="list"></div> 90 90 </section> 91 91
+2 -1
src/facets/l/index.js
··· 60 60 if (!facet) return; 61 61 62 62 // Make sure HTML is loaded 63 + // TODO: Handle URL loading error 63 64 if (!facet.html && facet.url) { 64 65 const html = await fetch(facet.url).then((res) => res.text()); 65 66 const cid = await CID.create(0x55, new TextEncoder().encode(html)); 66 67 67 68 facet.html = html; 68 - facet.cid = cid 69 + facet.cid = cid; 69 70 } 70 71 71 72 loadIntoContainer(facet);
+260
src/themes/index.js
··· 1 + import { Temporal } from "@js-temporal/polyfill"; 2 + import { html, render } from "lit-html"; 3 + 4 + import { basicSetup, EditorView } from "codemirror"; 5 + import { css as langCss } from "@codemirror/lang-css"; 6 + import { html as langHtml } from "@codemirror/lang-html"; 7 + import { javascript as langJs } from "@codemirror/lang-javascript"; 8 + import { autocompletion } from "@codemirror/autocomplete"; 9 + 10 + import * as CID from "@common/cid.js"; 11 + import foundation from "@common/facets/foundation.js"; 12 + import { effect, signal } from "@common/signal.js"; 13 + import { themeFromUrl } from "@common/themes/utils.js"; 14 + 15 + /** 16 + * @import {Theme} from "@definitions/types.d.ts" 17 + */ 18 + 19 + //////////////////////////////////////////// 20 + // SAVE & FORK 21 + //////////////////////////////////////////// 22 + 23 + document.body.addEventListener( 24 + "click", 25 + /** 26 + * @param {MouseEvent} event 27 + */ 28 + async (event) => { 29 + const target = /** @type {HTMLElement} */ (event.target); 30 + const rel = target.getAttribute("rel"); 31 + if (!rel) return; 32 + 33 + const url = target.closest("li")?.getAttribute("data-url"); 34 + if (!url) return; 35 + 36 + const name = target.closest("li")?.getAttribute("data-name"); 37 + if (!name) return; 38 + 39 + switch (rel) { 40 + case "fork": { 41 + const theme = await themeFromUrl({ name, url }, { fetchHTML: true }); 42 + editTheme(theme); 43 + document.querySelector("#build")?.scrollIntoView(); 44 + break; 45 + } 46 + case "save": { 47 + const theme = await themeFromUrl({ name, url }, { fetchHTML: false }); 48 + const out = foundation.orchestrator.output(); 49 + 50 + out.themes.save([ 51 + ...out.themes.collection(), 52 + theme, 53 + ]); 54 + break; 55 + } 56 + } 57 + }, 58 + ); 59 + 60 + //////////////////////////////////////////// 61 + // YOUR COLLECTION 62 + //////////////////////////////////////////// 63 + 64 + /** @type {HTMLElement | null} */ 65 + const listEl = document.querySelector("#list"); 66 + if (!listEl) throw new Error("List element not found"); 67 + 68 + const output = foundation.orchestrator.output(); 69 + 70 + effect(() => { 71 + const col = output.themes.collection().sort((a, b) => { 72 + return a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()); 73 + }); 74 + 75 + const h = col.length 76 + ? html` 77 + <ul> 78 + ${col.map((c) => 79 + html` 80 + <li style="margin-bottom: var(--space-2xs)"> 81 + <span>${c.name}</span> 82 + <div class="list-description"> 83 + <div style="margin-bottom: var(--space-2xs)"> 84 + ${c.url && !c.html 85 + ? html` 86 + <span class="with-icon"> 87 + <i class="ph-fill ph-binoculars"></i> 88 + <span>Tracking the original <a href="${c 89 + .url}">URL</a></span> 90 + </span> 91 + ` 92 + : html` 93 + <span class="with-icon"> 94 + <i class="ph-fill ph-code"></i> 95 + <span>Custom code</span> 96 + </span> 97 + `} 98 + </div> 99 + <div class="button-row"> 100 + <a href="themes/l/?id=${c.id}" class="button">Open</a> 101 + <button 102 + class="button--bg-twist-4" 103 + @click="${() => editTheme(c)}" 104 + > 105 + Edit 106 + </button> 107 + <button 108 + class="button--bg-twist-2" 109 + @click="${deleteTheme({ 110 + id: c.id, 111 + })}" 112 + > 113 + Delete 114 + </button> 115 + </div> 116 + </div> 117 + </li> 118 + ` 119 + )} 120 + </ul> 121 + ` 122 + : output.themes.state() === "loaded" 123 + ? emptyThemesList 124 + : html` 125 + <i class="ph-bold ph-spinner-gap"></i> 126 + `; 127 + 128 + render(h, listEl); 129 + }); 130 + 131 + const emptyThemesList = html` 132 + <p style="margin-bottom: 0;"> 133 + <i class="ph-fill ph-info"></i> You have not saved any themes yet. 134 + </p> 135 + `; 136 + 137 + /** 138 + * @param {{ id: string }} _ 139 + */ 140 + function deleteTheme({ id }) { 141 + return () => { 142 + const c = confirm("Are you sure you want to delete this theme?"); 143 + if (!c) return; 144 + 145 + output.themes.save( 146 + output.themes.collection().filter((c) => !(c.id === id)), 147 + ); 148 + }; 149 + } 150 + 151 + //////////////////////////////////////////// 152 + // BUILD 153 + //////////////////////////////////////////// 154 + 155 + const $editingTheme = signal(/** @type {Theme | null} */ (null)); 156 + 157 + // Code editor 158 + const editorContainer = document.body.querySelector("#html-input-container"); 159 + if (!editorContainer) throw new Error("Editor container not found"); 160 + 161 + const editor = new EditorView({ 162 + parent: editorContainer, 163 + doc: ``.trim(), 164 + extensions: [ 165 + basicSetup, 166 + langHtml(), 167 + langCss(), 168 + langJs(), 169 + autocompletion(), 170 + ], 171 + }); 172 + 173 + // Form submit 174 + document.querySelector("#build-form")?.addEventListener( 175 + "submit", 176 + onBuildSubmit, 177 + ); 178 + 179 + /** 180 + * @param {Event} event 181 + */ 182 + async function onBuildSubmit(event) { 183 + event.preventDefault(); 184 + 185 + const nameEl = /** @type {HTMLInputElement | null} */ (document.querySelector( 186 + "#name-input", 187 + )); 188 + 189 + const html = editor.state.doc.toString(); 190 + const cid = await CID.create(0x55, new TextEncoder().encode(html)); 191 + const name = nameEl?.value ?? "nameless"; 192 + 193 + /** @type {Theme} */ 194 + const theme = $editingTheme.value 195 + ? { 196 + ...$editingTheme.value, 197 + cid, 198 + html, 199 + name, 200 + } 201 + : { 202 + $type: "sh.diffuse.output.theme", 203 + id: crypto.randomUUID(), 204 + cid, 205 + html, 206 + name, 207 + }; 208 + 209 + switch (/** @type {any} */ (event).submitter.name) { 210 + case "save": 211 + await saveTheme(theme); 212 + break; 213 + case "save+open": 214 + await saveTheme(theme); 215 + globalThis.open(`./themes/l/?cid=${theme.cid}`, "blank"); 216 + break; 217 + } 218 + } 219 + 220 + /** 221 + * @param {Theme} ogTheme 222 + */ 223 + async function editTheme(ogTheme) { 224 + const theme = { ...ogTheme }; 225 + const nameEl = /** @type {HTMLInputElement | null} */ (document.querySelector( 226 + "#name-input", 227 + )); 228 + 229 + if (!nameEl) return; 230 + 231 + // Make sure HTML is loaded 232 + if (!theme.html && theme.url) { 233 + const html = await fetch(theme.url).then((res) => res.text()); 234 + const cid = await CID.create(0x55, new TextEncoder().encode(html)); 235 + 236 + theme.html = html; 237 + theme.cid = cid; 238 + } 239 + 240 + $editingTheme.value = theme; 241 + nameEl.value = theme.name; 242 + 243 + editor.dispatch({ 244 + changes: { from: 0, to: editor.state.doc.length, insert: theme.html }, 245 + }); 246 + } 247 + 248 + /** 249 + * @param {Theme} theme 250 + */ 251 + async function saveTheme(theme) { 252 + const col = output.themes.collection(); 253 + const colWithoutId = col.filter((c) => c.id !== theme.id); 254 + const timestamp = Temporal.Now.zonedDateTimeISO().toString(); 255 + 256 + await output.themes.save([...colWithoutId, { 257 + ...theme, 258 + updatedAt: timestamp, 259 + }]); 260 + }
+54
src/themes/index.vto
··· 51 51 52 52 <ul class="table-of-contents"> 53 53 <li><a href="themes/#built-in">Built-in</a></li> 54 + <li><a href="themes/#community">Community</a></li> 55 + <li><a href="themes/#collection">Your collection</a></li> 56 + <li><a href="themes/#build">Build</a></li> 54 57 </ul> 55 58 </div> 56 59 <div class="dither-mask filler"></div> ··· 64 67 {{ await comp.list({ items: builtIn }) }} 65 68 </section> 66 69 70 + <section class="flex"> 71 + <h2 id="community">Community</h2> 72 + <p> 73 + Check out some themes from the community and load them here. 74 + </p> 75 + <p> 76 + <small><i class="ph-fill ph-info"></i> Nothing here yet, too early.</small> 77 + </p> 78 + </section> 79 + </div> 80 + 81 + <!-- YOUR COLLECTION --> 82 + <div class="columns"> 83 + <section class="flex"> 84 + <h2 id="collection">Your collection</h2> 85 + <div id="list"></div> 86 + </section> 87 + 67 88 <section class="flex"></section> 68 89 </div> 90 + 91 + <!-- / --> 92 + <div class="dither-mask filler" style="height: var(--space-2xl); margin-top: var(--space-2xl);"></div> 93 + 94 + <!-- BUILD --> 95 + <section> 96 + <h2 id="build">Build</h2> 97 + 98 + <form id="build-form" class="columns"> 99 + <div class="flex"> 100 + <p style="margin-top: 0"> 101 + If you know a bit of HTML & Javascript, you can write your own or plug in some code you found elsewhere: 102 + </p> 103 + 104 + <div id="html-input-container" class="code-editor monospace-font"> 105 + </div> 106 + </div> 107 + 108 + <div class="flex"> 109 + <p style="margin-top: 0"> 110 + Your code here will be loaded in a dedicated page, it'll be injected into a <code>&lt;iframe&gt;</code> element in the body. You have access to the elements listed on the <a href="./#elements">index page</a> and the facets <a href="facets/#foundation">foundation</a>. 111 + </p> 112 + <input id="name-input" type="text" placeholder="Name" name="name" required /> 113 + <p> 114 + <span class="button-row"> 115 + <button name="save">Save</button> 116 + <button name="save+open">Save &amp; Open</button> 117 + </span> 118 + </p> 119 + </div> 120 + </form> 121 + </section> 122 + 69 123 </main>
+12
src/themes/l/index.css
··· 1 + body { 2 + margin: 0; 3 + overflow: hidden; 4 + padding: 0; 5 + } 6 + 7 + iframe { 8 + border: 0; 9 + display: block; 10 + height: 100dvh; 11 + width: 100%; 12 + }
+81
src/themes/l/index.js
··· 1 + import * as CID from "@common/cid.js"; 2 + import foundation from "@common/facets/foundation.js"; 3 + import { effect } from "@common/signal.js"; 4 + 5 + /** 6 + * @import {Theme} from "@definitions/types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // OUTPUT 11 + //////////////////////////////////////////// 12 + 13 + const output = foundation.orchestrator.output(); 14 + 15 + //////////////////////////////////////////// 16 + // URL PARAMS 17 + //////////////////////////////////////////// 18 + 19 + const docUrl = new URL(document.location.href); 20 + 21 + const id = docUrl.searchParams.get("id"); 22 + const cid = docUrl.searchParams.get("cid"); 23 + const name = docUrl.searchParams.get("name"); 24 + const url = docUrl.searchParams.get("url"); 25 + 26 + //////////////////////////////////////////// 27 + // LOAD 28 + //////////////////////////////////////////// 29 + 30 + effect(async () => { 31 + const collection = output.themes.collection(); 32 + if (output.themes.state() !== "loaded") return; 33 + 34 + let theme; 35 + 36 + if (id) { 37 + theme = collection.find((t) => t.id === id); 38 + } else if (cid) { 39 + theme = collection.find((t) => t.cid === cid); 40 + } else if (name) { 41 + theme = collection.find((t) => t.name === name); 42 + } else if (url) { 43 + /** @type {Theme} */ 44 + const t = { 45 + $type: "sh.diffuse.output.theme", 46 + id: crypto.randomUUID(), 47 + name: "tryout", 48 + url, 49 + }; 50 + 51 + theme = t; 52 + } 53 + 54 + // TODO: Message that theme was not found 55 + if (!theme) return; 56 + 57 + // Make sure HTML is loaded 58 + // TODO: Handle URL loading error 59 + if (!theme.html && theme.url) { 60 + const html = await fetch(theme.url).then((res) => res.text()); 61 + const cid = await CID.create(0x55, new TextEncoder().encode(html)); 62 + 63 + theme.html = html; 64 + theme.cid = cid; 65 + } 66 + 67 + loadIntoContainer(theme); 68 + }); 69 + 70 + /** 71 + * @param {Theme} theme 72 + */ 73 + function loadIntoContainer(theme) { 74 + // TODO: Validate if CID matches HTML 75 + 76 + const iframe = document.createElement("iframe") 77 + iframe.srcdoc = theme.html ?? "" 78 + 79 + document.body.innerHTML = "" 80 + document.body.append(iframe) 81 + }
+10
src/themes/l/index.vto
··· 1 + --- 2 + layout: layouts/facet.vto 3 + base: ../../ 4 + 5 + scripts: 6 + - themes/l/index.js 7 + 8 + styles: 9 + - themes/l/index.css 10 + ---