web components for a integrable atproto based guestbook
at main 11 kB view raw
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 all records in parallel 327 const recordPromises = data.records.map(async (record) => { 328 try { 329 const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', 'https://slingshot.wisp.place'); 330 recordUrl.searchParams.set('repo', record.did); 331 recordUrl.searchParams.set('collection', record.collection); 332 recordUrl.searchParams.set('rkey', record.rkey); 333 334 const recordResponse = await fetch(recordUrl.toString()); 335 if (!recordResponse.ok) { 336 console.warn(`Failed to fetch record ${record.did}/${record.collection}/${record.rkey}`); 337 return null; 338 } 339 340 const recordData = await recordResponse.json(); 341 342 // validate the record 343 if ( 344 recordData.value && 345 recordData.value.$type === 'pet.nkp.guestbook.sign' && 346 typeof recordData.value.message === 'string' && 347 typeof recordData.value.createdAt === 'string' 348 ) { 349 return { 350 uri: recordData.uri, 351 cid: recordData.cid, 352 value: recordData.value, 353 author: record.did, 354 authorHandle: undefined, 355 } as GuestbookSignature; 356 } 357 } catch (err) { 358 console.warn(`Error fetching record ${record.did}/${record.collection}/${record.rkey}:`, err); 359 } 360 return null; 361 }); 362 363 const results = await Promise.all(recordPromises); 364 const validSignatures = results.filter((sig): sig is GuestbookSignature => sig !== null); 365 366 // Sort once after collecting all signatures 367 validSignatures.sort((a, b) => { 368 return new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime(); 369 }); 370 371 this.signatures = validSignatures; 372 this.loading = false; 373 this.updateContent(); 374 375 // Batch fetch profiles asynchronously 376 if (validSignatures.length > 0) { 377 const uniqueDids = Array.from(new Set(validSignatures.map(sig => sig.author))); 378 379 // Batch fetch profiles up to 25 at a time (API limit) 380 const profilePromises = []; 381 for (let i = 0; i < uniqueDids.length; i += 25) { 382 const batch = uniqueDids.slice(i, i + 25); 383 384 const profileUrl = new URL('/xrpc/app.bsky.actor.getProfiles', 'https://public.api.bsky.app'); 385 batch.forEach(d => profileUrl.searchParams.append('actors', d)); 386 387 profilePromises.push( 388 fetch(profileUrl.toString()) 389 .then(profileResponse => profileResponse.ok ? profileResponse.json() : null) 390 .then(profilesData => { 391 if (profilesData?.profiles && Array.isArray(profilesData.profiles)) { 392 const handles = new Map<string, string>(); 393 profilesData.profiles.forEach((profile: any) => { 394 if (profile.handle) { 395 handles.set(profile.did, profile.handle); 396 } 397 }); 398 return handles; 399 } 400 return new Map<string, string>(); 401 }) 402 .catch((err) => { 403 console.warn('Failed to fetch profile batch:', err); 404 return new Map<string, string>(); 405 }) 406 ); 407 } 408 409 // Wait for all profile batches, then update once 410 const handleMaps = await Promise.all(profilePromises); 411 const allHandles = new Map<string, string>(); 412 handleMaps.forEach(map => { 413 map.forEach((handle, did) => allHandles.set(did, handle)); 414 }); 415 416 if (allHandles.size > 0) { 417 this.signatures = this.signatures.map(sig => { 418 const handle = allHandles.get(sig.author); 419 return handle ? { ...sig, authorHandle: handle } : sig; 420 }); 421 this.updateContent(); 422 } 423 } 424 } 425 426 } catch (error) { 427 console.error('Error fetching signatures:', error); 428 this.error = error instanceof Error ? error.message : 'Unknown error occurred'; 429 this.loading = false; 430 this.updateContent(); 431 } 432 } 433 434 private formatTimestamp(isoString: string): string { 435 const date = new Date(isoString); 436 // Format as "Nov 24, 2024" 437 return date.toLocaleDateString('en-US', { 438 month: 'short', 439 day: 'numeric', 440 year: 'numeric', 441 }); 442 } 443 444 private shortenDid(did: string): string { 445 if (did.startsWith('did:')) { 446 const afterPrefix = did.indexOf(':', 4); 447 if (afterPrefix !== -1) { 448 return `${did.slice(0, afterPrefix + 9)}...`; 449 } 450 } 451 return did; 452 } 453 454 private escapeHtml(text: string): string { 455 const div = document.createElement('div'); 456 div.textContent = text; 457 return div.innerHTML; 458 } 459 460 /** 461 * refresh signatures from the API 462 */ 463 refresh() { 464 this.fetchSignatures(); 465 } 466} 467