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: generic connect template

+323 -263
+99
src/facets/connect/common.css
··· 1 + main { 2 + display: flex; 3 + align-items: center; 4 + justify-content: center; 5 + min-height: 100dvh; 6 + opacity: 0; 7 + pointer-events: none; 8 + transition: opacity 750ms; 9 + 10 + &.has-loaded { 11 + opacity: 1; 12 + pointer-events: auto; 13 + } 14 + } 15 + 16 + wa-card { 17 + width: min(400px, calc(100vw - 2rem)); 18 + } 19 + 20 + .card-header { 21 + display: flex; 22 + align-items: center; 23 + justify-content: space-between; 24 + } 25 + 26 + .card-body { 27 + display: flex; 28 + flex-direction: column; 29 + gap: var(--wa-space-m); 30 + } 31 + 32 + .button-row { 33 + display: flex; 34 + gap: var(--wa-space-s); 35 + flex-wrap: wrap; 36 + } 37 + 38 + .dialog-body { 39 + display: flex; 40 + flex-direction: column; 41 + gap: var(--wa-space-m); 42 + } 43 + 44 + .dialog-footer { 45 + display: flex; 46 + gap: var(--wa-space-s); 47 + } 48 + 49 + .connect-list { 50 + list-style: none; 51 + padding: 0; 52 + margin: 0; 53 + display: flex; 54 + flex-direction: column; 55 + gap: var(--wa-space-s); 56 + } 57 + 58 + .connect-item { 59 + display: flex; 60 + align-items: center; 61 + gap: var(--wa-space-s); 62 + } 63 + 64 + .connect-item__info { 65 + display: flex; 66 + flex-direction: column; 67 + gap: var(--wa-space-2xs); 68 + flex: 1; 69 + min-width: 0; 70 + } 71 + 72 + .connect-item__name { 73 + font-weight: var(--wa-font-weight-semibold); 74 + white-space: nowrap; 75 + overflow: hidden; 76 + text-overflow: ellipsis; 77 + } 78 + 79 + .connect-item__detail { 80 + font-size: var(--wa-font-size-xs); 81 + color: var(--wa-color-text-quiet); 82 + white-space: nowrap; 83 + overflow: hidden; 84 + text-overflow: ellipsis; 85 + } 86 + 87 + .connect-item__tags { 88 + display: flex; 89 + gap: var(--wa-space-2xs); 90 + flex-shrink: 0; 91 + } 92 + 93 + [hidden] { 94 + display: none !important; 95 + } 96 + 97 + p { 98 + margin: 0; 99 + }
+154
src/facets/connect/common.js
··· 1 + import "@awesome.me/webawesome/dist/components/badge/badge.js"; 2 + import "@awesome.me/webawesome/dist/components/button/button.js"; 3 + import "@awesome.me/webawesome/dist/components/card/card.js"; 4 + import "@awesome.me/webawesome/dist/components/dialog/dialog.js"; 5 + import "@awesome.me/webawesome/dist/components/divider/divider.js"; 6 + import "@awesome.me/webawesome/dist/components/icon/icon.js"; 7 + 8 + import { html, nothing, render as litRender } from "lit-html"; 9 + 10 + /** 11 + * @import { default as WaDialog } from "@awesome.me/webawesome/dist/components/dialog/dialog.js" 12 + * @import { TemplateResult } from "lit-html" 13 + */ 14 + 15 + /** 16 + * @typedef {{ name: string; detail: string; isInput: boolean; isOutput: boolean; onRemove: () => void }} ConnectItem 17 + */ 18 + 19 + /** 20 + * Sets up a connect facet UI: a card with "Add audio input" and 21 + * "Use as userdata storage" buttons, a dialog with a form, and a 22 + * reactive list of configured items below a divider. 23 + * 24 + * @param {Object} config 25 + * @param {string} config.title - Card header title 26 + * @param {TemplateResult | string} config.description - Content above the buttons 27 + * @param {TemplateResult} config.formFields - Form body content (inputs, footnotes, etc.) 28 + * @param {(mode: 'input' | 'output') => Promise<void>} config.onSubmit 29 + * 30 + * @returns {{ setItems: (items: ConnectItem[]) => void }} 31 + */ 32 + export function setup({ title, description, formFields, onSubmit }) { 33 + const main = document.querySelector("main"); 34 + if (!main) throw new Error("No <main> element"); 35 + 36 + litRender( 37 + html` 38 + <wa-card> 39 + <div slot="header" class="card-header"> 40 + <strong>${title}</strong> 41 + </div> 42 + <div class="card-body"> 43 + ${description} 44 + <div class="button-row"> 45 + <wa-button id="connect-add-input-btn" variant="brand" appearance="filled"> 46 + <wa-icon slot="start" library="phosphor/bold" name="music-notes"></wa-icon> 47 + Add audio input 48 + </wa-button> 49 + <wa-button id="connect-add-output-btn" variant="neutral" appearance="outlined"> 50 + <wa-icon slot="start" library="phosphor/bold" name="person"></wa-icon> 51 + Use as userdata storage 52 + </wa-button> 53 + </div> 54 + <wa-divider id="connect-divider" hidden></wa-divider> 55 + <ul id="connect-list" class="connect-list" hidden></ul> 56 + </div> 57 + </wa-card> 58 + 59 + <wa-dialog id="connect-dialog" label=""> 60 + <form id="connect-form" class="dialog-body"> 61 + ${formFields} 62 + </form> 63 + <div slot="footer" class="dialog-footer"> 64 + <wa-button 65 + type="submit" 66 + form="connect-form" 67 + variant="brand" 68 + appearance="filled" 69 + >Add</wa-button> 70 + <wa-button id="connect-cancel-btn" variant="neutral" appearance="outlined"> 71 + Cancel 72 + </wa-button> 73 + </div> 74 + </wa-dialog> 75 + `, 76 + main, 77 + ); 78 + 79 + const dialog = /** @type {WaDialog} */ (main.querySelector("#connect-dialog")); 80 + const form = /** @type {HTMLFormElement} */ (main.querySelector("#connect-form")); 81 + const divider = /** @type {HTMLElement} */ (main.querySelector("#connect-divider")); 82 + const list = /** @type {HTMLElement} */ (main.querySelector("#connect-list")); 83 + 84 + /** @type {'input' | 'output'} */ 85 + let mode = "input"; 86 + 87 + /** @param {'input' | 'output'} m */ 88 + const openDialog = (m) => { 89 + mode = m; 90 + dialog.label = m === "input" ? "Add audio input" : "Use as userdata storage"; 91 + form.reset(); 92 + dialog.open = true; 93 + }; 94 + 95 + main 96 + .querySelector("#connect-add-input-btn") 97 + ?.addEventListener("click", () => openDialog("input")); 98 + 99 + main 100 + .querySelector("#connect-add-output-btn") 101 + ?.addEventListener("click", () => openDialog("output")); 102 + 103 + main.querySelector("#connect-cancel-btn")?.addEventListener("click", () => { 104 + dialog.open = false; 105 + }); 106 + 107 + form.addEventListener("submit", async (e) => { 108 + e.preventDefault(); 109 + await onSubmit(mode); 110 + dialog.open = false; 111 + }); 112 + 113 + return { 114 + /** 115 + * Updates the list of configured items below the divider. 116 + * Call inside an effect() for reactivity. 117 + * 118 + * @param {ConnectItem[]} items 119 + */ 120 + setItems(items) { 121 + divider.hidden = items.length === 0; 122 + list.hidden = items.length === 0; 123 + litRender( 124 + html`${items.map( 125 + ({ name, detail, isInput, isOutput, onRemove }) => html` 126 + <li class="connect-item"> 127 + <div class="connect-item__info"> 128 + <span class="connect-item__name">${name}</span> 129 + <span class="connect-item__detail">${detail}</span> 130 + </div> 131 + <div class="connect-item__tags"> 132 + ${isInput 133 + ? html`<wa-badge appearance="outlined" variant="brand">Input</wa-badge>` 134 + : nothing} 135 + ${isOutput 136 + ? html`<wa-badge appearance="outlined" variant="neutral">Output</wa-badge>` 137 + : nothing} 138 + </div> 139 + <wa-button 140 + appearance="plain" 141 + size="small" 142 + aria-label="Remove" 143 + @click="${onRemove}" 144 + > 145 + <wa-icon library="phosphor/bold" name="x"></wa-icon> 146 + </wa-button> 147 + </li> 148 + `, 149 + )}`, 150 + list, 151 + ); 152 + }, 153 + }; 154 + }
+3 -149
src/facets/connect/s3/index.html
··· 1 1 <style> 2 2 @import "./vendor/@awesome.me/webawesome/styles/webawesome.css" layer(wa); 3 + @import "./facets/connect/common.css" layer(connect); 3 4 4 5 @layer base, diffuse, wa; 5 - 6 - main { 7 - display: flex; 8 - align-items: center; 9 - justify-content: center; 10 - min-height: 100dvh; 11 - opacity: 0; 12 - pointer-events: none; 13 - transition: opacity 750ms; 14 - 15 - &.has-loaded { 16 - opacity: 1; 17 - pointer-events: auto; 18 - } 19 - } 20 - 21 - wa-card { 22 - width: min(400px, calc(100vw - 2rem)); 23 - } 24 - 25 - .card-header { 26 - display: flex; 27 - align-items: center; 28 - justify-content: space-between; 29 - } 30 - 31 - .card-body { 32 - display: flex; 33 - flex-direction: column; 34 - gap: var(--wa-space-m); 35 - } 36 - 37 - .button-row { 38 - display: flex; 39 - gap: var(--wa-space-s); 40 - flex-wrap: wrap; 41 - } 42 - 43 - .dialog-body { 44 - display: flex; 45 - flex-direction: column; 46 - gap: var(--wa-space-m); 47 - } 48 - 49 - .dialog-footer { 50 - display: flex; 51 - gap: var(--wa-space-s); 52 - } 53 - 54 - .buckets-list { 55 - list-style: none; 56 - padding: 0; 57 - margin: 0; 58 - display: flex; 59 - flex-direction: column; 60 - gap: var(--wa-space-s); 61 - } 62 - 63 - .bucket-item { 64 - display: flex; 65 - align-items: center; 66 - gap: var(--wa-space-s); 67 - } 68 - 69 - .bucket-info { 70 - display: flex; 71 - flex-direction: column; 72 - gap: var(--wa-space-2xs); 73 - flex: 1; 74 - min-width: 0; 75 - } 76 - 77 - .bucket-name { 78 - font-weight: var(--wa-font-weight-semibold); 79 - white-space: nowrap; 80 - overflow: hidden; 81 - text-overflow: ellipsis; 82 - } 83 - 84 - .bucket-host { 85 - font-size: var(--wa-font-size-xs); 86 - color: var(--wa-color-text-quiet); 87 - white-space: nowrap; 88 - overflow: hidden; 89 - text-overflow: ellipsis; 90 - } 91 - 92 - .bucket-tags { 93 - display: flex; 94 - gap: var(--wa-space-2xs); 95 - flex-shrink: 0; 96 - } 97 - 98 - [hidden] { 99 - display: none !important; 100 - } 101 - 102 - p { 103 - margin: 0; 104 - } 105 6 </style> 106 7 107 - <main class="wa-theme-default"> 108 - <wa-card> 109 - <div slot="header" class="card-header"> 110 - <strong>S3</strong> 111 - </div> 112 - 113 - <div class="card-body"> 114 - <p>Connect to an S3-compatible storage to use it as audio input or user-data storage.</p> 115 - 116 - <p class="wa-caption-xs"> 117 - A custom syncing strategy is used for the user-data storage, tracking what was added and 118 - removed so conflicts can be resolved. 119 - </p> 120 - 121 - <div class="button-row"> 122 - <wa-button id="add-input-btn" variant="brand" appearance="filled"> 123 - <wa-icon slot="start" library="phosphor/bold" name="music-notes"></wa-icon> 124 - Add audio input 125 - </wa-button> 126 - <wa-button id="add-output-btn" variant="neutral" appearance="outlined"> 127 - <wa-icon slot="start" library="phosphor/bold" name="person"></wa-icon> 128 - Use as userdata storage 129 - </wa-button> 130 - </div> 131 - 132 - <wa-divider id="buckets-divider" hidden></wa-divider> 133 - 134 - <ul id="buckets-list" class="buckets-list" hidden> 135 - <!-- Rendered by JS --> 136 - </ul> 137 - </div> 138 - </wa-card> 139 - 140 - <wa-dialog id="bucket-dialog" label="Add S3 Bucket"> 141 - <div class="dialog-body"> 142 - <wa-input id="host-input" label="Host" placeholder="s3.amazonaws.com"></wa-input> 143 - <wa-input id="bucket-name-input" label="Bucket name" placeholder="my-bucket"></wa-input> 144 - <wa-input id="region-input" label="Region" placeholder="us-east-1"></wa-input> 145 - <wa-input id="access-key-input" label="Access key"></wa-input> 146 - <wa-input id="secret-key-input" label="Secret key" type="password"></wa-input> 147 - <wa-input id="path-input" label="Path" placeholder="/"></wa-input> 148 - </div> 149 - <div slot="footer" class="dialog-footer"> 150 - <wa-button id="confirm-btn" variant="brand" appearance="filled">Add</wa-button> 151 - <wa-button id="cancel-btn" variant="neutral" appearance="outlined">Cancel</wa-button> 152 - </div> 153 - </wa-dialog> 154 - </main> 8 + <main class="wa-theme-default"></main> 155 9 156 10 <script type="module" src="facets/connect/s3/index.inline.js"></script> 157 11 158 12 <script type="module"> 159 - await customElements.whenDefined("wa-button"); 13 + await customElements.whenDefined("wa-card"); 160 14 document.querySelector("main")?.classList.add("has-loaded"); 161 15 </script>
+67 -114
src/facets/connect/s3/index.inline.js
··· 1 - import "@awesome.me/webawesome/dist/components/badge/badge.js"; 2 - import "@awesome.me/webawesome/dist/components/button/button.js"; 3 - import "@awesome.me/webawesome/dist/components/card/card.js"; 4 - import "@awesome.me/webawesome/dist/components/dialog/dialog.js"; 5 - import "@awesome.me/webawesome/dist/components/divider/divider.js"; 6 - import "@awesome.me/webawesome/dist/components/icon/icon.js"; 7 1 import "@awesome.me/webawesome/dist/components/input/input.js"; 8 2 9 3 import "~/common/webawesome/detect-dark.js"; 10 4 import "~/common/webawesome/phosphor/bold.js"; 11 5 12 - import { html, nothing, render } from "lit-html"; 6 + import { html } from "lit-html"; 13 7 14 8 import * as IDB from "idb-keyval"; 15 9 import * as TID from "@atcute/tid"; ··· 19 13 import foundation from "~/common/foundation.js"; 20 14 import { effect, signal } from "~/common/signal.js"; 21 15 16 + import { setup } from "~/facets/connect/common.js"; 17 + 22 18 document.title = "Connect S3 | Diffuse"; 23 19 24 20 /** 25 - * @import { default as WaDialog } from "@awesome.me/webawesome/dist/components/dialog/dialog.js" 26 21 * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 27 22 * @import { Bucket } from "~/components/input/s3/types.d.ts" 28 23 */ ··· 46 41 customElements.whenDefined(sourcesOrchestrator.localName), 47 42 ]); 48 43 49 - //////////////////////////////////////////// 50 - // STATE 51 - //////////////////////////////////////////// 52 - 53 - /** @type {'input' | 'output'} */ 54 - let dialogMode = "input"; 55 - 56 44 const $outputBucket = signal( 57 45 /** @type {Bucket | undefined} */ (await IDB.get(OUTPUT_IDB_KEY)), 58 46 ); 59 47 60 48 //////////////////////////////////////////// 61 - // ELEMENTS 49 + // UI 62 50 //////////////////////////////////////////// 63 51 64 - const bucketDialog = /** @type {WaDialog} */ ( 65 - document.querySelector("#bucket-dialog") 66 - ); 52 + const { setItems } = setup({ 53 + title: "S3", 67 54 68 - const hostInput = /** @type {WaInput} */ (document.querySelector("#host-input")); 69 - const bucketNameInput = /** @type {WaInput} */ ( 70 - document.querySelector("#bucket-name-input") 71 - ); 72 - const regionInput = /** @type {WaInput} */ ( 73 - document.querySelector("#region-input") 74 - ); 75 - const accessKeyInput = /** @type {WaInput} */ ( 76 - document.querySelector("#access-key-input") 77 - ); 78 - const secretKeyInput = /** @type {WaInput} */ ( 79 - document.querySelector("#secret-key-input") 80 - ); 81 - const pathInput = /** @type {WaInput} */ (document.querySelector("#path-input")); 55 + description: html` 56 + <p> 57 + Connect to an S3-compatible storage to use it as audio input or user-data 58 + storage. 59 + </p> 60 + <p class="wa-caption-xs"> 61 + A custom syncing strategy is used for the user-data storage, tracking what was 62 + added and removed so conflicts can be resolved. 63 + </p> 64 + `, 65 + 66 + formFields: html` 67 + <wa-input id="s3-access-key" label="Access key" required></wa-input> 68 + <wa-input 69 + id="s3-secret-key" 70 + label="Secret key" 71 + type="password" 72 + required 73 + ></wa-input> 74 + <wa-input 75 + id="s3-bucket-name" 76 + label="Bucket name" 77 + placeholder="my-bucket" 78 + required 79 + ></wa-input> 80 + <wa-input id="s3-host" label="Host" placeholder="s3.amazonaws.com"></wa-input> 81 + <wa-input id="s3-region" label="Region" placeholder="us-east-1"></wa-input> 82 + <wa-input id="s3-path" label="Path" placeholder="/"></wa-input> 83 + <p class="wa-caption-xs">* Required fields</p> 84 + `, 82 85 83 - const bucketsDivider = /** @type {HTMLElement} */ ( 84 - document.querySelector("#buckets-divider") 85 - ); 86 + onSubmit: (mode) => addBucket(mode), 87 + }); 86 88 87 - const bucketsList = /** @type {HTMLElement} */ ( 88 - document.querySelector("#buckets-list") 89 - ); 89 + const accessKeyInput = 90 + /** @type {WaInput} */ (document.querySelector("#s3-access-key")); 91 + const secretKeyInput = 92 + /** @type {WaInput} */ (document.querySelector("#s3-secret-key")); 93 + const bucketNameInput = 94 + /** @type {WaInput} */ (document.querySelector("#s3-bucket-name")); 95 + const hostInput = /** @type {WaInput} */ (document.querySelector("#s3-host")); 96 + const regionInput = 97 + /** @type {WaInput} */ (document.querySelector("#s3-region")); 98 + const pathInput = /** @type {WaInput} */ (document.querySelector("#s3-path")); 90 99 91 100 //////////////////////////////////////////// 92 - // RENDER 101 + // REACTIVE LIST 93 102 //////////////////////////////////////////// 94 103 95 104 effect(() => { ··· 130 139 } 131 140 } 132 141 133 - const hasAny = allBuckets.size > 0; 134 - bucketsDivider.hidden = !hasAny; 135 - bucketsList.hidden = !hasAny; 136 - 137 - render( 138 - html` 139 - ${[...allBuckets.entries()].map( 140 - ([id, { name, host, uri, isInput, isOutput }]) => html` 141 - <li class="bucket-item"> 142 - <div class="bucket-info"> 143 - <span class="bucket-name">${name}</span> 144 - <span class="bucket-host">${host}</span> 145 - </div> 146 - <div class="bucket-tags"> 147 - ${isInput 148 - ? html`<wa-badge appearance="outlined" variant="brand">Input</wa-badge>` 149 - : nothing} 150 - ${isOutput 151 - ? html`<wa-badge appearance="outlined" variant="neutral">Output</wa-badge>` 152 - : nothing} 153 - </div> 154 - <wa-button 155 - appearance="plain" 156 - size="small" 157 - aria-label="Remove" 158 - @click="${() => removeBucket(uri, isOutput)}" 159 - > 160 - <wa-icon library="phosphor/bold" name="x"></wa-icon> 161 - </wa-button> 162 - </li> 163 - `, 164 - )} 165 - `, 166 - bucketsList, 142 + setItems( 143 + [...allBuckets.values()].map(({ name, host, uri, isInput, isOutput }) => ({ 144 + name, 145 + detail: host, 146 + isInput, 147 + isOutput, 148 + onRemove: () => removeBucket(uri, isOutput), 149 + })), 167 150 ); 168 151 }); 169 152 ··· 171 154 // ACTIONS 172 155 //////////////////////////////////////////// 173 156 174 - /** @param {'input' | 'output'} mode */ 175 - function openDialog(mode) { 176 - dialogMode = mode; 177 - bucketDialog.label = 178 - mode === "input" ? "Add audio input" : "Use as userdata storage"; 179 - hostInput.value = ""; 180 - bucketNameInput.value = ""; 181 - regionInput.value = ""; 182 - accessKeyInput.value = ""; 183 - secretKeyInput.value = ""; 184 - pathInput.value = ""; 185 - bucketDialog.open = true; 186 - } 187 - 188 157 /** 189 158 * @param {string | undefined} uri 190 159 * @param {boolean} isOutput ··· 208 177 } 209 178 } 210 179 211 - async function addBucket() { 212 - const host = hostInput.value?.trim(); 213 - const bucketName = bucketNameInput.value?.trim(); 214 - const region = regionInput.value?.trim() || "us-east-1"; 180 + /** @param {'input' | 'output'} mode */ 181 + async function addBucket(mode) { 215 182 const accessKey = accessKeyInput.value?.trim(); 216 183 const secretKey = secretKeyInput.value?.trim(); 184 + const bucketName = bucketNameInput.value?.trim(); 185 + const rawHost = hostInput.value?.trim(); 186 + const host = rawHost?.length 187 + ? rawHost.replace(/^\w+:\/\//, "") 188 + : "s3.amazonaws.com"; 189 + const region = regionInput.value?.trim() || "us-east-1"; 217 190 const path = pathInput.value?.trim() || "/"; 218 191 219 - if (!host || !bucketName || !accessKey || !secretKey) return; 192 + if (!accessKey || !secretKey || !bucketName) return; 220 193 221 194 /** @type {Bucket} */ 222 195 const bucket = { accessKey, bucketName, host, path, region, secretKey }; 223 196 224 - if (dialogMode === "input") { 197 + if (mode === "input") { 225 198 const now = new Date().toISOString(); 226 199 const uri = buildURI(bucket); 227 200 ··· 248 221 ); 249 222 if (option) await outputOrchestrator.select(option.id); 250 223 } 251 - 252 - bucketDialog.open = false; 253 224 } 254 - 255 - //////////////////////////////////////////// 256 - // EVENT LISTENERS 257 - //////////////////////////////////////////// 258 - 259 - document 260 - .querySelector("#add-input-btn") 261 - ?.addEventListener("click", () => openDialog("input")); 262 - 263 - document 264 - .querySelector("#add-output-btn") 265 - ?.addEventListener("click", () => openDialog("output")); 266 - 267 - document.querySelector("#cancel-btn")?.addEventListener("click", () => { 268 - bucketDialog.open = false; 269 - }); 270 - 271 - document.querySelector("#confirm-btn")?.addEventListener("click", addBucket);