forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { html, render } from "lit-html";
2import { classMap } from "lit-html/directives/class-map.js";
3import { keyed } from "lit-html/directives/keyed.js";
4import { marked } from "marked";
5import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
6
7import * as FacetCategory from "~/common/facets/category.js";
8import { effect, signal } from "~/common/signal.js";
9
10import { nothing } from "~/common/element.js";
11
12import { deleteFacet, toggleFacetEnabled } from "./crud.js";
13import { output } from "./output.js";
14import { openAddFromURIModal } from "./from-uri.js";
15
16// Signals
17const FILTER_STORAGE_KEY = "diffuse/dashboard/filter";
18const storedFilter = localStorage.getItem(FILTER_STORAGE_KEY);
19const activeFilter = signal(
20 storedFilter === "prelude" || storedFilter === "interface" ||
21 storedFilter === "base"
22 ? storedFilter
23 : "all",
24);
25
26effect(() => {
27 localStorage.setItem(FILTER_STORAGE_KEY, activeFilter.get());
28});
29
30/**
31 * @import OutputOrchestrator from "~/components/orchestrator/output/element.js";
32 */
33
34const emptyFacetsList = () =>
35 html`
36 <p>
37 <span>
38 You haven't saved anything yet. Add a facet by browsing the <a
39 href="featured/"
40 >featured ones</a> or any of the other categories. You can click the toggle
41 to quickly add or remove from your collection. Alternatively, add one using
42 an URI:
43 </span>
44 </p>
45 `;
46
47////////////////////////////////////////////
48// LIST
49////////////////////////////////////////////
50
51/** @type {() => void | undefined} */
52let stopMonitor;
53
54/** */
55export async function renderList() {
56 if (stopMonitor) stopMonitor();
57 activeFilter.set((() => {
58 const stored = localStorage.getItem(FILTER_STORAGE_KEY);
59 return stored === "prelude" || stored === "interface" || stored === "base"
60 ? stored
61 : "all";
62 })());
63
64 /** @type {HTMLElement | null} */
65 const listEl = document.querySelector("#list");
66 if (!listEl) throw new Error("List element not found");
67
68 if (listEl.getAttribute("data-rendered") === "f") {
69 listEl.innerHTML = "";
70 listEl.removeAttribute("data-rendered");
71 }
72
73 const out = await output();
74
75 stopMonitor = effect(() => {
76 _renderList(out, listEl);
77 });
78}
79
80/**
81 * @param {OutputOrchestrator} output
82 * @param {HTMLElement} listEl
83 */
84function _renderList(output, listEl) {
85 const facetsCol = output.facets.collection();
86
87 if (facetsCol.state !== "loaded") {
88 const loading = html`
89 <div class="with-icon" style="font-size: var(--fs-sm)">
90 <i class="ph-bold ph-spinner animate-spin"></i>
91 Loading your software
92 </div>
93 `;
94
95 render(loading, listEl);
96 return;
97 }
98
99 const filter = activeFilter.get();
100
101 const col = facetsCol.state === "loaded"
102 ? [...facetsCol.data]
103 .filter((c) =>
104 filter === "base" ? !!c.tags?.includes("base") : (filter === "all" ||
105 (filter === "prelude"
106 ? c.kind === "prelude"
107 : c.kind !== "prelude")) &&
108 !c.tags?.includes("base")
109 )
110 .sort((a, b) => {
111 return a.name.toLocaleLowerCase().localeCompare(
112 b.name.toLocaleLowerCase(),
113 ) || a.id.localeCompare(b.id);
114 })
115 : [];
116
117 const selected = output.selected();
118 const outputLabel = selected?.label ?? selected?.getAttribute?.("label") ??
119 "Local storage";
120
121 const filterBar = html`
122 <div class="grid-filter">
123 <span class="grid-filter--label">Filter by</span>
124 <button
125 class="button--border button--tiny ${filter === "all"
126 ? ""
127 : "button--transparent"}"
128 @click="${() => activeFilter.set("all")}"
129 >
130 All
131 </button>
132 <button
133 class="button--border button--tiny button--bg-twist-4 button--tr-twist-4 ${filter ===
134 "prelude"
135 ? ""
136 : "button--transparent"}"
137 @click="${() => activeFilter.set("prelude")}"
138 >
139 Features
140 </button>
141 <button
142 class="button--border button--tiny button--bg-twist-2 button--tr-twist-2 ${filter ===
143 "interface"
144 ? ""
145 : "button--transparent"}"
146 @click="${() => activeFilter.set("interface")}"
147 >
148 Interfaces
149 </button>
150
151 <button
152 class="button--border button--tiny ${filter === "base"
153 ? ""
154 : "button--transparent"}"
155 title="Show the hidden essential features"
156 @click="${() => activeFilter.set("base")}"
157 >
158 Base
159 </button>
160
161 <span class="divider"></span>
162
163 <button
164 class="button--border button--tiny button--bg-accent button--tr-accent button--transparent"
165 @click="${() => openAddFromURIModal()}"
166 >
167 <span class="with-repositioned-icon">
168 <i class="ph-fill ph-plus-circle"></i>
169 <span class="button__supplementary-text">Add from URI</span>
170 </span>
171 </button>
172
173 <div style="flex: 1"></div>
174
175 <span class="grid-filter--label grid-filter--label-output"
176 >Userdata from</span>
177 <span class="grid-filter--output">${outputLabel}</span>
178 </div>
179 `;
180
181 const h = col.length || filter !== "all"
182 ? html`
183 ${filterBar}
184 <ul class="grid" style="margin: 0">
185 ${col.map((c, index) => {
186 const color = FacetCategory.color(c);
187 const kind = FacetCategory.name(c);
188
189 const title = c.kind === "prelude"
190 ? html`
191 <span style="display: inline-block; padding: var(--space-3xs) 0">
192 ${c.name}
193 </span>
194 `
195 : html`
196 <a
197 href="l/?id=${c
198 .id}"
199 style="display: inline-block; padding: var(--space-3xs) 0"
200 >
201 ${c.name}
202 </a>
203 `;
204
205 return keyed(
206 c.id,
207 html`
208 <li
209 class="grid-item"
210 style="--grid-item-color: ${color}"
211 ?data-disabled="${!(c.enabled ?? true)}"
212 >
213 <div class="grid-item__contents">
214 <div class="grid-item__title" style="color: ${color}">
215 ${title}
216 </div>
217 <div class="list-description">
218 <div>
219 ${c.description?.trim().length
220 ? unsafeHTML(
221 marked.parse(c.description, { async: false }),
222 )
223 : nothing}
224 </div>
225 <div>
226 ${c.uri && !c.html
227 ? html`
228 <span class="with-icon">
229 <i class="ph-fill ph-binoculars"></i>
230 <span>Tracking the original <a href="${c
231 .uri}">URI</a></span>
232 </span>
233 `
234 : html`
235 <span class="with-icon">
236 <i class="ph-fill ph-code-simple"></i>
237 <span>Custom code</span>
238 </span>
239 `}
240 </div>
241 </div>
242 </div>
243
244 <div class="grid-item__menu ${classMap({
245 "grid-item__menu--active": c.enabled ?? true,
246 })}">
247 <button
248 class="button--transparent"
249 title="${(c.enabled ?? true)
250 ? c.kind === "prelude" ? "Disable" : "Dim"
251 : c.kind === "prelude"
252 ? "Enable"
253 : "Light"}"
254 @click="${toggleFacetEnabled({ id: c.id })}"
255 >
256 <i class="${(c.enabled ?? true)
257 ? c.kind === "prelude"
258 ? "ph-fill ph-lightning"
259 : "ph-fill ph-eye"
260 : c.kind === "prelude"
261 ? "ph-bold ph-lightning-slash"
262 : "ph-bold ph-eye-slash"}"></i>
263 </button>
264 <hr />
265 <button
266 class="button--transparent"
267 title="More actions"
268 popovertarget="facet-menu-${c.id}"
269 >
270 <i class="ph-bold ph-dots-three-vertical"></i>
271 </button>
272 <div id="facet-menu-${c.id}" class="dropdown" popover>
273 <a
274 class="with-icon"
275 href="code/?id=${encodeURIComponent(c.id)}"
276 >
277 <i class="ph-fill ph-code-block"></i>
278 Edit
279 </a>
280 <a
281 class="with-icon"
282 href="#"
283 @click="${(/** @type {MouseEvent} */ e) => {
284 e.preventDefault();
285 deleteFacet({ id: c.id })();
286 }}"
287 >
288 <i class="ph-fill ph-skull"></i>
289 Delete
290 </a>
291 </div>
292 </div>
293 </li>
294 `,
295 );
296 })}
297 </ul>
298 `
299 : html`
300 ${filterBar} ${emptyFacetsList()}
301 `;
302
303 render(h, listEl);
304
305 setTimeout(() => {
306 /** @type {HTMLElement | null} */
307 const l = listEl.querySelector(".grid-filter--label-output");
308
309 /** @type {HTMLElement | null} */
310 const o = listEl.querySelector(".grid-filter--output");
311
312 if (o && l) {
313 l.style.opacity = "0.4";
314 o.style.opacity = "1";
315 }
316 }, 250);
317}