slack status without the slack status.zzstoatzz.io
hatk statusphere
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 892c436419136c8b65024a53397aea0bb7a000fe 1220 lines 44 kB view raw
1// Configuration 2const CONFIG = { 3 server: 'https://zzstoatzz-quickslice-status.fly.dev', 4 clientId: 'client_2mP9AwgVHkg1vaSpcWSsKw', 5}; 6 7// Base path for routing (empty for root domain, '/subpath' for subdirectory) 8const BASE_PATH = ''; 9 10let client = null; 11let userPreferences = null; 12 13// Default preferences 14const DEFAULT_PREFERENCES = { 15 accentColor: '#4a9eff', 16 font: 'mono', 17 theme: 'dark' 18}; 19 20// Available fonts - use simple keys, map to actual CSS in applyPreferences 21const FONTS = [ 22 { value: 'system', label: 'system' }, 23 { value: 'mono', label: 'mono' }, 24 { value: 'serif', label: 'serif' }, 25 { value: 'comic', label: 'comic' }, 26]; 27 28const FONT_CSS = { 29 'system': 'system-ui, -apple-system, sans-serif', 30 'mono': 'ui-monospace, SF Mono, Monaco, monospace', 31 'serif': 'ui-serif, Georgia, serif', 32 'comic': 'Comic Sans MS, Comic Sans, cursive', 33}; 34 35// Preset accent colors 36const ACCENT_COLORS = [ 37 '#4a9eff', // blue (default) 38 '#10b981', // green 39 '#f59e0b', // amber 40 '#ef4444', // red 41 '#8b5cf6', // purple 42 '#ec4899', // pink 43 '#06b6d4', // cyan 44 '#f97316', // orange 45]; 46 47// Apply preferences to the page 48function applyPreferences(prefs) { 49 const { accentColor, font, theme } = { ...DEFAULT_PREFERENCES, ...prefs }; 50 51 document.documentElement.style.setProperty('--accent', accentColor); 52 // Map simple font key to actual CSS font-family 53 const fontCSS = FONT_CSS[font] || FONT_CSS['mono']; 54 document.documentElement.style.setProperty('--font-family', fontCSS); 55 document.documentElement.setAttribute('data-theme', theme); 56 57 localStorage.setItem('theme', theme); 58} 59 60// Load preferences from server 61async function loadPreferences() { 62 if (!client) return DEFAULT_PREFERENCES; 63 64 try { 65 const user = client.getUser(); 66 if (!user) return DEFAULT_PREFERENCES; 67 68 const res = await fetch(`${CONFIG.server}/graphql`, { 69 method: 'POST', 70 headers: { 'Content-Type': 'application/json' }, 71 body: JSON.stringify({ 72 query: ` 73 query GetPreferences($did: String!) { 74 ioZzstoatzzStatusPreferences( 75 where: { did: { eq: $did } } 76 first: 1 77 ) { 78 edges { node { accentColor font theme } } 79 } 80 } 81 `, 82 variables: { did: user.did } 83 }) 84 }); 85 const json = await res.json(); 86 const edges = json.data?.ioZzstoatzzStatusPreferences?.edges || []; 87 88 if (edges.length > 0) { 89 userPreferences = edges[0].node; 90 return userPreferences; 91 } 92 return DEFAULT_PREFERENCES; 93 } catch (e) { 94 console.error('Failed to load preferences:', e); 95 return DEFAULT_PREFERENCES; 96 } 97} 98 99// Save preferences to server 100async function savePreferences(prefs) { 101 if (!client) return; 102 103 try { 104 const user = client.getUser(); 105 if (!user) return; 106 107 // First, delete any existing preferences records for this user 108 const res = await fetch(`${CONFIG.server}/graphql`, { 109 method: 'POST', 110 headers: { 'Content-Type': 'application/json' }, 111 body: JSON.stringify({ 112 query: ` 113 query GetExistingPrefs($did: String!) { 114 ioZzstoatzzStatusPreferences(where: { did: { eq: $did } }, first: 50) { 115 edges { node { uri } } 116 } 117 } 118 `, 119 variables: { did: user.did } 120 }) 121 }); 122 const json = await res.json(); 123 const existing = json.data?.ioZzstoatzzStatusPreferences?.edges || []; 124 125 // Delete all existing preference records 126 for (const edge of existing) { 127 const rkey = edge.node.uri.split('/').pop(); 128 try { 129 await client.mutate(` 130 mutation DeletePref($rkey: String!) { 131 deleteIoZzstoatzzStatusPreferences(rkey: $rkey) { uri } 132 } 133 `, { rkey }); 134 } catch (e) { 135 console.warn('Failed to delete old pref:', e); 136 } 137 } 138 139 // Create new preferences record 140 await client.mutate(` 141 mutation SavePreferences($input: CreateIoZzstoatzzStatusPreferencesInput!) { 142 createIoZzstoatzzStatusPreferences(input: $input) { uri } 143 } 144 `, { 145 input: { 146 accentColor: prefs.accentColor, 147 font: prefs.font, 148 theme: prefs.theme 149 } 150 }); 151 152 userPreferences = prefs; 153 applyPreferences(prefs); 154 } catch (e) { 155 console.error('Failed to save preferences:', e); 156 alert('Failed to save preferences: ' + e.message); 157 } 158} 159 160// Create settings modal 161function createSettingsModal() { 162 const overlay = document.createElement('div'); 163 overlay.className = 'settings-overlay hidden'; 164 overlay.innerHTML = ` 165 <div class="settings-modal"> 166 <div class="settings-header"> 167 <h3>settings</h3> 168 <button class="settings-close" aria-label="close">✕</button> 169 </div> 170 <div class="settings-content"> 171 <div class="setting-group"> 172 <label>accent color</label> 173 <div class="color-picker"> 174 ${ACCENT_COLORS.map(c => ` 175 <button class="color-btn" data-color="${c}" style="background: ${c}" title="${c}"></button> 176 `).join('')} 177 <input type="color" id="custom-color" class="custom-color-input" title="custom color"> 178 </div> 179 </div> 180 <div class="setting-group"> 181 <label>font</label> 182 <select id="font-select"> 183 ${FONTS.map(f => `<option value="${f.value}">${f.label}</option>`).join('')} 184 </select> 185 </div> 186 <div class="setting-group"> 187 <label>theme</label> 188 <select id="theme-select"> 189 <option value="dark">dark</option> 190 <option value="light">light</option> 191 <option value="system">system</option> 192 </select> 193 </div> 194 </div> 195 <div class="settings-footer"> 196 <button id="save-settings" class="save-btn">save</button> 197 </div> 198 </div> 199 `; 200 201 const modal = overlay.querySelector('.settings-modal'); 202 const closeBtn = overlay.querySelector('.settings-close'); 203 const colorBtns = overlay.querySelectorAll('.color-btn'); 204 const customColor = overlay.querySelector('#custom-color'); 205 const fontSelect = overlay.querySelector('#font-select'); 206 const themeSelect = overlay.querySelector('#theme-select'); 207 const saveBtn = overlay.querySelector('#save-settings'); 208 209 let currentPrefs = { ...DEFAULT_PREFERENCES }; 210 211 function updateColorSelection(color) { 212 colorBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.color === color)); 213 customColor.value = color; 214 currentPrefs.accentColor = color; 215 } 216 217 function open(prefs) { 218 currentPrefs = { ...DEFAULT_PREFERENCES, ...prefs }; 219 updateColorSelection(currentPrefs.accentColor); 220 fontSelect.value = currentPrefs.font; 221 themeSelect.value = currentPrefs.theme; 222 overlay.classList.remove('hidden'); 223 } 224 225 function close() { 226 overlay.classList.add('hidden'); 227 } 228 229 overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); 230 closeBtn.addEventListener('click', close); 231 232 colorBtns.forEach(btn => { 233 btn.addEventListener('click', () => updateColorSelection(btn.dataset.color)); 234 }); 235 236 customColor.addEventListener('input', () => { 237 updateColorSelection(customColor.value); 238 }); 239 240 fontSelect.addEventListener('change', () => { 241 currentPrefs.font = fontSelect.value; 242 }); 243 244 themeSelect.addEventListener('change', () => { 245 currentPrefs.theme = themeSelect.value; 246 }); 247 248 saveBtn.addEventListener('click', async () => { 249 saveBtn.disabled = true; 250 saveBtn.textContent = 'saving...'; 251 await savePreferences(currentPrefs); 252 saveBtn.disabled = false; 253 saveBtn.textContent = 'save'; 254 close(); 255 }); 256 257 document.body.appendChild(overlay); 258 return { open, close }; 259} 260 261// Theme (fallback for non-logged-in users) 262function initTheme() { 263 const saved = localStorage.getItem('theme') || 'dark'; 264 document.documentElement.setAttribute('data-theme', saved); 265} 266 267function toggleTheme() { 268 const current = document.documentElement.getAttribute('data-theme'); 269 const next = current === 'dark' ? 'light' : 'dark'; 270 document.documentElement.setAttribute('data-theme', next); 271 localStorage.setItem('theme', next); 272 273 // If logged in, also update preferences 274 if (userPreferences) { 275 userPreferences.theme = next; 276 savePreferences(userPreferences); 277 } 278} 279 280// Timestamp formatting (ported from original status app) 281const TimestampFormatter = { 282 formatRelative(date, now = new Date()) { 283 const diffMs = now - date; 284 const diffMins = Math.floor(diffMs / 60000); 285 const diffHours = Math.floor(diffMs / 3600000); 286 const diffDays = Math.floor(diffMs / 86400000); 287 288 if (diffMs < 30000) return 'just now'; 289 if (diffMins < 60) return `${diffMins}m ago`; 290 if (diffHours < 24) { 291 const remainingMins = diffMins % 60; 292 return remainingMins === 0 ? `${diffHours}h ago` : `${diffHours}h ${remainingMins}m ago`; 293 } 294 if (diffDays < 7) { 295 const remainingHours = diffHours % 24; 296 return remainingHours === 0 ? `${diffDays}d ago` : `${diffDays}d ${remainingHours}h ago`; 297 } 298 299 const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 300 if (date.getFullYear() === now.getFullYear()) { 301 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; 302 } 303 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; 304 }, 305 306 formatCompact(date, now = new Date()) { 307 const diffMs = now - date; 308 const diffDays = Math.floor(diffMs / 86400000); 309 310 if (date.toDateString() === now.toDateString()) { 311 return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 312 } 313 const yesterday = new Date(now); 314 yesterday.setDate(yesterday.getDate() - 1); 315 if (date.toDateString() === yesterday.toDateString()) { 316 return 'yesterday, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 317 } 318 if (diffDays < 7) { 319 const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); 320 const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 321 return `${dayName}, ${time}`; 322 } 323 if (date.getFullYear() === now.getFullYear()) { 324 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 325 } 326 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 327 }, 328 329 getFullTimestamp(date) { 330 const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); 331 const monthDay = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); 332 const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); 333 const tzAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop(); 334 return `${dayName}, ${monthDay} at ${time} ${tzAbbr}`; 335 } 336}; 337 338function relativeTime(dateStr, format = 'relative') { 339 const date = new Date(dateStr); 340 return format === 'compact' 341 ? TimestampFormatter.formatCompact(date) 342 : TimestampFormatter.formatRelative(date); 343} 344 345function relativeTimeFuture(dateStr) { 346 const date = new Date(dateStr); 347 const now = new Date(); 348 const diffMs = date - now; 349 350 if (diffMs <= 0) return 'now'; 351 352 const diffMins = Math.floor(diffMs / 60000); 353 const diffHours = Math.floor(diffMs / 3600000); 354 const diffDays = Math.floor(diffMs / 86400000); 355 356 if (diffMins < 1) return 'in less than a minute'; 357 if (diffMins < 60) return `in ${diffMins}m`; 358 if (diffHours < 24) { 359 const remainingMins = diffMins % 60; 360 return remainingMins === 0 ? `in ${diffHours}h` : `in ${diffHours}h ${remainingMins}m`; 361 } 362 if (diffDays < 7) { 363 const remainingHours = diffHours % 24; 364 return remainingHours === 0 ? `in ${diffDays}d` : `in ${diffDays}d ${remainingHours}h`; 365 } 366 367 // For longer times, show the date 368 const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 369 if (date.getFullYear() === now.getFullYear()) { 370 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; 371 } 372 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; 373} 374 375function fullTimestamp(dateStr) { 376 return TimestampFormatter.getFullTimestamp(new Date(dateStr)); 377} 378 379// Emoji picker 380let emojiData = null; 381let bufoList = null; 382let userFrequentEmojis = null; 383const DEFAULT_FREQUENT_EMOJIS = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏', '😴', '🤔', '👀', '💻']; 384 385async function loadUserFrequentEmojis() { 386 if (userFrequentEmojis) return userFrequentEmojis; 387 if (!client) return DEFAULT_FREQUENT_EMOJIS; 388 389 try { 390 const user = client.getUser(); 391 if (!user) return DEFAULT_FREQUENT_EMOJIS; 392 393 // Fetch user's status history to count emoji usage 394 const res = await fetch(`${CONFIG.server}/graphql`, { 395 method: 'POST', 396 headers: { 'Content-Type': 'application/json' }, 397 body: JSON.stringify({ 398 query: ` 399 query GetUserEmojis($did: String!) { 400 ioZzstoatzzStatusRecord( 401 first: 100 402 where: { did: { eq: $did } } 403 ) { 404 edges { node { emoji } } 405 } 406 } 407 `, 408 variables: { did: user.did } 409 }) 410 }); 411 const json = await res.json(); 412 const emojis = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node.emoji) || []; 413 414 if (emojis.length === 0) return DEFAULT_FREQUENT_EMOJIS; 415 416 // Count emoji frequency 417 const counts = {}; 418 emojis.forEach(e => { counts[e] = (counts[e] || 0) + 1; }); 419 420 // Sort by frequency and take top 16 421 const sorted = Object.entries(counts) 422 .sort((a, b) => b[1] - a[1]) 423 .slice(0, 16) 424 .map(([emoji]) => emoji); 425 426 userFrequentEmojis = sorted.length > 0 ? sorted : DEFAULT_FREQUENT_EMOJIS; 427 return userFrequentEmojis; 428 } catch (e) { 429 console.error('Failed to load frequent emojis:', e); 430 return DEFAULT_FREQUENT_EMOJIS; 431 } 432} 433 434async function loadBufoList() { 435 if (bufoList) return bufoList; 436 const res = await fetch('/bufos.json'); 437 if (!res.ok) throw new Error('Failed to load bufos'); 438 bufoList = await res.json(); 439 return bufoList; 440} 441 442async function loadEmojiData() { 443 if (emojiData) return emojiData; 444 try { 445 const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json'); 446 if (!response.ok) throw new Error('Failed to fetch'); 447 const data = await response.json(); 448 449 const emojis = {}; 450 const categories = { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] }; 451 const categoryMap = { 452 'Smileys & Emotion': 'people', 'People & Body': 'people', 'Animals & Nature': 'nature', 453 'Food & Drink': 'food', 'Activities': 'activity', 'Travel & Places': 'travel', 454 'Objects': 'objects', 'Symbols': 'symbols', 'Flags': 'flags' 455 }; 456 457 data.forEach(emoji => { 458 const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join(''); 459 const keywords = [...(emoji.short_names || []), ...(emoji.name ? emoji.name.toLowerCase().split(/[\s_-]+/) : [])]; 460 emojis[char] = keywords; 461 const cat = categoryMap[emoji.category]; 462 if (cat && categories[cat]) categories[cat].push(char); 463 }); 464 465 emojiData = { emojis, categories }; 466 return emojiData; 467 } catch (e) { 468 console.error('Failed to load emoji data:', e); 469 return { emojis: {}, categories: { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] } }; 470 } 471} 472 473function searchEmojis(query, data) { 474 if (!query) return []; 475 const q = query.toLowerCase(); 476 return Object.entries(data.emojis) 477 .filter(([char, keywords]) => keywords.some(k => k.includes(q))) 478 .map(([char]) => char) 479 .slice(0, 50); 480} 481 482function createEmojiPicker(onSelect) { 483 const overlay = document.createElement('div'); 484 overlay.className = 'emoji-picker-overlay hidden'; 485 overlay.innerHTML = ` 486 <div class="emoji-picker"> 487 <div class="emoji-picker-header"> 488 <h3>pick an emoji</h3> 489 <button class="emoji-picker-close" aria-label="close">✕</button> 490 </div> 491 <input type="text" class="emoji-search" placeholder="search emojis..."> 492 <div class="emoji-categories"> 493 <button class="category-btn active" data-category="frequent">⭐</button> 494 <button class="category-btn" data-category="custom">🐸</button> 495 <button class="category-btn" data-category="people">😊</button> 496 <button class="category-btn" data-category="nature">🌿</button> 497 <button class="category-btn" data-category="food">🍔</button> 498 <button class="category-btn" data-category="activity">⚽</button> 499 <button class="category-btn" data-category="travel">✈️</button> 500 <button class="category-btn" data-category="objects">💡</button> 501 <button class="category-btn" data-category="symbols">💕</button> 502 <button class="category-btn" data-category="flags">🏁</button> 503 </div> 504 <div class="emoji-grid"></div> 505 <div class="bufo-helper hidden"><a href="https://find-bufo.fly.dev/" target="_blank">need help finding a bufo?</a></div> 506 </div> 507 `; 508 509 const picker = overlay.querySelector('.emoji-picker'); 510 const grid = overlay.querySelector('.emoji-grid'); 511 const search = overlay.querySelector('.emoji-search'); 512 const closeBtn = overlay.querySelector('.emoji-picker-close'); 513 const categoryBtns = overlay.querySelectorAll('.category-btn'); 514 const bufoHelper = overlay.querySelector('.bufo-helper'); 515 516 let currentCategory = 'frequent'; 517 let data = null; 518 519 async function renderCategory(cat) { 520 currentCategory = cat; 521 categoryBtns.forEach(b => b.classList.toggle('active', b.dataset.category === cat)); 522 bufoHelper.classList.toggle('hidden', cat !== 'custom'); 523 524 if (cat === 'custom') { 525 grid.classList.add('bufo-grid'); 526 grid.innerHTML = '<div class="loading">loading bufos...</div>'; 527 try { 528 const bufos = await loadBufoList(); 529 grid.innerHTML = bufos.map(name => ` 530 <button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}"> 531 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" loading="lazy" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 532 </button> 533 `).join(''); 534 } catch (e) { 535 grid.innerHTML = '<div class="no-results">failed to load bufos</div>'; 536 } 537 return; 538 } 539 540 grid.classList.remove('bufo-grid'); 541 542 // Load user's frequent emojis for the frequent category 543 if (cat === 'frequent') { 544 grid.innerHTML = '<div class="loading">loading...</div>'; 545 const frequentEmojis = await loadUserFrequentEmojis(); 546 grid.innerHTML = frequentEmojis.map(e => { 547 if (e.startsWith('custom:')) { 548 const name = e.replace('custom:', ''); 549 return `<button class="emoji-btn bufo-btn" data-emoji="${e}" title="${name}"> 550 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 551 </button>`; 552 } 553 return `<button class="emoji-btn" data-emoji="${e}">${e}</button>`; 554 }).join(''); 555 return; 556 } 557 558 if (!data) data = await loadEmojiData(); 559 const emojis = data.categories[cat] || []; 560 grid.innerHTML = emojis.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join(''); 561 } 562 563 function close() { 564 overlay.classList.add('hidden'); 565 search.value = ''; 566 } 567 568 function open() { 569 overlay.classList.remove('hidden'); 570 renderCategory('frequent'); 571 search.focus(); 572 } 573 574 overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); 575 closeBtn.addEventListener('click', close); 576 categoryBtns.forEach(btn => btn.addEventListener('click', () => renderCategory(btn.dataset.category))); 577 578 grid.addEventListener('click', e => { 579 const btn = e.target.closest('.emoji-btn'); 580 if (btn) { 581 onSelect(btn.dataset.emoji); 582 close(); 583 } 584 }); 585 586 search.addEventListener('input', async () => { 587 const q = search.value.trim(); 588 if (!q) { renderCategory(currentCategory); return; } 589 590 // Search both emojis and bufos 591 if (!data) data = await loadEmojiData(); 592 const emojiResults = searchEmojis(q, data); 593 594 // Search bufos by name 595 let bufoResults = []; 596 try { 597 const bufos = await loadBufoList(); 598 const qLower = q.toLowerCase(); 599 bufoResults = bufos.filter(name => name.toLowerCase().includes(qLower)).slice(0, 30); 600 } catch (e) { /* ignore */ } 601 602 grid.classList.remove('bufo-grid'); 603 bufoHelper.classList.add('hidden'); 604 605 if (emojiResults.length === 0 && bufoResults.length === 0) { 606 grid.innerHTML = '<div class="no-results">no emojis found</div>'; 607 return; 608 } 609 610 let html = ''; 611 // Show emoji results first 612 html += emojiResults.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join(''); 613 // Then bufo results 614 html += bufoResults.map(name => ` 615 <button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}"> 616 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 617 </button> 618 `).join(''); 619 620 grid.innerHTML = html; 621 }); 622 623 document.body.appendChild(overlay); 624 return { open, close }; 625} 626 627// Render emoji (handles custom:name format) 628function renderEmoji(emoji) { 629 if (emoji && emoji.startsWith('custom:')) { 630 const name = emoji.slice(7); 631 return `<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" title="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">`; 632 } 633 return emoji || '-'; 634} 635 636function escapeHtml(str) { 637 if (!str) return ''; 638 const div = document.createElement('div'); 639 div.textContent = str; 640 return div.innerHTML; 641} 642 643// Parse markdown links [text](url) and return HTML 644function parseLinks(text) { 645 if (!text) return ''; 646 // First escape HTML, then parse markdown links 647 const escaped = escapeHtml(text); 648 // Match [text](url) pattern 649 return escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { 650 // Validate URL (basic check) 651 if (url.startsWith('http://') || url.startsWith('https://')) { 652 return `<a href="${url}" target="_blank" rel="noopener">${linkText}</a>`; 653 } 654 return match; 655 }); 656} 657 658// Resolve handle to DID 659async function resolveHandle(handle) { 660 const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 661 if (!res.ok) return null; 662 const data = await res.json(); 663 return data.did; 664} 665 666// Resolve DID to handle 667async function resolveDidToHandle(did) { 668 const res = await fetch(`https://plc.directory/${did}`); 669 if (!res.ok) return null; 670 const data = await res.json(); 671 // alsoKnownAs is like ["at://handle"] 672 if (data.alsoKnownAs && data.alsoKnownAs.length > 0) { 673 return data.alsoKnownAs[0].replace('at://', ''); 674 } 675 return null; 676} 677 678// Router 679function getRoute() { 680 const path = window.location.pathname; 681 if (path === '/' || path === '/index.html') return { page: 'home' }; 682 if (path === '/feed' || path === '/feed.html') return { page: 'feed' }; 683 if (path.startsWith('/@')) { 684 const handle = path.slice(2); 685 return { page: 'profile', handle }; 686 } 687 return { page: '404' }; 688} 689 690// Render home page 691async function renderHome() { 692 const main = document.getElementById('main-content'); 693 document.getElementById('page-title').textContent = 'status'; 694 695 if (typeof QuicksliceClient === 'undefined') { 696 main.innerHTML = '<div class="center">failed to load. check console.</div>'; 697 return; 698 } 699 700 try { 701 client = await QuicksliceClient.createQuicksliceClient({ 702 server: CONFIG.server, 703 clientId: CONFIG.clientId, 704 redirectUri: window.location.origin + '/', 705 }); 706 console.log('Client created with server:', CONFIG.server, 'clientId:', CONFIG.clientId); 707 708 if (window.location.search.includes('code=')) { 709 console.log('Got OAuth callback with code, handling...'); 710 try { 711 const result = await client.handleRedirectCallback(); 712 console.log('handleRedirectCallback result:', result); 713 } catch (err) { 714 console.error('handleRedirectCallback error:', err); 715 } 716 window.history.replaceState({}, document.title, '/'); 717 } 718 719 const isAuthed = await client.isAuthenticated(); 720 721 if (!isAuthed) { 722 main.innerHTML = ` 723 <div class="center"> 724 <p>share your status on the atproto network</p> 725 <form id="login-form"> 726 <input type="text" id="handle-input" placeholder="your.handle" required> 727 <button type="submit">log in</button> 728 </form> 729 </div> 730 `; 731 document.getElementById('login-form').addEventListener('submit', async (e) => { 732 e.preventDefault(); 733 const handle = document.getElementById('handle-input').value.trim(); 734 if (handle && client) { 735 await client.loginWithRedirect({ handle }); 736 } 737 }); 738 } else { 739 const user = client.getUser(); 740 if (!user) { 741 // Token might be invalid, log out 742 await client.logout(); 743 window.location.reload(); 744 return; 745 } 746 const handle = await resolveDidToHandle(user.did) || user.did; 747 748 // Load and apply preferences, set up settings/logout buttons 749 const prefs = await loadPreferences(); 750 applyPreferences(prefs); 751 752 // Show settings button and set up modal 753 const settingsBtn = document.getElementById('settings-btn'); 754 settingsBtn.classList.remove('hidden'); 755 const settingsModal = createSettingsModal(); 756 settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); 757 758 // Add logout button to header nav (if not already there) 759 if (!document.getElementById('logout-btn')) { 760 const nav = document.querySelector('header nav'); 761 const logoutBtn = document.createElement('button'); 762 logoutBtn.id = 'logout-btn'; 763 logoutBtn.className = 'nav-btn'; 764 logoutBtn.setAttribute('aria-label', 'log out'); 765 logoutBtn.setAttribute('title', 'log out'); 766 logoutBtn.innerHTML = ` 767 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 768 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 769 <polyline points="16 17 21 12 16 7"></polyline> 770 <line x1="21" y1="12" x2="9" y2="12"></line> 771 </svg> 772 `; 773 logoutBtn.addEventListener('click', async () => { 774 await client.logout(); 775 window.location.href = '/'; 776 }); 777 nav.appendChild(logoutBtn); 778 } 779 780 // Set page title with Bluesky profile link 781 document.getElementById('page-title').innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`; 782 783 // Load user's statuses (full history) 784 const res = await fetch(`${CONFIG.server}/graphql`, { 785 method: 'POST', 786 headers: { 'Content-Type': 'application/json' }, 787 body: JSON.stringify({ 788 query: ` 789 query GetUserStatuses($did: String!) { 790 ioZzstoatzzStatusRecord( 791 first: 100 792 where: { did: { eq: $did } } 793 sortBy: [{ field: "createdAt", direction: DESC }] 794 ) { 795 edges { node { uri did emoji text createdAt expires } } 796 } 797 } 798 `, 799 variables: { did: user.did } 800 }) 801 }); 802 const json = await res.json(); 803 const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); 804 805 let currentHtml = '<span class="big-emoji">-</span>'; 806 let historyHtml = ''; 807 808 if (statuses.length > 0) { 809 const current = statuses[0]; 810 const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; 811 currentHtml = ` 812 <span class="big-emoji">${renderEmoji(current.emoji)}</span> 813 <div class="status-info"> 814 ${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''} 815 <span class="meta">since ${relativeTime(current.createdAt)}${expiresHtml}</span> 816 </div> 817 `; 818 if (statuses.length > 1) { 819 historyHtml = '<section class="history"><h2>history</h2><div id="history-list">'; 820 statuses.slice(1).forEach(s => { 821 // Extract rkey from URI (at://did/collection/rkey) 822 const rkey = s.uri.split('/').pop(); 823 historyHtml += ` 824 <div class="status-item"> 825 <span class="emoji">${renderEmoji(s.emoji)}</span> 826 <div class="content"> 827 <div>${s.text ? `<span class="text">${parseLinks(s.text)}</span>` : ''}</div> 828 <span class="time">${relativeTime(s.createdAt)}</span> 829 </div> 830 <button class="delete-btn" data-rkey="${escapeHtml(rkey)}" title="delete"> 831 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 832 <line x1="18" y1="6" x2="6" y2="18"></line> 833 <line x1="6" y1="6" x2="18" y2="18"></line> 834 </svg> 835 </button> 836 </div> 837 `; 838 }); 839 historyHtml += '</div></section>'; 840 } 841 } 842 843 const currentEmoji = statuses.length > 0 ? statuses[0].emoji : '😊'; 844 845 main.innerHTML = ` 846 <div class="profile-card"> 847 <div class="current-status">${currentHtml}</div> 848 </div> 849 <form id="status-form" class="status-form"> 850 <div class="emoji-input-row"> 851 <button type="button" id="emoji-trigger" class="emoji-trigger"> 852 <span id="selected-emoji">${renderEmoji(currentEmoji)}</span> 853 </button> 854 <input type="hidden" id="emoji-input" value="${escapeHtml(currentEmoji)}"> 855 <input type="text" id="text-input" placeholder="what's happening?" maxlength="256"> 856 </div> 857 <div class="form-actions"> 858 <select id="expires-select"> 859 <option value="">don't clear</option> 860 <option value="30">30 min</option> 861 <option value="60">1 hour</option> 862 <option value="120">2 hours</option> 863 <option value="240">4 hours</option> 864 <option value="480">8 hours</option> 865 <option value="1440">1 day</option> 866 <option value="10080">1 week</option> 867 <option value="custom">custom...</option> 868 </select> 869 <input type="datetime-local" id="custom-datetime" class="custom-datetime hidden"> 870 <button type="submit">set status</button> 871 </div> 872 </form> 873 ${historyHtml} 874 `; 875 876 // Set up emoji picker 877 const emojiInput = document.getElementById('emoji-input'); 878 const selectedEmojiEl = document.getElementById('selected-emoji'); 879 const emojiPicker = createEmojiPicker((emoji) => { 880 emojiInput.value = emoji; 881 selectedEmojiEl.innerHTML = renderEmoji(emoji); 882 }); 883 document.getElementById('emoji-trigger').addEventListener('click', () => emojiPicker.open()); 884 885 // Custom datetime toggle 886 const expiresSelect = document.getElementById('expires-select'); 887 const customDatetime = document.getElementById('custom-datetime'); 888 889 // Helper to format date for datetime-local input (local timezone) 890 function toLocalDatetimeString(date) { 891 const offset = date.getTimezoneOffset(); 892 const local = new Date(date.getTime() - offset * 60 * 1000); 893 return local.toISOString().slice(0, 16); 894 } 895 896 expiresSelect.addEventListener('change', () => { 897 if (expiresSelect.value === 'custom') { 898 customDatetime.classList.remove('hidden'); 899 // Set min to now (prevent past dates) 900 const now = new Date(); 901 customDatetime.min = toLocalDatetimeString(now); 902 // Default to 1 hour from now 903 const defaultTime = new Date(Date.now() + 60 * 60 * 1000); 904 customDatetime.value = toLocalDatetimeString(defaultTime); 905 } else { 906 customDatetime.classList.add('hidden'); 907 } 908 }); 909 910 document.getElementById('status-form').addEventListener('submit', async (e) => { 911 e.preventDefault(); 912 const emoji = document.getElementById('emoji-input').value.trim(); 913 const text = document.getElementById('text-input').value.trim(); 914 const expiresVal = document.getElementById('expires-select').value; 915 const customDt = document.getElementById('custom-datetime').value; 916 917 if (!emoji) return; 918 919 const input = { emoji, createdAt: new Date().toISOString() }; 920 if (text) input.text = text; 921 if (expiresVal === 'custom' && customDt) { 922 input.expires = new Date(customDt).toISOString(); 923 } else if (expiresVal && expiresVal !== 'custom') { 924 input.expires = new Date(Date.now() + parseInt(expiresVal) * 60 * 1000).toISOString(); 925 } 926 927 try { 928 await client.mutate(` 929 mutation CreateStatus($input: CreateIoZzstoatzzStatusRecordInput!) { 930 createIoZzstoatzzStatusRecord(input: $input) { uri } 931 } 932 `, { input }); 933 window.location.reload(); 934 } catch (err) { 935 console.error('Failed to create status:', err); 936 alert('Failed to set status: ' + err.message); 937 } 938 }); 939 940 // Delete buttons 941 document.querySelectorAll('.delete-btn').forEach(btn => { 942 btn.addEventListener('click', async () => { 943 const rkey = btn.dataset.rkey; 944 if (!confirm('Delete this status?')) return; 945 946 try { 947 await client.mutate(` 948 mutation DeleteStatus($rkey: String!) { 949 deleteIoZzstoatzzStatusRecord(rkey: $rkey) { uri } 950 } 951 `, { rkey }); 952 window.location.reload(); 953 } catch (err) { 954 console.error('Failed to delete status:', err); 955 alert('Failed to delete: ' + err.message); 956 } 957 }); 958 }); 959 } 960 } catch (e) { 961 console.error('Failed to init:', e); 962 main.innerHTML = '<div class="center">failed to initialize. check console.</div>'; 963 } 964} 965 966// Render feed page 967let feedCursor = null; 968let feedHasMore = true; 969 970async function renderFeed(append = false) { 971 const main = document.getElementById('main-content'); 972 document.getElementById('page-title').textContent = 'global feed'; 973 974 if (!append) { 975 // Initialize auth UI for header elements 976 await initAuthUI(); 977 main.innerHTML = '<div id="feed-list" class="feed-list"><div class="center">loading...</div></div><div id="load-more" class="center hidden"><button id="load-more-btn">load more</button></div><div id="end-of-feed" class="center hidden"><span class="meta">you\'ve reached the end</span></div>'; 978 } 979 980 const feedList = document.getElementById('feed-list'); 981 982 try { 983 const res = await fetch(`${CONFIG.server}/graphql`, { 984 method: 'POST', 985 headers: { 'Content-Type': 'application/json' }, 986 body: JSON.stringify({ 987 query: ` 988 query GetFeed($after: String) { 989 ioZzstoatzzStatusRecord(first: 20, after: $after, sortBy: [{ field: "createdAt", direction: DESC }]) { 990 edges { node { uri did emoji text createdAt } cursor } 991 pageInfo { hasNextPage endCursor } 992 } 993 } 994 `, 995 variables: { after: append ? feedCursor : null } 996 }) 997 }); 998 999 const json = await res.json(); 1000 const data = json.data.ioZzstoatzzStatusRecord; 1001 const statuses = data.edges.map(e => e.node); 1002 feedCursor = data.pageInfo.endCursor; 1003 feedHasMore = data.pageInfo.hasNextPage; 1004 1005 // Resolve all handles in parallel 1006 const handlePromises = statuses.map(s => resolveDidToHandle(s.did)); 1007 const handles = await Promise.all(handlePromises); 1008 1009 if (!append) { 1010 feedList.innerHTML = ''; 1011 } 1012 1013 statuses.forEach((status, i) => { 1014 const handle = handles[i] || status.did.slice(8, 28); 1015 const div = document.createElement('div'); 1016 div.className = 'status-item'; 1017 div.innerHTML = ` 1018 <span class="emoji">${renderEmoji(status.emoji)}</span> 1019 <div class="content"> 1020 <div> 1021 <a href="/@${handle}" class="author">@${handle}</a> 1022 ${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''} 1023 </div> 1024 <span class="time">${relativeTime(status.createdAt)}</span> 1025 </div> 1026 `; 1027 feedList.appendChild(div); 1028 }); 1029 1030 const loadMore = document.getElementById('load-more'); 1031 const endOfFeed = document.getElementById('end-of-feed'); 1032 if (feedHasMore) { 1033 loadMore.classList.remove('hidden'); 1034 endOfFeed.classList.add('hidden'); 1035 } else { 1036 loadMore.classList.add('hidden'); 1037 endOfFeed.classList.remove('hidden'); 1038 } 1039 1040 // Attach load more handler 1041 const btn = document.getElementById('load-more-btn'); 1042 if (btn && !btn.dataset.bound) { 1043 btn.dataset.bound = 'true'; 1044 btn.addEventListener('click', () => renderFeed(true)); 1045 } 1046 } catch (e) { 1047 console.error('Failed to load feed:', e); 1048 if (!append) { 1049 feedList.innerHTML = '<div class="center">failed to load feed</div>'; 1050 } 1051 } 1052} 1053 1054// Render profile page 1055async function renderProfile(handle) { 1056 const main = document.getElementById('main-content'); 1057 const pageTitle = document.getElementById('page-title'); 1058 1059 // Initialize auth UI for header elements 1060 await initAuthUI(); 1061 1062 pageTitle.innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`; 1063 1064 main.innerHTML = '<div class="center">loading...</div>'; 1065 1066 try { 1067 // Resolve handle to DID 1068 const did = await resolveHandle(handle); 1069 if (!did) { 1070 main.innerHTML = '<div class="center">user not found</div>'; 1071 return; 1072 } 1073 1074 const res = await fetch(`${CONFIG.server}/graphql`, { 1075 method: 'POST', 1076 headers: { 'Content-Type': 'application/json' }, 1077 body: JSON.stringify({ 1078 query: ` 1079 query GetUserStatuses($did: String!) { 1080 ioZzstoatzzStatusRecord(first: 20, where: { did: { eq: $did } }, sortBy: [{ field: "createdAt", direction: DESC }]) { 1081 edges { node { uri did emoji text createdAt expires } } 1082 } 1083 } 1084 `, 1085 variables: { did } 1086 }) 1087 }); 1088 1089 const json = await res.json(); 1090 const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); 1091 1092 if (statuses.length === 0) { 1093 main.innerHTML = '<div class="center">no statuses yet</div>'; 1094 return; 1095 } 1096 1097 const current = statuses[0]; 1098 const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; 1099 let html = ` 1100 <div class="profile-card"> 1101 <div class="current-status"> 1102 <span class="big-emoji">${renderEmoji(current.emoji)}</span> 1103 <div class="status-info"> 1104 ${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''} 1105 <span class="meta">${relativeTime(current.createdAt)}${expiresHtml}</span> 1106 </div> 1107 </div> 1108 </div> 1109 `; 1110 1111 if (statuses.length > 1) { 1112 html += '<section class="history"><h2>history</h2><div class="feed-list">'; 1113 statuses.slice(1).forEach(status => { 1114 html += ` 1115 <div class="status-item"> 1116 <span class="emoji">${renderEmoji(status.emoji)}</span> 1117 <div class="content"> 1118 <div>${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''}</div> 1119 <span class="time">${relativeTime(status.createdAt)}</span> 1120 </div> 1121 </div> 1122 `; 1123 }); 1124 html += '</div></section>'; 1125 } 1126 1127 main.innerHTML = html; 1128 } catch (e) { 1129 console.error('Failed to load profile:', e); 1130 main.innerHTML = '<div class="center">failed to load profile</div>'; 1131 } 1132} 1133 1134// Update nav active state - hide current page icon, show the other 1135function updateNavActive(page) { 1136 const navHome = document.getElementById('nav-home'); 1137 const navFeed = document.getElementById('nav-feed'); 1138 // Hide the nav icon for the current page, show the other 1139 if (navHome) navHome.classList.toggle('hidden', page === 'home'); 1140 if (navFeed) navFeed.classList.toggle('hidden', page === 'feed'); 1141} 1142 1143// Initialize auth state for header (settings, logout) - used by all pages 1144async function initAuthUI() { 1145 if (typeof QuicksliceClient === 'undefined') return; 1146 1147 try { 1148 client = await QuicksliceClient.createQuicksliceClient({ 1149 server: CONFIG.server, 1150 clientId: CONFIG.clientId, 1151 redirectUri: window.location.origin + '/', 1152 }); 1153 1154 const isAuthed = await client.isAuthenticated(); 1155 if (!isAuthed) return; 1156 1157 const user = client.getUser(); 1158 if (!user) return; 1159 1160 // Load and apply preferences 1161 const prefs = await loadPreferences(); 1162 applyPreferences(prefs); 1163 1164 // Show settings button and set up modal 1165 const settingsBtn = document.getElementById('settings-btn'); 1166 settingsBtn.classList.remove('hidden'); 1167 const settingsModal = createSettingsModal(); 1168 settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); 1169 1170 // Add logout button to header nav (if not already there) 1171 if (!document.getElementById('logout-btn')) { 1172 const nav = document.querySelector('header nav'); 1173 const logoutBtn = document.createElement('button'); 1174 logoutBtn.id = 'logout-btn'; 1175 logoutBtn.className = 'nav-btn'; 1176 logoutBtn.setAttribute('aria-label', 'log out'); 1177 logoutBtn.setAttribute('title', 'log out'); 1178 logoutBtn.innerHTML = ` 1179 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1180 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 1181 <polyline points="16 17 21 12 16 7"></polyline> 1182 <line x1="21" y1="12" x2="9" y2="12"></line> 1183 </svg> 1184 `; 1185 logoutBtn.addEventListener('click', async () => { 1186 await client.logout(); 1187 window.location.href = '/'; 1188 }); 1189 nav.appendChild(logoutBtn); 1190 } 1191 1192 return { user, prefs }; 1193 } catch (e) { 1194 console.error('Failed to init auth UI:', e); 1195 return null; 1196 } 1197} 1198 1199// Init 1200document.addEventListener('DOMContentLoaded', () => { 1201 initTheme(); 1202 1203 const themeBtn = document.getElementById('theme-toggle'); 1204 if (themeBtn) { 1205 themeBtn.addEventListener('click', toggleTheme); 1206 } 1207 1208 const route = getRoute(); 1209 updateNavActive(route.page); 1210 1211 if (route.page === 'home') { 1212 renderHome(); 1213 } else if (route.page === 'feed') { 1214 renderFeed(); 1215 } else if (route.page === 'profile') { 1216 renderProfile(route.handle); 1217 } else { 1218 document.getElementById('main-content').innerHTML = '<div class="center">page not found</div>'; 1219 } 1220});