The smokesignal.events web application
at main 505 lines 18 kB view raw
1(function(window, document) { 2 'use strict'; 3 4 class SmokeSignalWidget { 5 constructor(config) { 6 this.config = this.validateConfig(config); 7 this.shadowRoot = null; 8 this.eventData = null; 9 this.init(); 10 } 11 12 validateConfig(config) { 13 // Support both single event and search modes 14 if (!config.aturi && !config.query) { 15 throw new Error('Either aturi or query is required'); 16 } 17 18 return { 19 apiUrl: 'https://smokesignal.events', 20 theme: 'light', 21 showTime: true, 22 showRSVP: true, 23 refreshInterval: 0, 24 mode: config.aturi ? 'single' : 'search', 25 maxResults: 5, 26 ...config 27 }; 28 } 29 30 parseAturi(aturi) { 31 // AT URI format: at://<did>/<collection>/<record_key> 32 // Example: at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.calendar.event/3kxbvxj7blk2t 33 34 if (!aturi.startsWith('at://')) { 35 throw new Error('Invalid AT-URI: must start with at://'); 36 } 37 38 const uriWithoutProtocol = aturi.slice(5); // Remove 'at://' 39 const parts = uriWithoutProtocol.split('/'); 40 41 if (parts.length < 3) { 42 throw new Error('Invalid AT-URI: must contain DID, collection, and record key'); 43 } 44 45 const repository = parts[0]; // The DID 46 const recordKey = parts[parts.length - 1]; // Last part is the record key 47 48 if (!repository || !recordKey) { 49 throw new Error('Invalid AT-URI: missing required components'); 50 } 51 52 return { 53 repository, 54 recordKey 55 }; 56 } 57 58 async init() { 59 this.createContainer(); 60 await this.fetchEventData(); 61 this.render(); 62 63 if (this.config.refreshInterval > 0) { 64 this.startAutoRefresh(); 65 } 66 } 67 68 createContainer() { 69 const container = typeof this.config.container === 'string' 70 ? document.querySelector(this.config.container) 71 : this.config.container; 72 73 if (!container) { 74 throw new Error('Container element not found'); 75 } 76 77 this.shadowRoot = container.attachShadow({ mode: 'open' }); 78 } 79 80 async fetchEventData() { 81 try { 82 if (this.config.mode === 'single') { 83 await this.fetchSingleEvent(); 84 } else { 85 await this.fetchEventList(); 86 } 87 } catch (error) { 88 console.error('Failed to fetch event data:', error); 89 this.renderError(); 90 } 91 } 92 93 async fetchSingleEvent() { 94 const parsedAturi = this.parseAturi(this.config.aturi); 95 96 const params = new URLSearchParams({ 97 repository: parsedAturi.repository, 98 record_key: parsedAturi.recordKey, 99 }); 100 101 if (this.config.repository) { 102 params.append('repository', this.config.repository); 103 } 104 105 const url = `${this.config.apiUrl}/xrpc/community.lexicon.calendar.GetEvent?${params}`; 106 console.log(url); 107 108 const response = await fetch( 109 url, 110 { 111 headers: { 112 'Content-Type': 'application/json', 113 'X-Widget-Version': '1.0' 114 } 115 } 116 ); 117 118 if (!response.ok) { 119 throw new Error(`HTTP ${response.status}`); 120 } 121 122 this.eventData = await response.json(); 123 } 124 125 async fetchEventList() { 126 const params = new URLSearchParams({ 127 query: this.config.query 128 }); 129 130 if (this.config.repository) { 131 params.append('repository', this.config.repository); 132 } 133 134 const url = `${this.config.apiUrl}/xrpc/community.lexicon.calendar.SearchEvents?${params}`; 135 console.log(url); 136 137 const response = await fetch( 138 url, 139 { 140 headers: { 141 'Content-Type': 'application/json', 142 'X-Widget-Version': '1.0' 143 } 144 } 145 ); 146 147 if (!response.ok) { 148 throw new Error(`HTTP ${response.status}`); 149 } 150 151 const data = await response.json(); 152 this.eventData = data.results.slice(0, this.config.maxResults); 153 if (this.eventData.length == 1) { 154 this.config.mode = 'single'; 155 this.eventData = this.eventData[0] 156 } 157 console.log(this.eventData); 158 } 159 160 render() { 161 if (!this.eventData) { 162 console.log("no event data"); 163 this.renderError(); 164 return; 165 } 166 167 if (this.config.mode === 'single') { 168 this.renderSingleEvent(); 169 } else { 170 this.renderEventList(); 171 } 172 } 173 174 renderSingleEvent() { 175 this.shadowRoot.innerHTML = ` 176 <style>${this.getStyles()}</style> 177 <article class="event-widget" role="article"> 178 <header class="event-header"> 179 <h3 class="event-title">${this.escapeHtml(this.eventData.name)}</h3> 180 <a href="${this.escapeHtml(this.eventData.url)}" 181 class="event-link" 182 target="_blank" 183 rel="noopener"> 184 View to RSVP → 185 </a> 186 </header> 187 <div class="event-body"> 188 <p class="event-description"> 189 ${this.escapeHtml(this.truncateText(this.eventData.description || this.eventData.body, 150))} 190 </p> 191 </div> 192 <footer class="event-footer"> 193 ${this.renderEventFooter(this.eventData)} 194 </footer> 195 </article> 196 `; 197 } 198 199 renderEventList() { 200 const eventsHtml = this.eventData.map(event => ` 201 <article class="event-item" role="article"> 202 <header class="event-header"> 203 <h4 class="event-title">${this.escapeHtml(event.name)}</h4> 204 <a href="${this.escapeHtml(event.url)}" 205 class="event-link" 206 target="_blank" 207 rel="noopener"> 208 View to RSVP → 209 </a> 210 </header> 211 <div class="event-body"> 212 <p class="event-description"> 213 ${this.escapeHtml(this.truncateText(event.description, 100))} 214 </p> 215 </div> 216 <footer class="event-footer"> 217 ${this.renderEventFooter(event)} 218 </footer> 219 </article> 220 `).join(''); 221 222 this.shadowRoot.innerHTML = ` 223 <style>${this.getStyles()}</style> 224 <div class="events-widget" role="region" aria-label="Event list"> 225 <header class="widget-header"> 226 <h3 class="widget-title">Events: ${this.escapeHtml(this.config.query)}</h3> 227 <span class="event-count">${this.eventData.length} result${this.eventData.length !== 1 ? 's' : ''}</span> 228 </header> 229 <div class="events-list"> 230 ${eventsHtml} 231 </div> 232 </div> 233 `; 234 } 235 236 renderEventFooter(event) { 237 const startTime = event.startsAt || event.startTime; 238 const rsvpCount = event.countGoing || event.rsvpCount || 0; 239 const interestedCount = event.countInterested || 0; 240 241 return ` 242 ${this.config.showTime && startTime ? ` 243 <time class="event-time" datetime="${startTime}"> 244 ${this.formatDate(startTime)} 245 </time> 246 ` : ''} 247 ${this.config.showRSVP ? ` 248 <div class="event-rsvp"> 249 <span class="rsvp-count">${rsvpCount}</span> 250 <span class="rsvp-label">going</span> 251 ${interestedCount > 0 ? ` 252 <span class="interested-count">${interestedCount}</span> 253 <span class="interested-label">interested</span> 254 ` : ''} 255 </div> 256 ` : ''} 257 `; 258 } 259 260 renderError() { 261 this.shadowRoot.innerHTML = ` 262 <style>${this.getStyles()}</style> 263 <div class="event-widget event-error"> 264 <p>Unable to load event</p> 265 <button onclick="this.retry()">Retry</button> 266 </div> 267 `; 268 } 269 270 getStyles() { 271 const theme = this.getTheme(); 272 return ` 273 :host { 274 all: initial; 275 display: block; 276 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 277 font-size: 14px; 278 line-height: 1.5; 279 color: ${theme.textColor}; 280 * { 281 box-sizing: border-box; 282 } 283 } 284 285 .event-widget, .events-widget { 286 background: ${theme.backgroundColor}; 287 border: 1px solid ${theme.borderColor}; 288 border-radius: 8px; 289 padding: 16px; 290 max-width: 500px; 291 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 292 } 293 294 .widget-header { 295 display: flex; 296 justify-content: space-between; 297 align-items: center; 298 margin-bottom: 16px; 299 padding-bottom: 12px; 300 border-bottom: 1px solid ${theme.borderColor}; 301 } 302 303 .widget-title { 304 margin: 0; 305 font-size: 18px; 306 font-weight: 600; 307 color: ${theme.titleColor}; 308 } 309 310 .event-count { 311 font-size: 12px; 312 color: ${theme.mutedColor}; 313 background: ${theme.backgroundColor === '#ffffff' ? '#f8f9fa' : '#2a2a2a'}; 314 padding: 4px 8px; 315 border-radius: 12px; 316 } 317 318 .events-list { 319 display: flex; 320 flex-direction: column; 321 gap: 12px; 322 } 323 324 .event-item { 325 background: ${theme.backgroundColor === '#ffffff' ? '#fbfcfd' : '#222222'}; 326 border: 1px solid ${theme.borderColor}; 327 border-radius: 6px; 328 padding: 12px; 329 transition: box-shadow 0.2s ease; 330 } 331 332 .event-item:hover { 333 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 334 } 335 336 .event-header { 337 display: flex; 338 justify-content: space-between; 339 align-items: flex-start; 340 margin-bottom: 8px; 341 } 342 343 .event-title { 344 margin: 0; 345 font-size: 16px; 346 font-weight: 600; 347 color: ${theme.titleColor}; 348 flex: 1; 349 line-height: 1.3; 350 } 351 352 .event-item .event-title { 353 font-size: 15px; 354 } 355 356 .event-link { 357 color: ${theme.linkColor}; 358 text-decoration: none; 359 font-weight: 500; 360 white-space: nowrap; 361 margin-left: 12px; 362 font-size: 13px; 363 } 364 365 .event-link:hover { 366 text-decoration: underline; 367 } 368 369 .event-description { 370 margin: 0 0 8px 0; 371 color: ${theme.textColor}; 372 font-size: 13px; 373 line-height: 1.4; 374 } 375 376 .event-footer { 377 display: flex; 378 justify-content: space-between; 379 align-items: center; 380 font-size: 12px; 381 color: ${theme.mutedColor}; 382 gap: 12px; 383 } 384 385 .event-time { 386 display: flex; 387 align-items: center; 388 flex-shrink: 0; 389 } 390 391 .event-rsvp { 392 display: flex; 393 align-items: center; 394 gap: 4px; 395 flex-shrink: 0; 396 } 397 398 .rsvp-count, .interested-count { 399 font-weight: 600; 400 color: ${theme.textColor}; 401 } 402 403 .interested-count { 404 margin-left: 8px; 405 } 406 407 @media (max-width: 320px) { 408 .event-header { 409 flex-direction: column; 410 gap: 6px; 411 } 412 413 .event-link { 414 margin-left: 0; 415 align-self: flex-start; 416 } 417 418 .event-footer { 419 flex-direction: column; 420 align-items: flex-start; 421 gap: 6px; 422 } 423 } 424 `; 425 } 426 427 getTheme() { 428 const themes = { 429 light: { 430 backgroundColor: '#ffffff', 431 textColor: '#333333', 432 titleColor: '#1a1a1a', 433 linkColor: '#0066cc', 434 borderColor: '#e1e5e9', 435 mutedColor: '#6c757d' 436 }, 437 dark: { 438 backgroundColor: '#1a1a1a', 439 textColor: '#cccccc', 440 titleColor: '#ffffff', 441 linkColor: '#4da6ff', 442 borderColor: '#333333', 443 mutedColor: '#999999' 444 } 445 }; 446 447 return themes[this.config.theme] || themes.light; 448 } 449 450 escapeHtml(text) { 451 const div = document.createElement('div'); 452 div.textContent = text; 453 return div.innerHTML; 454 } 455 456 truncateText(text, maxLength) { 457 if (text.length <= maxLength) return text; 458 return text.substr(0, maxLength) + '...'; 459 } 460 461 formatDate(dateString) { 462 const date = new Date(dateString); 463 return date.toLocaleDateString('en-US', { 464 weekday: 'short', 465 year: 'numeric', 466 month: 'short', 467 day: 'numeric', 468 hour: '2-digit', 469 minute: '2-digit' 470 }); 471 } 472 473 startAutoRefresh() { 474 setInterval(() => { 475 this.fetchEventData().then(() => this.render()); 476 }, this.config.refreshInterval); 477 } 478 } 479 480 // Export to global scope 481 window.SmokeSignalWidget = SmokeSignalWidget; 482 483 // Auto-initialize widgets on page load 484 if (document.readyState === 'loading') { 485 document.addEventListener('DOMContentLoaded', () => { 486 const widgets = document.querySelectorAll('[data-smoke-signal-widget]'); 487 widgets.forEach(initializeWidget); 488 }); 489 } 490 491 function initializeWidget(container) { 492 const config = { 493 container: container, 494 aturi: container.dataset.aturi, 495 query: container.dataset.query, 496 repository: container.dataset.repository, 497 apiUrl: container.dataset.apiUrl, 498 theme: container.dataset.theme, 499 maxResults: parseInt(container.dataset.maxResults) || 5 500 }; 501 502 new SmokeSignalWidget(config); 503 } 504 505})(window, document);