web components for a integrable atproto based guestbook
at main 12 kB view raw
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 ${!isLoggedIn ? ` 233 <form id="login-form"> 234 <div class="form-group"> 235 <label for="handle">AT Proto Handle</label> 236 <actor-typeahead> 237 <input 238 type="text" 239 id="handle" 240 name="handle" 241 placeholder="user.bsky.social" 242 autocomplete="off" 243 data-1p-ignore 244 data-lpignore="true" 245 required 246 /> 247 </actor-typeahead> 248 </div> 249 <button type="submit" id="login-btn">Sign in to Continue</button> 250 <div class="status" id="status"></div> 251 </form> 252 ` : ` 253 <form id="sign-form"> 254 <div class="form-group"> 255 <label for="name">Name</label> 256 <input 257 type="text" 258 id="name" 259 name="name" 260 placeholder="Your name" 261 value="${this.userHandle || globalAgent?.session.info.sub || ''}" 262 readonly 263 /> 264 </div> 265 <div class="form-group"> 266 <label for="message">Message</label> 267 <textarea 268 id="message" 269 name="message" 270 placeholder="Share your thoughts..." 271 maxlength="100" 272 required 273 ></textarea> 274 </div> 275 <button type="submit" id="submit-btn">Sign Guestbook</button> 276 <div class="status" id="status"></div> 277 </form> 278 `} 279 </div> 280 `; 281 282 if (isLoggedIn) { 283 this.form = this.shadowRoot.querySelector('#sign-form'); 284 this.messageInput = this.shadowRoot.querySelector('#message'); 285 this.submitButton = this.shadowRoot.querySelector('#submit-btn'); 286 } else { 287 this.form = this.shadowRoot.querySelector('#login-form'); 288 this.loginInput = this.shadowRoot.querySelector('#handle'); 289 this.loginButton = this.shadowRoot.querySelector('#login-btn'); 290 } 291 292 this.statusDiv = this.shadowRoot.querySelector('#status'); 293 } 294 295 private attachEventListeners() { 296 if (!this.form) { 297 return; 298 } 299 300 const isLoggedIn = globalAgent !== null; 301 302 if (isLoggedIn && this.messageInput) { 303 // character counter 304 this.messageInput.addEventListener('input', () => { 305 this.updateCharCount(); 306 }); 307 308 // form submission for signing 309 this.form.addEventListener('submit', (e) => { 310 e.preventDefault(); 311 this.handleSubmit(); 312 }); 313 } else { 314 // form submission for login 315 this.form.addEventListener('submit', (e) => { 316 e.preventDefault(); 317 this.handleLogin(); 318 }); 319 } 320 } 321 322 private updateCharCount() { 323 if (!this.messageInput || !this.shadowRoot) { 324 return; 325 } 326 327 const length = this.messageInput.value.length; 328 const charCountEl = this.shadowRoot.querySelector('#char-count'); 329 330 if (charCountEl) { 331 charCountEl.textContent = `${length} / 100`; 332 charCountEl.classList.remove('warning', 'error'); 333 334 if (length > 90) { 335 charCountEl.classList.add('error'); 336 } else if (length > 80) { 337 charCountEl.classList.add('warning'); 338 } 339 } 340 } 341 342 private async handleLogin() { 343 const handle = this.loginInput?.value.trim(); 344 345 if (!handle) { 346 this.showStatus('Please enter your Bluesky handle.', 'error'); 347 return; 348 } 349 350 try { 351 this.showStatus('Redirecting to sign in...', 'loading'); 352 this.setFormDisabled(true); 353 354 const config = getConfig(); 355 if (!config) { 356 throw new Error('Guestbook not configured. Call configureGuestbook() first.'); 357 } 358 359 const authUrl = await createAuthorizationUrl({ 360 target: { type: 'account', identifier: handle as `${string}.${string}` }, 361 scope: config.oauth.scope, 362 }); 363 364 // recommended to wait for the browser to persist local storage before proceeding 365 await sleep(250); 366 367 // redirect the user to sign in and authorize the app 368 window.location.assign(authUrl); 369 370 } catch (error) { 371 console.error('Login error:', error); 372 this.showStatus('Login failed. Please try again.', 'error'); 373 this.setFormDisabled(false); 374 } 375 } 376 377 private async handleSubmit() { 378 if (!globalAgent) { 379 this.showStatus('Please log in first.', 'error'); 380 return; 381 } 382 383 const did = this.getAttribute('did'); 384 if (!did) { 385 this.showStatus('Missing guestbook DID.', 'error'); 386 return; 387 } 388 389 // subject is just the DID (at-identifier format) 390 const subject = did; 391 392 const message = this.messageInput?.value.trim(); 393 if (!message) { 394 this.showStatus('Please enter a message.', 'error'); 395 return; 396 } 397 398 try { 399 this.showStatus('Signing guestbook...', 'loading'); 400 this.setFormDisabled(true); 401 402 // get the client from the OAuth agent 403 const client = new Client({ handler: globalAgent }); 404 405 // create the record 406 const record = { 407 $type: 'pet.nkp.guestbook.sign', 408 subject: subject, 409 message: message, 410 createdAt: new Date().toISOString(), 411 }; 412 413 const response = await client.post('com.atproto.repo.createRecord', { 414 input: { 415 repo: globalAgent.session.info.sub, // user's DID 416 collection: 'pet.nkp.guestbook.sign', 417 record: record, 418 }, 419 }); 420 421 if (response.ok) { 422 this.showStatus('✓ Successfully signed the guestbook!', 'success'); 423 424 // clear the form 425 if (this.messageInput) { 426 this.messageInput.value = ''; 427 } 428 this.updateCharCount(); 429 430 // dispatch custom event for parent to listen to 431 this.dispatchEvent(new CustomEvent('sign-created', { 432 detail: { 433 uri: response.data.uri, 434 cid: response.data.cid, 435 }, 436 bubbles: true, 437 composed: true, 438 })); 439 440 // hide success message after 3 seconds 441 setTimeout(() => { 442 this.hideStatus(); 443 }, 3000); 444 } else { 445 throw new Error('Failed to create record'); 446 } 447 } catch (error) { 448 console.error('Error signing guestbook:', error); 449 this.showStatus( 450 `Failed to sign: ${error instanceof Error ? error.message : 'Unknown error'}`, 451 'error' 452 ); 453 } finally { 454 this.setFormDisabled(false); 455 } 456 } 457 458 private showStatus(message: string, type: 'success' | 'error' | 'loading') { 459 if (!this.statusDiv) { 460 return; 461 } 462 463 this.statusDiv.textContent = message; 464 this.statusDiv.className = `status show ${type}`; 465 } 466 467 private hideStatus() { 468 if (!this.statusDiv) { 469 return; 470 } 471 472 this.statusDiv.classList.remove('show'); 473 } 474 475 private setFormDisabled(disabled: boolean) { 476 if (this.messageInput) { 477 this.messageInput.disabled = disabled; 478 } 479 if (this.submitButton) { 480 this.submitButton.disabled = disabled; 481 } 482 if (this.loginInput) { 483 this.loginInput.disabled = disabled; 484 } 485 if (this.loginButton) { 486 this.loginButton.disabled = disabled; 487 } 488 } 489} 490