web components for a integrable atproto based guestbook
1import { Client } from '@atcute/client'; 2import type {} from '@atcute/atproto'; 3import { 4 createAuthorizationUrl, 5 finalizeAuthorization, 6 OAuthUserAgent, 7} from '@atcute/oauth-browser-client'; 8import { getConfig } from './config'; 9 10// Actor typeahead web component 11import 'actor-typeahead'; 12 13// Global agent instance 14let globalAgent: OAuthUserAgent | null = null; 15 16// Helper function to wait 17const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 18 19/** 20 * Web component for signing the guestbook. 21 * 22 * Usage: 23 * <guestbook-sign did="did:web:nekomimi.pet"></guestbook-sign> 24 */ 25export class GuestbookSignElement extends HTMLElement { 26 private form: HTMLFormElement | null = null; 27 private messageInput: HTMLTextAreaElement | null = null; 28 private submitButton: HTMLButtonElement | null = null; 29 private statusDiv: HTMLDivElement | null = null; 30 private loginInput: HTMLInputElement | null = null; 31 private loginButton: HTMLButtonElement | null = null; 32 private userHandle: string | null = null; 33 34 static get observedAttributes() { 35 return ['did']; 36 } 37 38 constructor() { 39 super(); 40 this.attachShadow({ mode: 'open' }); 41 } 42 43 async connectedCallback() { 44 await this.initializeAuth(); 45 if (globalAgent) { 46 await this.fetchUserHandle(); 47 } 48 this.render(); 49 this.attachEventListeners(); 50 } 51 52 private async fetchUserHandle() { 53 if (!globalAgent) return; 54 55 try { 56 const response = await fetch( 57 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${globalAgent.session.info.sub}` 58 ); 59 if (response.ok) { 60 const data = await response.json(); 61 this.userHandle = data.handle || null; 62 } 63 } catch (error) { 64 console.error('Failed to fetch user handle:', error); 65 } 66 } 67 68 private async initializeAuth() { 69 // Check if we have OAuth parameters in the hash (returning from OAuth) 70 if (location.hash.length > 1) { 71 const params = new URLSearchParams(location.hash.slice(1)); 72 73 if (params.has('state') && (params.has('code') || params.has('error'))) { 74 try { 75 // Scrub the parameters from history to prevent replay 76 history.replaceState(null, '', location.pathname + location.search); 77 78 // Finalize the authorization and get the result 79 const result = await finalizeAuthorization(params); 80 81 // Create the OAuth user agent with the session 82 globalAgent = new OAuthUserAgent(result.session); 83 84 console.log('Authorization successful! Logged in as:', result.session.info.sub); 85 86 } catch (error) { 87 console.error('Failed to finalize authorization:', error); 88 alert('Login failed. Please try again.'); 89 } 90 } 91 } 92 } 93 94 private render() { 95 if (!this.shadowRoot) { 96 return; 97 } 98 99 const isLoggedIn = globalAgent !== null; 100 101 this.shadowRoot.innerHTML = ` 102 <style> 103 * { 104 box-sizing: border-box; 105 margin: 0; 106 padding: 0; 107 } 108 109 :host { 110 display: block; 111 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 112 -webkit-font-smoothing: antialiased; 113 -moz-osx-font-smoothing: grayscale; 114 } 115 116 .guestbook-sign { 117 background: #f9fafb; 118 padding: 0; 119 width: 100%; 120 } 121 122 h2 { 123 margin: 0 0 24px 0; 124 font-size: 11px; 125 font-weight: 700; 126 color: #000; 127 text-transform: uppercase; 128 letter-spacing: 0.05em; 129 } 130 131 .form-group { 132 margin-bottom: 24px; 133 } 134 135 label { 136 display: block; 137 margin-bottom: 8px; 138 font-size: 14px; 139 font-weight: 600; 140 color: #000; 141 } 142 143 textarea, input[type="text"] { 144 width: 100%; 145 padding: 12px 16px; 146 border: 1px solid #e5e7eb; 147 border-radius: 6px; 148 font-family: inherit; 149 font-size: 15px; 150 background: white; 151 box-sizing: border-box; 152 transition: border-color 0.2s, box-shadow 0.2s; 153 } 154 155 textarea { 156 min-height: 120px; 157 resize: vertical; 158 } 159 160 textarea:focus, input[type="text"]:focus { 161 outline: none; 162 border-color: #a1a1aa; 163 box-shadow: 0 0 0 2px rgba(161, 161, 170, 0.2); 164 } 165 166 textarea::placeholder, input[type="text"]::placeholder { 167 color: #9ca3af; 168 } 169 170 .char-count { 171 display: none; 172 } 173 174 button { 175 width: 100%; 176 background: #18181b; 177 color: white; 178 border: none; 179 border-radius: 6px; 180 padding: 12px 24px; 181 font-size: 15px; 182 font-weight: 500; 183 cursor: pointer; 184 transition: background-color 0.2s; 185 } 186 187 button:hover:not(:disabled) { 188 background: #27272a; 189 } 190 191 button:disabled { 192 background: #d1d5db; 193 cursor: not-allowed; 194 } 195 196 .status { 197 margin-top: 16px; 198 padding: 12px; 199 border-radius: 8px; 200 font-size: 14px; 201 display: none; 202 } 203 204 .status.show { 205 display: block; 206 } 207 208 .status.success { 209 background: #ecfdf5; 210 color: #065f46; 211 border: 1px solid #d1fae5; 212 } 213 214 .status.error { 215 background: #fef2f2; 216 color: #991b1b; 217 border: 1px solid #fecaca; 218 } 219 220 .status.loading { 221 background: #eff6ff; 222 color: #1e40af; 223 border: 1px solid #dbeafe; 224 } 225 226 .login-prompt { 227 display: none; 228 } 229 </style> 230 231 <div class="guestbook-sign"> 232 <h2>SIGN</h2> 233 ${!isLoggedIn ? ` 234 <form id="login-form"> 235 <div class="form-group"> 236 <label for="handle">AT Proto Handle</label> 237 <actor-typeahead> 238 <input 239 type="text" 240 id="handle" 241 name="handle" 242 placeholder="user.bsky.social" 243 autocomplete="username" 244 required 245 /> 246 </actor-typeahead> 247 </div> 248 <button type="submit" id="login-btn">Sign in to Continue</button> 249 <div class="status" id="status"></div> 250 </form> 251 ` : ` 252 <form id="sign-form"> 253 <div class="form-group"> 254 <label for="name">Name</label> 255 <input 256 type="text" 257 id="name" 258 name="name" 259 placeholder="Your name" 260 value="${this.userHandle || globalAgent?.session.info.sub || ''}" 261 readonly 262 /> 263 </div> 264 <div class="form-group"> 265 <label for="message">Message</label> 266 <textarea 267 id="message" 268 name="message" 269 placeholder="Share your thoughts..." 270 maxlength="100" 271 required 272 ></textarea> 273 </div> 274 <button type="submit" id="submit-btn">Sign Guestbook</button> 275 <div class="status" id="status"></div> 276 </form> 277 `} 278 </div> 279 `; 280 281 if (isLoggedIn) { 282 this.form = this.shadowRoot.querySelector('#sign-form'); 283 this.messageInput = this.shadowRoot.querySelector('#message'); 284 this.submitButton = this.shadowRoot.querySelector('#submit-btn'); 285 } else { 286 this.form = this.shadowRoot.querySelector('#login-form'); 287 this.loginInput = this.shadowRoot.querySelector('#handle'); 288 this.loginButton = this.shadowRoot.querySelector('#login-btn'); 289 } 290 291 this.statusDiv = this.shadowRoot.querySelector('#status'); 292 } 293 294 private attachEventListeners() { 295 if (!this.form) { 296 return; 297 } 298 299 const isLoggedIn = globalAgent !== null; 300 301 if (isLoggedIn && this.messageInput) { 302 // character counter 303 this.messageInput.addEventListener('input', () => { 304 this.updateCharCount(); 305 }); 306 307 // form submission for signing 308 this.form.addEventListener('submit', (e) => { 309 e.preventDefault(); 310 this.handleSubmit(); 311 }); 312 } else { 313 // form submission for login 314 this.form.addEventListener('submit', (e) => { 315 e.preventDefault(); 316 this.handleLogin(); 317 }); 318 } 319 } 320 321 private updateCharCount() { 322 if (!this.messageInput || !this.shadowRoot) { 323 return; 324 } 325 326 const length = this.messageInput.value.length; 327 const charCountEl = this.shadowRoot.querySelector('#char-count'); 328 329 if (charCountEl) { 330 charCountEl.textContent = `${length} / 100`; 331 charCountEl.classList.remove('warning', 'error'); 332 333 if (length > 90) { 334 charCountEl.classList.add('error'); 335 } else if (length > 80) { 336 charCountEl.classList.add('warning'); 337 } 338 } 339 } 340 341 private async handleLogin() { 342 const handle = this.loginInput?.value.trim(); 343 344 if (!handle) { 345 this.showStatus('Please enter your Bluesky handle.', 'error'); 346 return; 347 } 348 349 try { 350 this.showStatus('Redirecting to sign in...', 'loading'); 351 this.setFormDisabled(true); 352 353 const config = getConfig(); 354 if (!config) { 355 throw new Error('Guestbook not configured. Call configureGuestbook() first.'); 356 } 357 358 const authUrl = await createAuthorizationUrl({ 359 target: { type: 'account', identifier: handle as `${string}.${string}` }, 360 scope: config.oauth.scope, 361 }); 362 363 // recommended to wait for the browser to persist local storage before proceeding 364 await sleep(250); 365 366 // redirect the user to sign in and authorize the app 367 window.location.assign(authUrl); 368 369 } catch (error) { 370 console.error('Login error:', error); 371 this.showStatus('Login failed. Please try again.', 'error'); 372 this.setFormDisabled(false); 373 } 374 } 375 376 private async handleSubmit() { 377 if (!globalAgent) { 378 this.showStatus('Please log in first.', 'error'); 379 return; 380 } 381 382 const did = this.getAttribute('did'); 383 if (!did) { 384 this.showStatus('Missing guestbook DID.', 'error'); 385 return; 386 } 387 388 // subject is just the DID (at-identifier format) 389 const subject = did; 390 391 const message = this.messageInput?.value.trim(); 392 if (!message) { 393 this.showStatus('Please enter a message.', 'error'); 394 return; 395 } 396 397 try { 398 this.showStatus('Signing guestbook...', 'loading'); 399 this.setFormDisabled(true); 400 401 // get the client from the OAuth agent 402 const client = new Client({ handler: globalAgent }); 403 404 // create the record 405 const record = { 406 $type: 'pet.nkp.guestbook.sign', 407 subject: subject, 408 message: message, 409 createdAt: new Date().toISOString(), 410 }; 411 412 const response = await client.post('com.atproto.repo.createRecord', { 413 input: { 414 repo: globalAgent.session.info.sub, // user's DID 415 collection: 'pet.nkp.guestbook.sign', 416 record: record, 417 }, 418 }); 419 420 if (response.ok) { 421 this.showStatus('✓ Successfully signed the guestbook!', 'success'); 422 423 // clear the form 424 if (this.messageInput) { 425 this.messageInput.value = ''; 426 } 427 this.updateCharCount(); 428 429 // dispatch custom event for parent to listen to 430 this.dispatchEvent(new CustomEvent('sign-created', { 431 detail: { 432 uri: response.data.uri, 433 cid: response.data.cid, 434 }, 435 bubbles: true, 436 composed: true, 437 })); 438 439 // hide success message after 3 seconds 440 setTimeout(() => { 441 this.hideStatus(); 442 }, 3000); 443 } else { 444 throw new Error('Failed to create record'); 445 } 446 } catch (error) { 447 console.error('Error signing guestbook:', error); 448 this.showStatus( 449 `Failed to sign: ${error instanceof Error ? error.message : 'Unknown error'}`, 450 'error' 451 ); 452 } finally { 453 this.setFormDisabled(false); 454 } 455 } 456 457 private showStatus(message: string, type: 'success' | 'error' | 'loading') { 458 if (!this.statusDiv) { 459 return; 460 } 461 462 this.statusDiv.textContent = message; 463 this.statusDiv.className = `status show ${type}`; 464 } 465 466 private hideStatus() { 467 if (!this.statusDiv) { 468 return; 469 } 470 471 this.statusDiv.classList.remove('show'); 472 } 473 474 private setFormDisabled(disabled: boolean) { 475 if (this.messageInput) { 476 this.messageInput.disabled = disabled; 477 } 478 if (this.submitButton) { 479 this.submitButton.disabled = disabled; 480 } 481 if (this.loginInput) { 482 this.loginInput.disabled = disabled; 483 } 484 if (this.loginButton) { 485 this.loginButton.disabled = disabled; 486 } 487 } 488} 489