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.

chore: create playlist via playlist facet

+200 -46
+1 -1
src/_data/facets.json
··· 146 146 "title": "Playlists", 147 147 "category": "Data", 148 148 "featured": true, 149 - "desc": "An overview of all playlists and their contents." 149 + "desc": "Manage your playlists." 150 150 }, 151 151 { 152 152 "url": "facets/playback/preload/prelude/index.html",
+10 -1
src/facets/data/playlists/index.html
··· 1 1 <style> 2 + @import "./vendor/@phosphor-icons/web/bold/style.css"; 3 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 4 + 2 5 @import "./styles/base.css"; 3 6 @import "./styles/diffuse/facet.css"; 4 - @import "./vendor/@phosphor-icons/web/fill/style.css"; 5 7 6 8 @layer base, diffuse; 7 9 ··· 98 100 </style> 99 101 100 102 <dialog id="playlists-dialog"></dialog> 103 + <dialog id="create-playlist-dialog"></dialog> 101 104 102 105 <main> 103 106 <div class="facet__left"> ··· 114 117 </div> 115 118 <h1>Playlists</h1> 116 119 <p>An overview of all playlists and their contents.</p> 120 + <p class="button-row" style="margin-top: var(--space-md)"> 121 + <button id="create-playlist-btn" class="button button--accent"> 122 + <i class="ph-bold ph-plus"></i> 123 + Create playlist 124 + </button> 125 + </p> 117 126 </div> 118 127 119 128 <div class="facet__right">
+165 -44
src/facets/data/playlists/index.inline.js
··· 2 2 3 3 import * as Output from "~/common/output.js"; 4 4 import * as Playlist from "~/common/playlist.js"; 5 + import * as TID from "@atcute/tid"; 5 6 import foundation from "~/common/foundation.js"; 6 7 import { effect } from "~/common/signal.js"; 7 8 ··· 28 29 const empty = 29 30 /** @type {HTMLElement} */ (document.querySelector("#playlists-empty")); 30 31 const dialog = 31 - /** @type {HTMLDialogElement} */ (document.querySelector("#playlists-dialog")); 32 + /** @type {HTMLDialogElement} */ (document.querySelector( 33 + "#playlists-dialog", 34 + )); 35 + const createDialog = 36 + /** @type {HTMLDialogElement} */ (document.querySelector( 37 + "#create-playlist-dialog", 38 + )); 39 + 40 + document.querySelector("#create-playlist-btn")?.addEventListener( 41 + "click", 42 + () => { 43 + showCreatePlaylist(); 44 + }, 45 + ); 32 46 33 47 effect(() => { 34 48 const playlistItemsCol = outputOrchestrator.playlistItems.collection(); 35 - const playlistItems = 36 - playlistItemsCol.state === "loaded" ? playlistItemsCol.data : []; 49 + const playlistItems = playlistItemsCol.state === "loaded" 50 + ? playlistItemsCol.data 51 + : []; 37 52 38 53 const tracksCol = outputOrchestrator.tracks.collection(); 39 54 const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; ··· 48 63 empty.hidden = playlists.length > 0; 49 64 50 65 /** @param {typeof playlists} group @param {number} offset */ 51 - const renderGroup = (group, offset) => group.map(({ name, items }, index) => { 52 - const menuId = `playlists-menu-${offset + index}`; 53 - return html` 54 - <li class="playlists-item"> 66 + const renderGroup = (group, offset) => 67 + group.map(({ name, items }, index) => { 68 + const menuId = `playlists-menu-${offset + index}`; 69 + return html` 70 + <li class="playlists-item"> 55 71 <div class="playlists-item__info"> 56 72 <span class="playlists-item__name">${name}</span> 57 73 </div> ··· 65 81 <div id="${menuId}" class="dropdown" popover> 66 82 <button 67 83 @click="${(/** @type {MouseEvent} */ e) => { 68 - /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 84 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e 85 + .currentTarget).closest("[popover]"))?.hidePopover(); 69 86 showDetails(name, tracks, items); 70 87 }}" 71 88 > ··· 74 91 </button> 75 92 <button 76 93 @click="${(/** @type {MouseEvent} */ e) => { 77 - /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 94 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e 95 + .currentTarget).closest("[popover]"))?.hidePopover(); 78 96 removeDuplicates(name, items); 79 97 }}" 80 98 > ··· 83 101 </button> 84 102 <button 85 103 @click="${(/** @type {MouseEvent} */ e) => { 86 - /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.currentTarget).closest("[popover]"))?.hidePopover(); 104 + /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e 105 + .currentTarget).closest("[popover]"))?.hidePopover(); 87 106 removePlaylist(name); 88 107 }}" 89 108 > ··· 93 112 </div> 94 113 </li> 95 114 `; 96 - }); 115 + }); 97 116 98 117 litRender( 99 118 html` 100 - ${orderedPlaylists.length > 0 ? html` 101 - <li class="playlists-category">Ordered</li> 102 - ${renderGroup(orderedPlaylists, 0)} 103 - ` : null} 104 - ${unorderedPlaylists.length > 0 ? html` 105 - <li class="playlists-category">Not ordered</li> 106 - ${renderGroup(unorderedPlaylists, orderedPlaylists.length)} 107 - ` : null} 119 + ${orderedPlaylists.length > 0 120 + ? html` 121 + <li class="playlists-category">Ordered</li> 122 + ${renderGroup(orderedPlaylists, 0)} 123 + ` 124 + : null} ${unorderedPlaylists.length > 0 125 + ? html` 126 + <li class="playlists-category">Not ordered</li> 127 + ${renderGroup(unorderedPlaylists, orderedPlaylists.length)} 128 + ` 129 + : null} 108 130 `, 109 131 list, 110 132 ); ··· 213 235 } 214 236 } 215 237 216 - found.sort((a, b) => (a.tags?.title ?? "").localeCompare(b.tags?.title ?? "")); 238 + found.sort((a, b) => 239 + (a.tags?.title ?? "").localeCompare(b.tags?.title ?? "") 240 + ); 217 241 218 242 const notFound = notFoundItems 219 243 .map((item) => ({ 220 - title: String(item.criteria.find((c) => c.field === "tags.title")?.value ?? ""), 221 - artist: String(item.criteria.find((c) => c.field === "tags.artist")?.value ?? "") || null, 222 - album: String(item.criteria.find((c) => c.field === "tags.album")?.value ?? "") || null, 244 + title: String( 245 + item.criteria.find((c) => c.field === "tags.title")?.value ?? "", 246 + ), 247 + artist: 248 + String( 249 + item.criteria.find((c) => c.field === "tags.artist")?.value ?? "", 250 + ) || null, 251 + album: 252 + String( 253 + item.criteria.find((c) => c.field === "tags.album")?.value ?? "", 254 + ) || null, 223 255 })) 224 256 .sort((a, b) => a.title.localeCompare(b.title)); 225 257 ··· 238 270 <div class="tracks-section"> 239 271 <span class="tracks-section__heading">${found.length} found</span> 240 272 <ul class="tracks-list"> 241 - ${found.map((t) => html` 242 - <li> 243 - <span class="tracks-list__name">${t.tags?.title ?? t.uri}</span> 244 - ${t.tags?.artist 245 - ? html`<span class="tracks-list__artist">${t.tags.artist}</span>` 246 - : null} 247 - </li> 248 - `)} 273 + ${found.map((t) => 274 + html` 275 + <li> 276 + <span class="tracks-list__name">${t.tags?.title ?? 277 + t.uri}</span> 278 + ${t.tags?.artist 279 + ? html` 280 + <span class="tracks-list__artist">${t.tags.artist}</span> 281 + ` 282 + : null} 283 + </li> 284 + ` 285 + )} 249 286 </ul> 250 287 </div> 251 - ${notFound.length > 0 ? html` 252 - <div class="tracks-section"> 253 - <span class="tracks-section__heading">${notFound.length} not found</span> 254 - <ul class="tracks-list"> 255 - ${notFound.map(({ title, artist, album }) => html` 256 - <li> 257 - <span class="tracks-list__name">${title}</span> 258 - ${artist ? html`<span class="tracks-list__artist">${artist}${album ? html` · ${album}` : null}</span>` : null} 259 - </li> 260 - `)} 261 - </ul> 262 - </div> 263 - ` : null} 288 + ${notFound.length > 0 289 + ? html` 290 + <div class="tracks-section"> 291 + <span class="tracks-section__heading">${notFound 292 + .length} not found</span> 293 + <ul class="tracks-list"> 294 + ${notFound.map(({ title, artist, album }) => 295 + html` 296 + <li> 297 + <span class="tracks-list__name">${title}</span> 298 + ${artist 299 + ? html` 300 + <span class="tracks-list__artist">${artist}${album 301 + ? html` 302 + · ${album} 303 + ` 304 + : null}</span> 305 + ` 306 + : null} 307 + </li> 308 + ` 309 + )} 310 + </ul> 311 + </div> 312 + ` 313 + : null} 264 314 </div> 265 315 `, 266 316 dialog, 267 317 ); 268 318 269 319 dialog.showModal(); 320 + } 321 + 322 + function showCreatePlaylist() { 323 + litRender( 324 + html` 325 + <div class="dialog-header"> 326 + <strong>Create playlist</strong> 327 + <button 328 + class="button--plain button--icon" 329 + @click="${() => createDialog.close()}" 330 + > 331 + <i class="ph-fill ph-x"></i> 332 + </button> 333 + </div> 334 + <div class="dialog-body"> 335 + <form 336 + class="create-form" 337 + @submit="${async (/** @type {SubmitEvent} */ e) => { 338 + e.preventDefault(); 339 + const form = /** @type {HTMLFormElement} */ (e.currentTarget); 340 + const name = 341 + /** @type {HTMLInputElement} */ (form.elements.namedItem("name")) 342 + ?.value.trim(); 343 + if (!name) return; 344 + await createPlaylist(name); 345 + createDialog.close(); 346 + }}" 347 + > 348 + <label> 349 + <span class="create-form__label">Name</span> 350 + <input 351 + name="name" 352 + type="text" 353 + placeholder="My playlist" 354 + autocomplete="off" 355 + required 356 + autofocus 357 + /> 358 + </label> 359 + <p class="button-row"> 360 + <button class="button button--accent" type="submit"> 361 + <i class="ph-bold ph-plus"></i> 362 + Create playlist 363 + </button> 364 + </p> 365 + </form> 366 + </div> 367 + `, 368 + createDialog, 369 + ); 370 + 371 + createDialog.showModal(); 372 + } 373 + 374 + /** @param {string} name */ 375 + async function createPlaylist(name) { 376 + const existing = await Output.data(outputOrchestrator.playlistItems); 377 + 378 + const now = new Date().toISOString(); 379 + 380 + /** @type {import("~/definitions/types.d.ts").PlaylistItem} */ 381 + const item = { 382 + $type: "sh.diffuse.output.playlistItem", 383 + id: TID.now(), 384 + playlist: name, 385 + criteria: [], 386 + createdAt: now, 387 + updatedAt: now, 388 + }; 389 + 390 + await outputOrchestrator.playlistItems.save([...existing, item]); 270 391 } 271 392 272 393 ////////////////////////////////////////////
+24
src/styles/diffuse/facet.css
··· 286 286 margin: var(--space-sm) 0; 287 287 } 288 288 289 + .create-form { 290 + display: flex; 291 + flex-direction: column; 292 + gap: var(--space-sm); 293 + } 294 + 295 + .create-form__label { 296 + font-size: var(--fs-xs); 297 + font-weight: 600; 298 + letter-spacing: var(--tracking-wider); 299 + opacity: 0.5; 300 + text-transform: uppercase; 301 + } 302 + 303 + .create-form__checkbox-row { 304 + align-items: center; 305 + flex-direction: row; 306 + gap: var(--space-2xs); 307 + 308 + input { 309 + width: auto; 310 + } 311 + } 312 + 289 313 .dropdown { 290 314 background: oklch(from var(--bg-color) calc(l + 0.2) c h / 0.975); 291 315 border: 1px solid var(--border-color);