The smokesignal.events web application
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);