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

Configure Feed

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

feat: very rough atproto output element

+654 -23
+43 -1
_config.ts
··· 39 39 dotenvRun({ 40 40 files: [".env"], 41 41 }), 42 - nodeModulesPolyfillPlugin(), 42 + // Force @atcute/uint8array to use the browser entry (dist/index.js) 43 + // instead of the Node entry (dist/index.node.js) which imports from 44 + // node:crypto. The @deno/loader Workspace defaults to platform "node", 45 + // causing the "node" export condition to match before "default". 46 + { 47 + name: "atcute-uint8array-browser", 48 + setup(build) { 49 + build.onLoad( 50 + { filter: /@atcute\+uint8array.*index\.node\.js$/ }, 51 + async (args) => { 52 + const browserPath = args.path.replace( 53 + "index.node.js", 54 + "index.js", 55 + ); 56 + const contents = await Deno.readTextFile(browserPath); 57 + return { contents, loader: "js" }; 58 + }, 59 + ); 60 + }, 61 + }, 62 + { 63 + name: "atcute-multibase-browser", 64 + setup(build) { 65 + build.onLoad( 66 + { filter: /@atcute\+multibase.*-node\.js$/ }, 67 + async (args) => { 68 + const browserPath = args.path.replace( 69 + "-node.js", 70 + "-web.js", 71 + ); 72 + const contents = await Deno.readTextFile(browserPath); 73 + return { contents, loader: "js" }; 74 + }, 75 + ); 76 + }, 77 + }, 78 + nodeModulesPolyfillPlugin({ 79 + globals: { 80 + process: true, 81 + Buffer: true, 82 + }, 83 + }), 43 84 wasmLoader(), 44 85 ], 45 86 splitting: true, ··· 124 165 // MISC 125 166 126 167 site.add([".htm"]); 168 + site.add([".json"]); 127 169 site.add([".txt"]); 128 170 site.use(sourceMaps()); 129 171
+5
deno.jsonc
··· 5 5 "imports": { 6 6 "98.css": "npm:98.css@^0.1.21", 7 7 "@atcute/cbor": "npm:@atcute/cbor@^2.3.0", 8 + "@atcute/client": "npm:@atcute/client@^4.2.1", 9 + "@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.2.2", 8 10 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.7", 11 + "@atcute/oauth-browser-client": "npm:@atcute/oauth-browser-client@^3.0.0", 9 12 "@automerge/automerge": "npm:@automerge/automerge@^3.2.3", 10 13 "@automerge/automerge-repo": "npm:@automerge/automerge-repo@^2.5.1", 11 14 "@automerge/automerge-repo-network-broadcastchannel": "npm:@automerge/automerge-repo-network-broadcastchannel@^2.5.1", ··· 113 116 "./components/orchestrator/sources/element.js": "./src/components/orchestrator/sources/element.js", 114 117 "./components/output/bytes/automerge-repo-server/element.js": "./src/components/output/bytes/automerge-repo-server/element.js", 115 118 "./components/output/common.js": "./src/components/output/common.js", 119 + "./components/output/raw/atproto/element.js": "./src/components/output/raw/atproto/element.js", 116 120 "./components/output/polymorphic/automerge-repo/element.js": "./src/components/output/polymorphic/automerge-repo/element.js", 117 121 "./components/output/polymorphic/indexed-db/constants.js": "./src/components/output/polymorphic/indexed-db/constants.js", 118 122 "./components/output/polymorphic/indexed-db/element.js": "./src/components/output/polymorphic/indexed-db/element.js", ··· 145 149 "./components/input/types.d.ts": "./src/components/input/types.d.ts", 146 150 "./components/orchestrator/process-tracks/types.d.ts": "./src/components/orchestrator/process-tracks/types.d.ts", 147 151 "./components/orchestrator/scoped-tracks/types.d.ts": "./src/components/orchestrator/scoped-tracks/types.d.ts", 152 + "./components/output/raw/atproto/types.d.ts": "./src/components/output/raw/atproto/types.d.ts", 148 153 "./components/output/polymorphic/automerge-repo/types.d.ts": "./src/components/output/polymorphic/automerge-repo/types.d.ts", 149 154 "./components/output/polymorphic/indexed-db/types.d.ts": "./src/components/output/polymorphic/indexed-db/types.d.ts", 150 155 "./components/output/types.d.ts": "./src/components/output/types.d.ts",
+7
src/components/orchestrator/output/element.js
··· 3 3 4 4 import "@components/configurator/output/element.js"; 5 5 import "@components/output/polymorphic/indexed-db/element.js"; 6 + import "@components/output/raw/atproto/element.js"; 6 7 // import "@components/output/bytes/automerge-repo-server/element.js"; 7 8 // import "@components/transformer/output/bytes/automerge/element.js"; 8 9 import "@components/transformer/output/refiner/default/element.js"; ··· 78 79 id="do-output__dtos-json" 79 80 output-selector="#do-output__dop-indexed-db__json" 80 81 ></dtos-json> 82 + 83 + <dor-atproto 84 + id="do-output__dor-atproto" 85 + group="${ifDefined(group)}" 86 + label="AT Protocol" 87 + ></dor-atproto> 81 88 82 89 <!--<dor-automerge-repo 83 90 id="do-output__dor-automerge-repo"
+305
src/components/output/raw/atproto/element.js
··· 1 + import { Client, ok } from "@atcute/client"; 2 + import { BroadcastableDiffuseElement } from "@common/element.js"; 3 + import { signal } from "@common/signal.js"; 4 + import { outputManager } from "../../common.js"; 5 + import { login, logout, OAuthUserAgent, restoreOrFinalize } from "./oauth.js"; 6 + 7 + /** 8 + * @import {Signal} from "@common/signal.d.ts" 9 + * @import {OutputElement, OutputManager} from "../../types.d.ts" 10 + * @import {ATProtoOutputElement} from "./types.d.ts" 11 + */ 12 + 13 + //////////////////////////////////////////// 14 + // ELEMENT 15 + //////////////////////////////////////////// 16 + 17 + /** 18 + * @implements {ATProtoOutputElement} 19 + */ 20 + class ATProtoOutput extends BroadcastableDiffuseElement { 21 + static NAME = "diffuse/output/raw/atproto"; 22 + 23 + /** @type {Client | null} */ 24 + #rpc = null; 25 + 26 + /** @type {OAuthUserAgent | null} */ 27 + #agent = null; 28 + 29 + /** @type {string | null} */ 30 + #did = null; 31 + 32 + /** Public signal exposing the authenticated DID (null when not logged in). */ 33 + $did = signal(/** @type {string | null} */ (null)); 34 + 35 + #manager; 36 + 37 + /** @type {PromiseWithResolvers<void>} */ 38 + #authenticated = Promise.withResolvers(); 39 + 40 + constructor() { 41 + super(); 42 + 43 + /** @type {OutputManager} */ 44 + this.#manager = outputManager({ 45 + init: async () => { 46 + await this.#ensureAuthenticated(); 47 + return true; 48 + }, 49 + facets: { 50 + empty: () => [], 51 + get: () => this.#listRecords("sh.diffuse.output.facet"), 52 + put: (data) => this.#putRecords("sh.diffuse.output.facet", data), 53 + }, 54 + playlists: { 55 + empty: () => [], 56 + get: () => this.#listRecords("sh.diffuse.output.playlist"), 57 + put: (data) => this.#putRecords("sh.diffuse.output.playlist", data), 58 + }, 59 + themes: { 60 + empty: () => [], 61 + get: () => this.#listRecords("sh.diffuse.output.theme"), 62 + put: (data) => this.#putRecords("sh.diffuse.output.theme", data), 63 + }, 64 + tracks: { 65 + empty: () => [], 66 + get: () => this.#listRecords("sh.diffuse.output.track"), 67 + put: (data) => this.#putRecords("sh.diffuse.output.track", data), 68 + }, 69 + }); 70 + 71 + this.facets = this.#manager.facets; 72 + this.playlists = this.#manager.playlists; 73 + this.themes = this.#manager.themes; 74 + this.tracks = this.#manager.tracks; 75 + } 76 + 77 + // LIFECYCLE 78 + 79 + /** @override */ 80 + connectedCallback() { 81 + if (this.hasAttribute("group")) { 82 + const actions = this.broadcast(this.nameWithGroup, { 83 + put: { strategy: "replicate", fn: this.#putIncoming }, 84 + }); 85 + 86 + if (actions) { 87 + this.#put = this.#putOutgoing(actions.put); 88 + } 89 + } 90 + 91 + super.connectedCallback(); 92 + 93 + this.#tryRestore(); 94 + } 95 + 96 + // AUTH 97 + 98 + async #tryRestore() { 99 + await this.whenConnected(); 100 + 101 + const session = await restoreOrFinalize(); 102 + 103 + if (session) { 104 + this.#setSession(session); 105 + } 106 + } 107 + 108 + /** 109 + * @param {import("@atcute/oauth-browser-client").Session} session 110 + */ 111 + #setSession(session) { 112 + this.#agent = new OAuthUserAgent(session); 113 + this.#rpc = new Client({ handler: this.#agent }); 114 + this.#did = session.info.sub; 115 + this.$did.value = session.info.sub; 116 + this.#authenticated.resolve(); 117 + } 118 + 119 + async #ensureAuthenticated() { 120 + await this.whenConnected(); 121 + return this.#authenticated.promise; 122 + } 123 + 124 + /** 125 + * Initiate the OAuth flow. 126 + * Navigates the browser to the authorization server. 127 + * 128 + * @param {string} handle 129 + */ 130 + async login(handle) { 131 + await login(handle); 132 + } 133 + 134 + /** 135 + * Sign out and revoke the current session. 136 + */ 137 + async logout() { 138 + if (this.#agent) { 139 + await logout(this.#agent); 140 + this.#agent = null; 141 + this.#rpc = null; 142 + this.#did = null; 143 + this.$did.value = null; 144 + this.#authenticated = Promise.withResolvers(); 145 + } 146 + } 147 + 148 + // RECORDS 149 + 150 + /** 151 + * @template T 152 + * @param {string} collection 153 + * @returns {Promise<T[]>} 154 + */ 155 + async #listRecords(collection) { 156 + if (!this.#rpc || !this.#did) return []; 157 + 158 + const records = []; 159 + let cursor; 160 + 161 + do { 162 + /** @type {any} */ 163 + const page = await ok(this.#rpc.get( 164 + "com.atproto.repo.listRecords", 165 + { params: { repo: this.#did, collection, limit: 100, cursor } }, 166 + )); 167 + 168 + for (const record of page.records) { 169 + records.push(record.value); 170 + } 171 + 172 + cursor = page.cursor; 173 + } while (cursor); 174 + 175 + return records; 176 + } 177 + 178 + /** 179 + * @param {string} collection 180 + * @param {Array<{ id: string }>} data 181 + */ 182 + async #putRecordsSync(collection, data) { 183 + if (!this.#rpc || !this.#did) return; 184 + 185 + // 1. Fetch current state 186 + /** @type {Map<string, { rkey: string, value: unknown }>} */ 187 + const existing = new Map(); 188 + let cursor; 189 + 190 + do { 191 + /** @type {any} */ 192 + const page = await ok(this.#rpc.get( 193 + "com.atproto.repo.listRecords", 194 + { params: { repo: this.#did, collection, limit: 100, cursor } }, 195 + )); 196 + 197 + for (const record of page.records) { 198 + const rkey = record.uri.split("/").pop(); 199 + existing.set(record.value.id, { rkey, value: record.value }); 200 + } 201 + 202 + cursor = page.cursor; 203 + } while (cursor); 204 + 205 + // 2. Build desired state 206 + const desired = new Map(data.map((record) => [record.id, record])); 207 + 208 + // 3. Compute diff 209 + /** @type {unknown[]} */ 210 + const writes = []; 211 + 212 + for (const [id, { rkey }] of existing) { 213 + if (!desired.has(id)) { 214 + writes.push({ 215 + $type: "com.atproto.repo.applyWrites#delete", 216 + collection, 217 + rkey, 218 + }); 219 + } 220 + } 221 + 222 + for (const [id, record] of desired) { 223 + const entry = existing.get(id); 224 + 225 + if (!entry) { 226 + writes.push({ 227 + $type: "com.atproto.repo.applyWrites#create", 228 + collection, 229 + rkey: id, 230 + value: record, 231 + }); 232 + } else if (JSON.stringify(entry.value) !== JSON.stringify(record)) { 233 + writes.push({ 234 + $type: "com.atproto.repo.applyWrites#update", 235 + collection, 236 + rkey: entry.rkey, 237 + value: record, 238 + }); 239 + } 240 + } 241 + 242 + // 4. Apply 243 + if (writes.length > 0) { 244 + await this.#rpc.post("com.atproto.repo.applyWrites", { 245 + input: { repo: this.#did, writes }, 246 + }); 247 + } 248 + } 249 + 250 + // GET & PUT (broadcasting layer) 251 + 252 + /** 253 + * @param {string} collection 254 + * @param {Array<{ id: string }>} data 255 + */ 256 + #putProxy = (collection, data) => this.#putRecordsSync(collection, data); 257 + #put = this.#putProxy; 258 + 259 + /** 260 + * @param {string} collection 261 + * @param {Array<{ id: string }>} data 262 + */ 263 + #putRecords = (collection, data) => this.#put(collection, data); 264 + 265 + /** 266 + * @param {(uuidSender: ReturnType<typeof crypto.randomUUID>, collection: string, data: Array<{ id: string }>) => Promise<void>} action 267 + * @returns {(collection: string, data: Array<{ id: string }>) => Promise<void>} 268 + */ 269 + #putOutgoing = (action) => async (collection, data) => { 270 + return await action(this.uuid, collection, data); 271 + }; 272 + 273 + /** 274 + * @param {ReturnType<typeof crypto.randomUUID>} uuidSender 275 + * @param {string} collection 276 + * @param {Array<{ id: string }>} data 277 + */ 278 + #putIncoming(uuidSender, collection, data) { 279 + if (uuidSender === this.uuid) { 280 + this.#putProxy(collection, data); 281 + } else { 282 + /** @type {Record<string, Signal<unknown[]>>} */ 283 + const collectionMap = { 284 + "sh.diffuse.output.facet": this.#manager.signals.facets, 285 + "sh.diffuse.output.playlist": this.#manager.signals.playlists, 286 + "sh.diffuse.output.theme": this.#manager.signals.themes, 287 + "sh.diffuse.output.track": this.#manager.signals.tracks, 288 + }; 289 + 290 + const sig = collectionMap[collection]; 291 + if (sig) sig.value = data; 292 + } 293 + } 294 + } 295 + 296 + export default ATProtoOutput; 297 + 298 + //////////////////////////////////////////// 299 + // REGISTER 300 + //////////////////////////////////////////// 301 + 302 + export const CLASS = ATProtoOutput; 303 + export const NAME = "dor-atproto"; 304 + 305 + customElements.define(NAME, ATProtoOutput);
+139
src/components/output/raw/atproto/oauth.js
··· 1 + import { configureOAuth } from "@atcute/oauth-browser-client"; 2 + 3 + import { 4 + CompositeDidDocumentResolver, 5 + LocalActorResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + XrpcHandleResolver, 9 + } from "@atcute/identity-resolver"; 10 + 11 + import { 12 + createAuthorizationUrl, 13 + deleteStoredSession, 14 + finalizeAuthorization, 15 + getSession, 16 + OAuthUserAgent, 17 + } from "@atcute/oauth-browser-client"; 18 + 19 + export { OAuthUserAgent }; 20 + 21 + /** 22 + * @import {Session} from "@atcute/oauth-browser-client" 23 + */ 24 + 25 + const STORAGE_KEY = "dor-atproto:did"; 26 + 27 + // CONFIGURE 28 + // ========= 29 + 30 + configureOAuth({ 31 + metadata: { 32 + client_id: import.meta.env?.VITE_ATPROTO_CLIENT_ID ?? 33 + "https://elements.diffuse.sh/oauth-client-metadata.json", 34 + redirect_uri: 35 + window.location.origin.replace("://localhost", "://127.0.0.1") + 36 + window.location.pathname, 37 + }, 38 + identityResolver: new LocalActorResolver({ 39 + handleResolver: new XrpcHandleResolver({ 40 + serviceUrl: "https://public.api.bsky.app", 41 + }), 42 + didDocumentResolver: new CompositeDidDocumentResolver({ 43 + methods: { 44 + plc: new PlcDidDocumentResolver(), 45 + web: new WebDidDocumentResolver(), 46 + }, 47 + }), 48 + }), 49 + }); 50 + 51 + // LOGIN 52 + // ===== 53 + 54 + /** 55 + * Initiate the OAuth authorization flow for a given handle. 56 + * Navigates the browser away to the authorization server. 57 + * 58 + * @param {string} handle 59 + */ 60 + export async function login(handle) { 61 + const authUrl = await createAuthorizationUrl({ 62 + target: { type: "account", identifier: /** @type {any} */ (handle) }, 63 + scope: "atproto transition:generic", 64 + }); 65 + 66 + window.location.assign(authUrl.toString()); 67 + } 68 + 69 + // SESSION RESTORE / CALLBACK 70 + // ========================== 71 + 72 + /** 73 + * Attempt to restore an existing session or finalize an OAuth callback. 74 + * Returns the session if successful, or null if no session is available. 75 + * 76 + * @returns {Promise<Session | null>} 77 + */ 78 + export async function restoreOrFinalize() { 79 + // Check for OAuth callback parameters (the library uses response_mode=fragment, 80 + // so params arrive in the URL hash, not the query string) 81 + const params = new URLSearchParams(window.location.hash.slice(1)); 82 + 83 + if (params.has("code")) { 84 + const result = await finalizeAuthorization(params); 85 + 86 + // Clean up URL (remove fragment containing OAuth params) 87 + history.replaceState( 88 + null, 89 + "", 90 + window.location.pathname + window.location.search, 91 + ); 92 + 93 + // Persist the DID for future session restoration 94 + localStorage.setItem(STORAGE_KEY, result.session.info.sub); 95 + 96 + return result.session; 97 + } 98 + 99 + // Try to restore a previously stored session 100 + const did = localStorage.getItem(STORAGE_KEY); 101 + 102 + if (did) { 103 + try { 104 + return await getSession( 105 + /** @type {`did:${string}:${string}`} */ (did), 106 + { allowStale: true }, 107 + ); 108 + } catch { 109 + localStorage.removeItem(STORAGE_KEY); 110 + return null; 111 + } 112 + } 113 + 114 + return null; 115 + } 116 + 117 + // LOGOUT 118 + // ====== 119 + 120 + /** 121 + * Sign out and revoke the current session. 122 + * 123 + * @param {OAuthUserAgent} agent 124 + */ 125 + export async function logout(agent) { 126 + const did = localStorage.getItem(STORAGE_KEY); 127 + 128 + try { 129 + await agent.signOut(); 130 + } catch { 131 + if (did) { 132 + deleteStoredSession( 133 + /** @type {`did:${string}:${string}`} */ (did), 134 + ); 135 + } 136 + } 137 + 138 + localStorage.removeItem(STORAGE_KEY); 139 + }
+10
src/components/output/raw/atproto/types.d.ts
··· 1 + import type { Signal } from "@common/signal.d.ts"; 2 + import type { OutputElement } from "../../types.d.ts"; 3 + 4 + export type ATProtoOutputElement = 5 + & OutputElement 6 + & { 7 + $did: Signal<string | null>; 8 + login(handle: string): Promise<void>; 9 + logout(): Promise<void>; 10 + };
+2 -2
src/index.vto
··· 96 96 - url: "components/output/polymorphic/indexed-db/element.js" 97 97 title: "Polymorphic / IndexedDB" 98 98 desc: "Stores output into the local indexedDB. Supports any type of data that indexedDB supports." 99 - - title: "Raw / AT Protocol" 99 + - url: "components/output/raw/atproto/element.js" 100 + title: "Raw / AT Protocol" 100 101 desc: > 101 102 Store your user data on the storage associated with your ATProtocol identity. Data is lexicon shaped by default so this element takes in that data directly without any transformations. 102 - todo: true 103 103 104 104 processors: 105 105 - url: "components/processor/artwork/element.js"
+12
src/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://elements.diffuse.sh/oauth-client-metadata.json", 3 + "client_name": "Diffuse", 4 + "client_uri": "https://elements.diffuse.sh", 5 + "redirect_uris": ["https://elements.diffuse.sh/"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
+131 -20
src/themes/webamp/configurators/output/element.js
··· 1 1 import { DiffuseElement } from "@common/element.js"; 2 + import { signal } from "@common/signal.js"; 2 3 3 4 /** 4 5 * @import {RenderArg} from "@common/element.d.ts" 6 + * @import {ATProtoOutputElement} from "@components/output/raw/atproto/types.d.ts" 5 7 */ 6 8 7 9 class OutputConfig extends DiffuseElement { ··· 10 12 this.attachShadow({ mode: "open" }); 11 13 } 12 14 15 + // SIGNALS 16 + 17 + #atproto = signal(/** @type {ATProtoOutputElement | null} */ (null)); 18 + 19 + // LIFECYCLE 20 + 21 + /** @override */ 22 + async connectedCallback() { 23 + super.connectedCallback(); 24 + 25 + await customElements.whenDefined("dor-atproto"); 26 + 27 + // TODO: Remove? 28 + // The dor-atproto element is rendered by the do-output orchestrator, 29 + // which may connect after this element. Wait for it to appear. 30 + // await new Promise((resolve) => requestAnimationFrame(resolve)); 31 + 32 + /** @type {ATProtoOutputElement | null} */ 33 + const atproto = document.querySelector("dor-atproto"); 34 + if (atproto) this.#atproto.value = atproto; 35 + } 36 + 37 + // EVENTS 38 + 39 + /** @param {Event} event */ 40 + #handleAtprotoLogin = async (event) => { 41 + event.preventDefault(); 42 + 43 + /** @type {HTMLInputElement | null} */ 44 + const input = this.root().querySelector("#atproto-handle"); 45 + const handle = input?.value?.trim(); 46 + if (!handle) return; 47 + 48 + const atproto = this.#atproto.value; 49 + if (!atproto) return; 50 + 51 + /** @type {HTMLButtonElement | null} */ 52 + const button = this.root().querySelector("#atproto-submit"); 53 + if (button) button.disabled = true; 54 + 55 + await atproto.login(handle); 56 + }; 57 + 58 + #handleAtprotoLogout = async () => { 59 + const atproto = this.#atproto.value; 60 + if (!atproto) return; 61 + 62 + await atproto.logout(); 63 + }; 64 + 13 65 // RENDER 14 66 15 67 /** 16 68 * @param {RenderArg} _ 17 69 */ 18 70 render({ html }) { 71 + const did = this.#atproto.value?.$did.value ?? null; 72 + 19 73 return html` 20 74 <link rel="stylesheet" href="styles/vendor/98.css" /> 21 75 <link rel="stylesheet" href="themes/webamp/facet.css" /> ··· 65 119 } 66 120 67 121 #tabbed:has(#overview-tab:checked) #overview-contents { display: block } 122 + #tabbed:has(#atproto-tab:checked) #atproto-contents { display: block } 68 123 #tabbed:has(#s3-tab:checked) #s3-contents { display: block } 69 124 </style> 70 125 ··· 77 132 </label> 78 133 </li> 79 134 <li role="tab"> 135 + <label for="atproto-tab"> 136 + <span>AT Protocol</span> 137 + <input name="output-tab" id="atproto-tab" type="radio" /> 138 + </label> 139 + </li> 140 + <li role="tab"> 80 141 <label for="s3-tab"> 81 142 <span>S3</span> 82 143 <input name="output-tab" id="s3-tab" type="radio" /> ··· 89 150 <div class="window-body" id="overview-contents"> 90 151 <fieldset> 91 152 <span class="with-icon with-icon--large"> 92 - <img src="images/icons/windows_98/computer_user_pencil-0.png" width="24" /> 93 - <span>Here you can configure where to keep your user data.<br />Each storage method comes with its pros and cons.<br />By default your data is only kept locally here in the browser.</span> 94 - </span> 95 - </fieldset> 96 - </div> 153 + <img 154 + src="images/icons/windows_98/computer_user_pencil-0.png" 155 + width="24" 156 + /> 157 + <span>Here you can configure where to keep your user data.<br />Each 158 + storage method comes with its pros and cons.<br />By default your 159 + data is only kept locally here in the browser.</span> 160 + </span> 161 + </fieldset> 162 + </div> 163 + 164 + <!-- AT Protocol --> 165 + <div class="window-body" id="atproto-contents"> 166 + ${did 167 + ? html` 168 + <fieldset> 169 + <span class="with-icon with-icon--large"> 170 + <img src="images/icons/windows_98/computer_user_pencil-0.png" width="24" /> 171 + <span>Signed in as <strong>${did}</strong></span> 172 + </span> 173 + </fieldset> 174 + 175 + <p> 176 + <button @click="${this 177 + .#handleAtprotoLogout}">Sign out</button> 178 + </p> 179 + ` 180 + : html` 181 + <fieldset> 182 + <span class="with-icon with-icon--large"> 183 + <img src="images/icons/windows_98/computer_user_pencil-0.png" width="24" /> 184 + <span>Store your user data on the storage associated with your AT Protocol 185 + identity. WORK IN PROGRESS!</span> 186 + </span> 187 + </fieldset> 188 + 189 + <form @submit="${this.#handleAtprotoLogin}"> 190 + <fieldset> 191 + <div class="field-row"> 192 + <label for="atproto-handle">Your internet handle:</label> 193 + <input 194 + id="atproto-handle" 195 + type="text" 196 + required 197 + placeholder="you.bsky.social" 198 + /> 199 + </div> 200 + </fieldset> 201 + 202 + <p> 203 + <button type="submit" id="atproto-submit">Sign in</button> 204 + </p> 205 + </form> 206 + `} 207 + </div> 97 208 98 - <!-- S3 --> 99 - <div class="window-body" id="s3-contents"> 100 - <p>TODO</p> 209 + <!-- S3 --> 210 + <div class="window-body" id="s3-contents"> 211 + <p>TODO</p> 212 + </div> 213 + </div> 101 214 </div> 102 - </div> 103 - </div> 104 - `; 105 - } 106 - } 215 + `; 216 + } 217 + } 107 218 108 - export default OutputConfig; 219 + export default OutputConfig; 109 220 110 - //////////////////////////////////////////// 111 - // REGISTER 112 - //////////////////////////////////////////// 221 + //////////////////////////////////////////// 222 + // REGISTER 223 + //////////////////////////////////////////// 113 224 114 - export const CLASS = OutputConfig; 115 - export const NAME = "dtw-output-config"; 225 + export const CLASS = OutputConfig; 226 + export const NAME = "dtw-output-config"; 116 227 117 - customElements.define(NAME, CLASS); 228 + customElements.define(NAME, CLASS);