web components for a integrable atproto based guestbook
1// no imports needed for this component - uses native fetch 2 3/** 4 * represents a guestbook signature from constellation 5 */ 6export interface GuestbookSignature { 7 uri: string; 8 cid: string; 9 value: { 10 $type: 'pet.nkp.guestbook.sign'; 11 subject: string; // DID (at-identifier format) 12 message: string; 13 createdAt: string; 14 }; 15 author: string; 16 authorHandle?: string; 17} 18 19/** 20 * constellation API response for backlinks 21 */ 22interface ConstellationBacklinksResponse { 23 total: number; 24 records: Array<{ 25 did: string; 26 collection: string; 27 rkey: string; 28 }>; 29 cursor?: string; 30} 31 32/** 33 * Web component for displaying guestbook signatures. 34 * 35 * Usage: 36 * <guestbook-display 37 * did="did:web:nekomimi.pet" 38 * limit="50"> 39 * </guestbook-display> 40 */ 41export class GuestbookDisplayElement extends HTMLElement { 42 private signatures: GuestbookSignature[] = []; 43 private loading = false; 44 private error: string | null = null; 45 46 static get observedAttributes() { 47 return ['did', 'limit']; 48 } 49 50 constructor() { 51 super(); 52 this.attachShadow({ mode: 'open' }); 53 } 54 55 connectedCallback() { 56 this.render(); 57 this.fetchSignatures(); 58 } 59 60 attributeChangedCallback(name: string, oldValue: string, newValue: string) { 61 if (oldValue !== newValue && (name === 'did' || name === 'limit')) { 62 this.fetchSignatures(); 63 } 64 } 65 66 private render() { 67 if (!this.shadowRoot) { 68 return; 69 } 70 71 this.shadowRoot.innerHTML = ` 72 <style> 73 * { 74 box-sizing: border-box; 75 margin: 0; 76 padding: 0; 77 } 78 79 :host { 80 display: block; 81 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 82 -webkit-font-smoothing: antialiased; 83 -moz-osx-font-smoothing: grayscale; 84 } 85 86 .guestbook-display { 87 background: #f9fafb; 88 padding: 0; 89 width: 100%; 90 } 91 92 h2 { 93 margin: 0 0 24px 0; 94 font-size: 11px; 95 font-weight: 700; 96 color: #000; 97 text-transform: uppercase; 98 letter-spacing: 0.05em; 99 } 100 101 .signatures-list { 102 display: flex; 103 flex-direction: column; 104 gap: 12px; 105 } 106 107 .signature-card { 108 background: white; 109 border: 1px solid #e5e7eb; 110 border-radius: 6px; 111 padding: 16px; 112 transition: background-color 0.2s; 113 } 114 115 .signature-card:hover { 116 background: rgba(255, 255, 255, 0.5); 117 } 118 119 .signature-header { 120 display: flex; 121 justify-content: space-between; 122 align-items: baseline; 123 margin-bottom: 8px; 124 gap: 12px; 125 } 126 127 .author { 128 font-weight: 500; 129 color: #000; 130 font-size: 14px; 131 word-break: break-all; 132 } 133 134 .author a { 135 color: #000; 136 text-decoration: none; 137 } 138 139 .author a:hover { 140 text-decoration: underline; 141 } 142 143 .timestamp { 144 font-size: 12px; 145 color: #6b7280; 146 white-space: nowrap; 147 } 148 149 .message { 150 font-size: 14px; 151 line-height: 1.5; 152 color: #000; 153 word-wrap: break-word; 154 } 155 156 .loading { 157 text-align: center; 158 padding: 32px; 159 color: #9ca3af; 160 } 161 162 .spinner { 163 display: inline-block; 164 width: 20px; 165 height: 20px; 166 border: 3px solid #e5e7eb; 167 border-top-color: #6b7280; 168 border-radius: 50%; 169 animation: spin 0.8s linear infinite; 170 } 171 172 @keyframes spin { 173 to { transform: rotate(360deg); } 174 } 175 176 .error { 177 text-align: center; 178 padding: 32px; 179 color: #dc2626; 180 background: #fef2f2; 181 border-radius: 8px; 182 border: 1px solid #fecaca; 183 } 184 185 .empty { 186 text-align: center; 187 padding: 48px 24px; 188 color: #9ca3af; 189 font-size: 15px; 190 } 191 192 .empty-icon { 193 font-size: 48px; 194 margin-bottom: 16px; 195 opacity: 0.5; 196 } 197 198 .signature-count { 199 font-size: 14px; 200 color: #536471; 201 margin-bottom: 16px; 202 text-align: center; 203 } 204 </style> 205 206 <div class="guestbook-display"> 207 <h2>RECENT ENTRIES</h2> 208 <div id="content"></div> 209 </div> 210 `; 211 212 this.updateContent(); 213 } 214 215 private updateContent() { 216 const contentDiv = this.shadowRoot?.querySelector('#content'); 217 if (!contentDiv) { 218 return; 219 } 220 221 if (this.loading) { 222 contentDiv.innerHTML = ` 223 <div class="loading"> 224 <div class="spinner"></div> 225 <p>Loading signatures...</p> 226 </div> 227 `; 228 return; 229 } 230 231 if (this.error) { 232 contentDiv.innerHTML = ` 233 <div class="error"> 234 <p><strong>Error:</strong> ${this.error}</p> 235 </div> 236 `; 237 return; 238 } 239 240 if (this.signatures.length === 0) { 241 contentDiv.innerHTML = ` 242 <div class="empty"> 243 <div class="empty-icon">✍️</div> 244 <p>No signatures yet. Be the first to sign!</p> 245 </div> 246 `; 247 return; 248 } 249 250 // render signatures 251 const signaturesHtml = this.signatures 252 .map((sig) => this.renderSignature(sig)) 253 .join(''); 254 255 contentDiv.innerHTML = ` 256 <div class="signature-count"> 257 ${this.signatures.length} signature${this.signatures.length !== 1 ? 's' : ''} 258 </div> 259 <div class="signatures-list"> 260 ${signaturesHtml} 261 </div> 262 `; 263 } 264 265 private renderSignature(sig: GuestbookSignature): string { 266 const timestamp = this.formatTimestamp(sig.value.createdAt); 267 const authorIdentifier = sig.authorHandle || sig.author; 268 const authorLink = `https://bsky.app/profile/${sig.authorHandle || sig.author}`; 269 const displayName = sig.authorHandle || this.shortenDid(sig.author); 270 271 return ` 272 <div class="signature-card"> 273 <div class="signature-header"> 274 <div class="author"> 275 <a href="${authorLink}" target="_blank" rel="noopener noreferrer"> 276 ${this.escapeHtml(displayName)} 277 </a> 278 </div> 279 <div class="timestamp">${timestamp}</div> 280 </div> 281 <div class="message">${this.escapeHtml(sig.value.message)}</div> 282 </div> 283 `; 284 } 285 286 private async fetchSignatures() { 287 const did = this.getAttribute('did'); 288 if (!did) { 289 this.error = 'Missing did attribute'; 290 this.render(); 291 return; 292 } 293 294 // subject is just the DID (at-identifier format) 295 const subject = did; 296 297 const limit = parseInt(this.getAttribute('limit') || '50', 10); 298 299 this.loading = true; 300 this.error = null; 301 this.updateContent(); 302 303 try { 304 // query constellation for backlinks 305 // source format: collection:field.path 306 // constellation is not in atproto schemas, so we use a direct fetch 307 const url = new URL('/xrpc/blue.microcosm.links.getBacklinks', 'https://constellation.microcosm.blue'); 308 url.searchParams.set('subject', subject); 309 url.searchParams.set('source', 'pet.nkp.guestbook.sign:subject'); 310 url.searchParams.set('limit', limit.toString()); 311 312 const response = await fetch(url.toString()); 313 if (!response.ok) { 314 throw new Error('Failed to fetch signatures from Constellation'); 315 } 316 317 const data: ConstellationBacklinksResponse = await response.json(); 318 319 console.log('Constellation response:', data); 320 321 // fetch actual records 322 if (!data.records || !Array.isArray(data.records)) { 323 console.warn('No records found in response'); 324 this.signatures = []; 325 } else { 326 // fetch each record from the repository 327 const signatures: GuestbookSignature[] = []; 328 329 for (const record of data.records) { 330 try { 331 const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place'); 332 recordUrl.searchParams.set('repo', record.did); 333 recordUrl.searchParams.set('collection', record.collection); 334 recordUrl.searchParams.set('rkey', record.rkey); 335 336 const recordResponse = await fetch(recordUrl.toString()); 337 if (!recordResponse.ok) { 338 console.warn(`Failed to fetch record ${record.did}/${record.collection}/${record.rkey}`); 339 continue; 340 } 341 342 const recordData = await recordResponse.json(); 343 344 // validate the record 345 if ( 346 recordData.value && 347 recordData.value.$type === 'pet.nkp.guestbook.sign' && 348 typeof recordData.value.message === 'string' && 349 typeof recordData.value.createdAt === 'string' 350 ) { 351 // Fetch the handle for this author 352 let authorHandle: string | undefined; 353 try { 354 const profileResponse = await fetch( 355 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${record.did}` 356 ); 357 if (profileResponse.ok) { 358 const profileData = await profileResponse.json(); 359 authorHandle = profileData.handle; 360 } 361 } catch (err) { 362 console.warn(`Failed to fetch handle for ${record.did}:`, err); 363 } 364 365 signatures.push({ 366 uri: recordData.uri, 367 cid: recordData.cid, 368 value: recordData.value, 369 author: record.did, 370 authorHandle: authorHandle, 371 }); 372 } 373 } catch (err) { 374 console.warn(`Error fetching record ${record.did}/${record.collection}/${record.rkey}:`, err); 375 } 376 } 377 378 // sort by creation time, newest first 379 this.signatures = signatures.sort((a, b) => { 380 return new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime(); 381 }); 382 } 383 384 this.loading = false; 385 this.updateContent(); 386 387 } catch (error) { 388 console.error('Error fetching signatures:', error); 389 this.error = error instanceof Error ? error.message : 'Unknown error occurred'; 390 this.loading = false; 391 this.updateContent(); 392 } 393 } 394 395 private formatTimestamp(isoString: string): string { 396 const date = new Date(isoString); 397 // Format as "Nov 24, 2024" 398 return date.toLocaleDateString('en-US', { 399 month: 'short', 400 day: 'numeric', 401 year: 'numeric', 402 }); 403 } 404 405 private shortenDid(did: string): string { 406 if (did.startsWith('did:plc:')) { 407 return `${did.slice(0, 16)}...${did.slice(-4)}`; 408 } 409 return did; 410 } 411 412 private escapeHtml(text: string): string { 413 const div = document.createElement('div'); 414 div.textContent = text; 415 return div.innerHTML; 416 } 417 418 /** 419 * refresh signatures from the API 420 */ 421 refresh() { 422 this.fetchSignatures(); 423 } 424} 425