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: copy passkey functionality to atproto connector

+285 -70
+156 -15
src/facets/connect/atproto/index.inline.js
··· 1 + import "@awesome.me/webawesome/dist/components/callout/callout.js"; 1 2 import "@awesome.me/webawesome/dist/components/input/input.js"; 2 3 3 - import "~/common/webawesome/detect-dark.js"; 4 - import "~/common/webawesome/phosphor/bold.js"; 5 - 6 - import { html } from "lit-html"; 4 + import { html, nothing, render as litRender } from "lit-html"; 7 5 8 6 import { NAME as ATPROTO_NAME } from "~/components/output/raw/atproto/element.js"; 9 - import { effect } from "~/common/signal.js"; 7 + import { NAME as PASSKEY_NAME } from "~/components/transformer/output/refiner/track-uri-passkey/element.js"; 8 + import { effect, signal } from "~/common/signal.js"; 10 9 import foundation from "~/common/foundation.js"; 11 10 12 11 import { setup } from "~/facets/connect/common.js"; ··· 16 15 /** 17 16 * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 18 17 * @import { ATProtoOutputElement } from "~/components/output/raw/atproto/types.d.ts" 18 + * @import TrackUriPasskeyTransformer from "~/components/transformer/output/refiner/track-uri-passkey/element.js" 19 19 */ 20 20 21 21 //////////////////////////////////////////// ··· 40 40 41 41 const ATPROTO_OUTPUT_ID = atprotoOption.id; 42 42 43 + const atprotoPasskeyEl = /** @type {TrackUriPasskeyTransformer | null} */ ( 44 + outputOrchestrator.root().querySelector( 45 + `${PASSKEY_NAME}[namespace="atproto"]`, 46 + ) 47 + ); 48 + 49 + if (atprotoPasskeyEl) { 50 + await customElements.whenDefined(PASSKEY_NAME); 51 + } 52 + 53 + const $passkeyError = signal(/** @type {string | null} */ (null)); 54 + const $passkeyWorking = signal(false); 55 + 43 56 //////////////////////////////////////////// 44 57 // UI 45 58 //////////////////////////////////////////// ··· 83 96 84 97 effect(() => { 85 98 const did = atprotoEl?.did(); 86 - const isSelectedOutput = outputOrchestrator.selected()?.id === ATPROTO_OUTPUT_ID; 99 + const isSelectedOutput = 100 + outputOrchestrator.selected()?.id === ATPROTO_OUTPUT_ID; 87 101 88 102 setItems( 89 103 did 90 104 ? [ 91 - { 92 - name: did, 93 - detail: "AT Protocol", 94 - isInput: false, 95 - isOutput: true, 96 - isSelectedOutput, 97 - onRemove: () => disconnect(), 98 - }, 99 - ] 105 + { 106 + name: did, 107 + detail: "AT Protocol", 108 + isInput: false, 109 + isOutput: true, 110 + isSelectedOutput, 111 + onRemove: () => disconnect(), 112 + }, 113 + ] 100 114 : [], 101 115 ); 102 116 }); 103 117 104 118 //////////////////////////////////////////// 119 + // PASSKEY SECTION 120 + //////////////////////////////////////////// 121 + 122 + if (atprotoPasskeyEl) { 123 + const passkeyRoot = document.createElement("div"); 124 + passkeyRoot.classList.add("wa-stack"); 125 + document.querySelector("main .card-body")?.appendChild(passkeyRoot); 126 + 127 + effect(() => { 128 + const passkeyActive = atprotoPasskeyEl.passkeyActive() ?? false; 129 + const lockedTracksCount = atprotoPasskeyEl.lockedTracks().length ?? 0; 130 + const passkeyError = $passkeyError.value; 131 + const passkeyWorking = $passkeyWorking.value; 132 + 133 + litRender( 134 + html` 135 + <wa-divider style="margin: var(--spacing) 0"></wa-divider> 136 + 137 + <div> 138 + <strong>Passkey encryption (optional)</strong> 139 + </div> 140 + 141 + ${passkeyActive 142 + ? html` 143 + <p>Passkey active — Track URIs are encrypted.</p> 144 + 145 + ${passkeyError 146 + ? html` 147 + <wa-callout variant="danger">${passkeyError}</wa-callout> 148 + ` 149 + : nothing} 150 + 151 + <div class="button-row"> 152 + <wa-button 153 + variant="neutral" 154 + appearance="outlined" 155 + @click="${handlePasskeyRemove}" 156 + >Remove passkey</wa-button> 157 + </div> 158 + 159 + <p class="wa-caption-xs"> 160 + Removing the passkey will expose all the sensitive information that was 161 + previously encrypted. 162 + </p> 163 + ` 164 + : html` 165 + <p class="wa-caption-xs"> 166 + Track URIs can optionally be encrypted so that passwords and other sensitive 167 + authentication details are kept private. Note that, with this enabled, other 168 + people cannot play audio listed on your account. 169 + </p> 170 + 171 + ${passkeyError 172 + ? html` 173 + <wa-callout variant="danger">${passkeyError}</wa-callout> 174 + ` 175 + : nothing} 176 + 177 + <div class="button-row"> 178 + <wa-button 179 + variant="neutral" 180 + appearance="outlined" 181 + ?disabled="${passkeyWorking}" 182 + @click="${handlePasskeySetup}" 183 + >${passkeyWorking 184 + ? "Setting up …" 185 + : "Set up passkey encryption"}</wa-button> 186 + <wa-button 187 + variant="neutral" 188 + appearance="outlined" 189 + ?disabled="${passkeyWorking}" 190 + @click="${handlePasskeyAdopt}" 191 + >${passkeyWorking 192 + ? "Authenticating …" 193 + : "Use existing passkey"}</wa-button> 194 + </div> 195 + `} ${lockedTracksCount > 0 196 + ? html` 197 + <wa-callout variant="warning"> 198 + ${lockedTracksCount} encrypted track(s) cannot be played until you unlock them with 199 + your passkey. 200 + </wa-callout> 201 + ` 202 + : nothing} 203 + `, 204 + passkeyRoot, 205 + ); 206 + }); 207 + } 208 + 209 + //////////////////////////////////////////// 105 210 // ACTIONS 106 211 //////////////////////////////////////////// 107 212 ··· 116 221 async function disconnect() { 117 222 await atprotoEl?.logout(); 118 223 } 224 + 225 + async function handlePasskeySetup() { 226 + if (!atprotoPasskeyEl) return; 227 + $passkeyError.value = null; 228 + $passkeyWorking.value = true; 229 + try { 230 + await atprotoPasskeyEl.setupPasskey(); 231 + } catch (err) { 232 + $passkeyError.value = err instanceof Error 233 + ? err.message 234 + : "Passkey setup failed"; 235 + } finally { 236 + $passkeyWorking.value = false; 237 + } 238 + } 239 + 240 + async function handlePasskeyAdopt() { 241 + if (!atprotoPasskeyEl) return; 242 + $passkeyError.value = null; 243 + $passkeyWorking.value = true; 244 + try { 245 + await atprotoPasskeyEl.adoptPasskey(); 246 + } catch (err) { 247 + $passkeyError.value = err instanceof Error 248 + ? err.message 249 + : "Passkey adoption failed"; 250 + } finally { 251 + $passkeyWorking.value = false; 252 + } 253 + } 254 + 255 + async function handlePasskeyRemove() { 256 + if (!atprotoPasskeyEl) return; 257 + $passkeyError.value = null; 258 + await atprotoPasskeyEl.removePasskey(); 259 + }
+113 -41
src/facets/connect/common.js
··· 1 1 import "@awesome.me/webawesome/dist/components/badge/badge.js"; 2 2 import "@awesome.me/webawesome/dist/components/button/button.js"; 3 + import "@awesome.me/webawesome/dist/components/callout/callout.js"; 3 4 import "@awesome.me/webawesome/dist/components/card/card.js"; 4 5 import "@awesome.me/webawesome/dist/components/dialog/dialog.js"; 5 6 import "@awesome.me/webawesome/dist/components/divider/divider.js"; 6 7 import "@awesome.me/webawesome/dist/components/icon/icon.js"; 8 + 9 + import "~/common/webawesome/detect-dark.js"; 10 + import "~/common/webawesome/phosphor/bold.js"; 11 + import "~/common/webawesome/phosphor/fill.js"; 7 12 8 13 import { html, nothing, render as litRender } from "lit-html"; 9 14 ··· 29 34 * @param {boolean} [config.hasInput] - Whether to show the "Add audio input" button (default: true) 30 35 * @param {() => Promise<void>} [config.onOutputActivate] - Called instead of opening the dialog when output is already configured but inactive 31 36 * 32 - * @returns {{ setItems: (items: ConnectItem[]) => void }} 37 + * @returns {{ setItems: (items: ConnectItem[]) => void, setError: (message: string | null) => void }} 33 38 */ 34 - export function setup({ title, description, formFields, onSubmit, hasInput = true, onOutputActivate }) { 39 + export function setup( 40 + { 41 + title, 42 + description, 43 + formFields, 44 + onSubmit, 45 + hasInput = true, 46 + onOutputActivate, 47 + }, 48 + ) { 35 49 const main = document.querySelector("main"); 36 50 if (!main) throw new Error("No <main> element"); 37 51 ··· 45 59 ${description} 46 60 <div class="button-row"> 47 61 ${hasInput 48 - ? html`<wa-button id="connect-add-input-btn" variant="brand" appearance="filled"> 49 - <wa-icon slot="start" library="phosphor/bold" name="music-notes"></wa-icon> 50 - Add audio input 51 - </wa-button>` 62 + ? html` 63 + <wa-button id="connect-add-input-btn" variant="neutral" appearance="filled"> 64 + <wa-icon slot="start" library="phosphor/fill" name="music-notes"></wa-icon> 65 + Add audio input 66 + </wa-button> 67 + ` 52 68 : nothing} 53 - <wa-button id="connect-add-output-btn" variant="neutral" appearance="outlined"> 54 - <wa-icon slot="start" library="phosphor/bold" name="person"></wa-icon> 69 + <wa-button 70 + id="connect-add-output-btn" 71 + variant="brand" 72 + appearance="filled" 73 + > 74 + <wa-icon slot="start" library="phosphor/fill" name="person"></wa-icon> 55 75 Use as userdata storage 56 76 </wa-button> 57 77 </div> 78 + <wa-callout id="connect-card-error" variant="danger" hidden></wa-callout> 58 79 <wa-divider id="connect-divider" hidden></wa-divider> 59 80 <ul id="connect-list" class="connect-list" hidden></ul> 60 81 </div> ··· 63 84 <wa-dialog id="connect-dialog" label=""> 64 85 <form id="connect-form" class="dialog-body"> 65 86 ${formFields} 87 + <wa-callout id="connect-error" variant="danger" hidden></wa-callout> 66 88 </form> 67 89 <div slot="footer" class="dialog-footer"> 68 90 <wa-button 91 + id="connect-submit-btn" 69 92 type="submit" 70 93 form="connect-form" 71 94 variant="brand" ··· 80 103 main, 81 104 ); 82 105 83 - const dialog = /** @type {WaDialog} */ (main.querySelector("#connect-dialog")); 84 - const form = /** @type {HTMLFormElement} */ (main.querySelector("#connect-form")); 85 - const divider = /** @type {HTMLElement} */ (main.querySelector("#connect-divider")); 106 + const dialog = 107 + /** @type {WaDialog} */ (main.querySelector("#connect-dialog")); 108 + const form = 109 + /** @type {HTMLFormElement} */ (main.querySelector("#connect-form")); 110 + const dialogErrorEl = 111 + /** @type {HTMLElement} */ (main.querySelector("#connect-error")); 112 + const cardErrorEl = 113 + /** @type {HTMLElement} */ (main.querySelector("#connect-card-error")); 114 + const divider = 115 + /** @type {HTMLElement} */ (main.querySelector("#connect-divider")); 86 116 const list = /** @type {HTMLElement} */ (main.querySelector("#connect-list")); 87 - const outputBtn = /** @type {HTMLElement} */ (main.querySelector("#connect-add-output-btn")); 117 + const outputBtn = 118 + /** @type {HTMLElement} */ (main.querySelector("#connect-add-output-btn")); 88 119 89 120 /** @type {'input' | 'output'} */ 90 121 let mode = "input"; ··· 92 123 /** @type {ConnectItem[]} */ 93 124 let currentItems = []; 94 125 126 + /** @param {string | null} message */ 127 + const setDialogError = (message) => { 128 + dialogErrorEl.hidden = message === null; 129 + dialogErrorEl.textContent = message; 130 + }; 131 + 132 + /** @param {string | null} message */ 133 + const setError = (message) => { 134 + cardErrorEl.hidden = message === null; 135 + cardErrorEl.textContent = message; 136 + }; 137 + 95 138 /** @param {'input' | 'output'} m */ 96 139 const openDialog = (m) => { 97 140 mode = m; 98 - dialog.label = m === "input" ? "Add audio input" : "Use as userdata storage"; 141 + dialog.label = m === "input" 142 + ? "Add audio input" 143 + : "Use as userdata storage"; 99 144 form.reset(); 145 + setDialogError(null); 100 146 dialog.open = true; 101 147 }; 102 148 ··· 117 163 }); 118 164 119 165 main.querySelector("#connect-cancel-btn")?.addEventListener("click", () => { 166 + setDialogError(null); 120 167 dialog.open = false; 121 168 }); 122 169 170 + const submitBtn = 171 + /** @type {HTMLElement} */ (main.querySelector("#connect-submit-btn")); 172 + 123 173 form.addEventListener("submit", async (e) => { 124 174 e.preventDefault(); 125 - await onSubmit(mode); 126 - dialog.open = false; 175 + setDialogError(null); 176 + submitBtn.setAttribute("disabled", ""); 177 + submitBtn.textContent = "Loading …"; 178 + try { 179 + await onSubmit(mode); 180 + dialog.open = false; 181 + } catch (err) { 182 + setDialogError( 183 + err instanceof Error ? err.message : "Something went wrong", 184 + ); 185 + } finally { 186 + submitBtn.removeAttribute("disabled"); 187 + submitBtn.textContent = "Add"; 188 + } 127 189 }); 128 190 129 191 return { 192 + setError, 193 + 130 194 /** 131 195 * Updates the list of configured items below the divider. 132 196 * Call inside an effect() for reactivity. ··· 139 203 list.hidden = items.length === 0; 140 204 outputBtn.hidden = items.some((i) => i.isOutput && i.isSelectedOutput); 141 205 litRender( 142 - html`${items.map( 143 - ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) => html` 144 - <li class="connect-item"> 145 - <div class="connect-item__info"> 146 - <span class="connect-item__name">${name}</span> 147 - <span class="connect-item__detail">${detail}</span> 148 - </div> 149 - <div class="connect-item__tags"> 150 - ${isInput 151 - ? html`<wa-badge appearance="outlined" variant="brand">Input</wa-badge>` 152 - : nothing} 153 - ${isOutput 154 - ? html`<wa-badge appearance="outlined" variant="${isSelectedOutput ? "success" : "warning"}">Output</wa-badge>` 155 - : nothing} 156 - </div> 157 - <wa-button 158 - appearance="plain" 159 - size="small" 160 - aria-label="Remove" 161 - @click="${onRemove}" 162 - > 163 - <wa-icon library="phosphor/bold" name="x"></wa-icon> 164 - </wa-button> 165 - </li> 166 - `, 167 - )}`, 206 + html` 207 + ${items.map( 208 + ({ name, detail, isInput, isOutput, isSelectedOutput, onRemove }) => 209 + html` 210 + <li class="connect-item"> 211 + <div class="connect-item__info"> 212 + <span class="connect-item__name">${name}</span> 213 + <span class="connect-item__detail">${detail}</span> 214 + </div> 215 + <div class="connect-item__tags"> 216 + ${isInput 217 + ? html` 218 + <wa-badge appearance="outlined" variant="neutral">Input</wa-badge> 219 + ` 220 + : nothing} ${isOutput 221 + ? html` 222 + <wa-badge appearance="outlined" variant="${isSelectedOutput 223 + ? "brand" 224 + : "warning"}">Output</wa-badge> 225 + ` 226 + : nothing} 227 + </div> 228 + <wa-button 229 + appearance="plain" 230 + size="small" 231 + aria-label="Remove" 232 + @click="${onRemove}" 233 + > 234 + <wa-icon library="phosphor/bold" name="x"></wa-icon> 235 + </wa-button> 236 + </li> 237 + `, 238 + )} 239 + `, 168 240 list, 169 241 ); 170 242 },
+16 -14
src/facets/connect/s3/index.inline.js
··· 1 1 import "@awesome.me/webawesome/dist/components/input/input.js"; 2 2 3 - import "~/common/webawesome/detect-dark.js"; 4 - import "~/common/webawesome/phosphor/bold.js"; 5 - 6 3 import * as TID from "@atcute/tid"; 7 4 import { html } from "lit-html"; 8 5 ··· 58 55 // UI 59 56 //////////////////////////////////////////// 60 57 61 - const { setItems } = setup({ 58 + const { setItems, setError } = setup({ 62 59 title: "S3", 63 60 64 61 description: html` ··· 174 171 * @param {boolean} isOutput 175 172 */ 176 173 async function removeBucket(uri, isOutput) { 177 - if (uri) { 178 - const tracks = await Output.data(outputOrchestrator.tracks); 179 - const detachedTracks = await inputConfigurator.detach({ 180 - fileUriOrScheme: uri, 181 - tracks, 182 - }); 174 + setError(null); 175 + try { 176 + if (uri) { 177 + const tracks = await Output.data(outputOrchestrator.tracks); 178 + const detachedTracks = await inputConfigurator.detach({ 179 + fileUriOrScheme: uri, 180 + tracks, 181 + }); 183 182 184 - if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 185 - } 183 + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 184 + } 186 185 187 - if (isOutput) { 188 - await s3El?.unsetBucket(); 186 + if (isOutput) { 187 + await s3El?.unsetBucket(); 188 + } 189 + } catch (err) { 190 + setError(err instanceof Error ? err.message : "Failed to remove bucket"); 189 191 } 190 192 } 191 193