slack status without the slack status.zzstoatzz.io/
quickslice
at main 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 formatExpiration(dateStr) { 346 const date = new Date(dateStr); 347 const now = new Date(); 348 const diffMs = date - now; 349 350 // Already expired - show how long ago 351 if (diffMs <= 0) { 352 const agoMs = Math.abs(diffMs); 353 const agoMins = Math.floor(agoMs / 60000); 354 if (agoMins < 1) return 'expired'; 355 if (agoMins < 60) return `expired ${agoMins}m ago`; 356 const agoHours = Math.floor(agoMs / 3600000); 357 if (agoHours < 24) return `expired ${agoHours}h ago`; 358 const agoDays = Math.floor(agoMs / 86400000); 359 return `expired ${agoDays}d ago`; 360 } 361 362 // Future - show when it clears 363 return `clears ${relativeTimeFuture(dateStr)}`; 364} 365 366function relativeTimeFuture(dateStr) { 367 const date = new Date(dateStr); 368 const now = new Date(); 369 const diffMs = date - now; 370 371 if (diffMs <= 0) return 'now'; 372 373 const diffMins = Math.floor(diffMs / 60000); 374 const diffHours = Math.floor(diffMs / 3600000); 375 const diffDays = Math.floor(diffMs / 86400000); 376 377 if (diffMins < 1) return 'in less than a minute'; 378 if (diffMins < 60) return `in ${diffMins}m`; 379 if (diffHours < 24) { 380 const remainingMins = diffMins % 60; 381 return remainingMins === 0 ? `in ${diffHours}h` : `in ${diffHours}h ${remainingMins}m`; 382 } 383 if (diffDays < 7) { 384 const remainingHours = diffHours % 24; 385 return remainingHours === 0 ? `in ${diffDays}d` : `in ${diffDays}d ${remainingHours}h`; 386 } 387 388 // For longer times, show the date 389 const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); 390 if (date.getFullYear() === now.getFullYear()) { 391 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; 392 } 393 return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; 394} 395 396function fullTimestamp(dateStr) { 397 return TimestampFormatter.getFullTimestamp(new Date(dateStr)); 398} 399 400// Emoji picker 401let emojiData = null; 402let bufoList = null; 403let userFrequentEmojis = null; 404const DEFAULT_FREQUENT_EMOJIS = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏', '😴', '🤔', '👀', '💻']; 405 406async function loadUserFrequentEmojis() { 407 if (userFrequentEmojis) return userFrequentEmojis; 408 if (!client) return DEFAULT_FREQUENT_EMOJIS; 409 410 try { 411 const user = client.getUser(); 412 if (!user) return DEFAULT_FREQUENT_EMOJIS; 413 414 // Fetch user's status history to count emoji usage 415 const res = await fetch(`${CONFIG.server}/graphql`, { 416 method: 'POST', 417 headers: { 'Content-Type': 'application/json' }, 418 body: JSON.stringify({ 419 query: ` 420 query GetUserEmojis($did: String!) { 421 ioZzstoatzzStatusRecord( 422 first: 100 423 where: { did: { eq: $did } } 424 ) { 425 edges { node { emoji } } 426 } 427 } 428 `, 429 variables: { did: user.did } 430 }) 431 }); 432 const json = await res.json(); 433 const emojis = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node.emoji) || []; 434 435 if (emojis.length === 0) return DEFAULT_FREQUENT_EMOJIS; 436 437 // Count emoji frequency 438 const counts = {}; 439 emojis.forEach(e => { counts[e] = (counts[e] || 0) + 1; }); 440 441 // Sort by frequency and take top 16 442 const sorted = Object.entries(counts) 443 .sort((a, b) => b[1] - a[1]) 444 .slice(0, 16) 445 .map(([emoji]) => emoji); 446 447 userFrequentEmojis = sorted.length > 0 ? sorted : DEFAULT_FREQUENT_EMOJIS; 448 return userFrequentEmojis; 449 } catch (e) { 450 console.error('Failed to load frequent emojis:', e); 451 return DEFAULT_FREQUENT_EMOJIS; 452 } 453} 454 455async function loadBufoList() { 456 if (bufoList) return bufoList; 457 const res = await fetch('/bufos.json'); 458 if (!res.ok) throw new Error('Failed to load bufos'); 459 bufoList = await res.json(); 460 return bufoList; 461} 462 463async function loadEmojiData() { 464 if (emojiData) return emojiData; 465 try { 466 const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json'); 467 if (!response.ok) throw new Error('Failed to fetch'); 468 const data = await response.json(); 469 470 const emojis = {}; 471 const categories = { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] }; 472 const categoryMap = { 473 'Smileys & Emotion': 'people', 'People & Body': 'people', 'Animals & Nature': 'nature', 474 'Food & Drink': 'food', 'Activities': 'activity', 'Travel & Places': 'travel', 475 'Objects': 'objects', 'Symbols': 'symbols', 'Flags': 'flags' 476 }; 477 478 data.forEach(emoji => { 479 const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join(''); 480 const keywords = [...(emoji.short_names || []), ...(emoji.name ? emoji.name.toLowerCase().split(/[\s_-]+/) : [])]; 481 emojis[char] = keywords; 482 const cat = categoryMap[emoji.category]; 483 if (cat && categories[cat]) categories[cat].push(char); 484 }); 485 486 emojiData = { emojis, categories }; 487 return emojiData; 488 } catch (e) { 489 console.error('Failed to load emoji data:', e); 490 return { emojis: {}, categories: { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] } }; 491 } 492} 493 494function searchEmojis(query, data) { 495 if (!query) return []; 496 const q = query.toLowerCase(); 497 return Object.entries(data.emojis) 498 .filter(([char, keywords]) => keywords.some(k => k.includes(q))) 499 .map(([char]) => char) 500 .slice(0, 50); 501} 502 503function createEmojiPicker(onSelect) { 504 const overlay = document.createElement('div'); 505 overlay.className = 'emoji-picker-overlay hidden'; 506 overlay.innerHTML = ` 507 <div class="emoji-picker"> 508 <div class="emoji-picker-header"> 509 <h3>pick an emoji</h3> 510 <button class="emoji-picker-close" aria-label="close">✕</button> 511 </div> 512 <input type="text" class="emoji-search" placeholder="search emojis..."> 513 <div class="emoji-categories"> 514 <button class="category-btn active" data-category="frequent">⭐</button> 515 <button class="category-btn" data-category="custom">🐸</button> 516 <button class="category-btn" data-category="people">😊</button> 517 <button class="category-btn" data-category="nature">🌿</button> 518 <button class="category-btn" data-category="food">🍔</button> 519 <button class="category-btn" data-category="activity">⚽</button> 520 <button class="category-btn" data-category="travel">✈️</button> 521 <button class="category-btn" data-category="objects">💡</button> 522 <button class="category-btn" data-category="symbols">💕</button> 523 <button class="category-btn" data-category="flags">🏁</button> 524 </div> 525 <div class="emoji-grid"></div> 526 <div class="bufo-helper hidden"><a href="https://find-bufo.fly.dev/" target="_blank">need help finding a bufo?</a></div> 527 </div> 528 `; 529 530 const picker = overlay.querySelector('.emoji-picker'); 531 const grid = overlay.querySelector('.emoji-grid'); 532 const search = overlay.querySelector('.emoji-search'); 533 const closeBtn = overlay.querySelector('.emoji-picker-close'); 534 const categoryBtns = overlay.querySelectorAll('.category-btn'); 535 const bufoHelper = overlay.querySelector('.bufo-helper'); 536 537 let currentCategory = 'frequent'; 538 let data = null; 539 540 async function renderCategory(cat) { 541 currentCategory = cat; 542 categoryBtns.forEach(b => b.classList.toggle('active', b.dataset.category === cat)); 543 bufoHelper.classList.toggle('hidden', cat !== 'custom'); 544 545 if (cat === 'custom') { 546 grid.classList.add('bufo-grid'); 547 grid.innerHTML = '<div class="loading">loading bufos...</div>'; 548 try { 549 const bufos = await loadBufoList(); 550 grid.innerHTML = bufos.map(name => ` 551 <button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}"> 552 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" loading="lazy" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 553 </button> 554 `).join(''); 555 } catch (e) { 556 grid.innerHTML = '<div class="no-results">failed to load bufos</div>'; 557 } 558 return; 559 } 560 561 grid.classList.remove('bufo-grid'); 562 563 // Load user's frequent emojis for the frequent category 564 if (cat === 'frequent') { 565 grid.innerHTML = '<div class="loading">loading...</div>'; 566 const frequentEmojis = await loadUserFrequentEmojis(); 567 grid.innerHTML = frequentEmojis.map(e => { 568 if (e.startsWith('custom:')) { 569 const name = e.replace('custom:', ''); 570 return `<button class="emoji-btn bufo-btn" data-emoji="${e}" title="${name}"> 571 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 572 </button>`; 573 } 574 return `<button class="emoji-btn" data-emoji="${e}">${e}</button>`; 575 }).join(''); 576 return; 577 } 578 579 if (!data) data = await loadEmojiData(); 580 const emojis = data.categories[cat] || []; 581 grid.innerHTML = emojis.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join(''); 582 } 583 584 function close() { 585 overlay.classList.add('hidden'); 586 search.value = ''; 587 } 588 589 function open() { 590 overlay.classList.remove('hidden'); 591 renderCategory('frequent'); 592 search.focus(); 593 } 594 595 overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); 596 closeBtn.addEventListener('click', close); 597 categoryBtns.forEach(btn => btn.addEventListener('click', () => renderCategory(btn.dataset.category))); 598 599 grid.addEventListener('click', e => { 600 const btn = e.target.closest('.emoji-btn'); 601 if (btn) { 602 onSelect(btn.dataset.emoji); 603 close(); 604 } 605 }); 606 607 search.addEventListener('input', async () => { 608 const q = search.value.trim(); 609 if (!q) { renderCategory(currentCategory); return; } 610 611 // Search both emojis and bufos 612 if (!data) data = await loadEmojiData(); 613 const emojiResults = searchEmojis(q, data); 614 615 // Search bufos by name 616 let bufoResults = []; 617 try { 618 const bufos = await loadBufoList(); 619 const qLower = q.toLowerCase(); 620 bufoResults = bufos.filter(name => name.toLowerCase().includes(qLower)).slice(0, 30); 621 } catch (e) { /* ignore */ } 622 623 grid.classList.remove('bufo-grid'); 624 bufoHelper.classList.add('hidden'); 625 626 if (emojiResults.length === 0 && bufoResults.length === 0) { 627 grid.innerHTML = '<div class="no-results">no emojis found</div>'; 628 return; 629 } 630 631 let html = ''; 632 // Show emoji results first 633 html += emojiResults.map(e => `<button class="emoji-btn" data-emoji="${e}">${e}</button>`).join(''); 634 // Then bufo results 635 html += bufoResults.map(name => ` 636 <button class="emoji-btn bufo-btn" data-emoji="custom:${name}" title="${name}"> 637 <img src="https://all-the.bufo.zone/${name}.png" alt="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'"> 638 </button> 639 `).join(''); 640 641 grid.innerHTML = html; 642 }); 643 644 document.body.appendChild(overlay); 645 return { open, close }; 646} 647 648// Render emoji (handles custom:name format) 649function renderEmoji(emoji) { 650 if (emoji && emoji.startsWith('custom:')) { 651 const name = emoji.slice(7); 652 return `<img src="https://all-the.bufo.zone/${name}.png" alt="${name}" title="${name}" onerror="this.src='https://all-the.bufo.zone/${name}.gif'">`; 653 } 654 return emoji || '-'; 655} 656 657function escapeHtml(str) { 658 if (!str) return ''; 659 const div = document.createElement('div'); 660 div.textContent = str; 661 return div.innerHTML; 662} 663 664// Parse markdown links [text](url) and return HTML 665function parseLinks(text) { 666 if (!text) return ''; 667 // First escape HTML, then parse markdown links 668 const escaped = escapeHtml(text); 669 // Match [text](url) pattern 670 return escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { 671 // Validate URL (basic check) 672 if (url.startsWith('http://') || url.startsWith('https://')) { 673 return `<a href="${url}" target="_blank" rel="noopener">${linkText}</a>`; 674 } 675 return match; 676 }); 677} 678 679// Resolve handle to DID 680async function resolveHandle(handle) { 681 const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 682 if (!res.ok) return null; 683 const data = await res.json(); 684 return data.did; 685} 686 687// Resolve DID to handle 688async function resolveDidToHandle(did) { 689 const res = await fetch(`https://plc.directory/${did}`); 690 if (!res.ok) return null; 691 const data = await res.json(); 692 // alsoKnownAs is like ["at://handle"] 693 if (data.alsoKnownAs && data.alsoKnownAs.length > 0) { 694 return data.alsoKnownAs[0].replace('at://', ''); 695 } 696 return null; 697} 698 699// Router 700function getRoute() { 701 const path = window.location.pathname; 702 if (path === '/' || path === '/index.html') return { page: 'home' }; 703 if (path === '/feed' || path === '/feed.html') return { page: 'feed' }; 704 if (path.startsWith('/@')) { 705 const handle = path.slice(2); 706 return { page: 'profile', handle }; 707 } 708 return { page: '404' }; 709} 710 711// Render home page 712async function renderHome() { 713 const main = document.getElementById('main-content'); 714 document.getElementById('page-title').textContent = 'status'; 715 716 if (typeof QuicksliceClient === 'undefined') { 717 main.innerHTML = '<div class="center">failed to load. check console.</div>'; 718 return; 719 } 720 721 try { 722 client = await QuicksliceClient.createQuicksliceClient({ 723 server: CONFIG.server, 724 clientId: CONFIG.clientId, 725 redirectUri: window.location.origin + '/', 726 }); 727 console.log('Client created with server:', CONFIG.server, 'clientId:', CONFIG.clientId); 728 729 if (window.location.search.includes('code=')) { 730 console.log('Got OAuth callback with code, handling...'); 731 try { 732 const result = await client.handleRedirectCallback(); 733 console.log('handleRedirectCallback result:', result); 734 } catch (err) { 735 console.error('handleRedirectCallback error:', err); 736 } 737 window.history.replaceState({}, document.title, '/'); 738 } 739 740 const isAuthed = await client.isAuthenticated(); 741 742 if (!isAuthed) { 743 main.innerHTML = ` 744 <div class="center"> 745 <p>share your status on the atproto network</p> 746 <form id="login-form"> 747 <input type="text" id="handle-input" placeholder="your.handle" required> 748 <button type="submit">log in</button> 749 </form> 750 </div> 751 `; 752 document.getElementById('login-form').addEventListener('submit', async (e) => { 753 e.preventDefault(); 754 const handle = document.getElementById('handle-input').value.trim(); 755 if (handle && client) { 756 await client.loginWithRedirect({ handle }); 757 } 758 }); 759 } else { 760 const user = client.getUser(); 761 if (!user) { 762 // Token might be invalid, log out 763 await client.logout(); 764 window.location.reload(); 765 return; 766 } 767 768 // Load statuses first (includes actorHandle to avoid PLC lookup) 769 const res = await fetch(`${CONFIG.server}/graphql`, { 770 method: 'POST', 771 headers: { 'Content-Type': 'application/json' }, 772 body: JSON.stringify({ 773 query: ` 774 query GetUserStatuses($did: String!) { 775 ioZzstoatzzStatusRecord( 776 first: 100 777 where: { did: { eq: $did } } 778 sortBy: [{ field: "createdAt", direction: DESC }] 779 ) { 780 edges { node { uri did actorHandle emoji text createdAt expires } } 781 } 782 } 783 `, 784 variables: { did: user.did } 785 }) 786 }); 787 const json = await res.json(); 788 const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); 789 790 // Get handle from statuses if available, otherwise fall back to PLC lookup 791 const handle = statuses.length > 0 && statuses[0].actorHandle 792 ? statuses[0].actorHandle 793 : (await resolveDidToHandle(user.did) || user.did); 794 795 // Load and apply preferences, set up settings/logout buttons 796 const prefs = await loadPreferences(); 797 applyPreferences(prefs); 798 799 // Show settings button and set up modal 800 const settingsBtn = document.getElementById('settings-btn'); 801 settingsBtn.classList.remove('hidden'); 802 const settingsModal = createSettingsModal(); 803 settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); 804 805 // Add logout button to header nav (if not already there) 806 if (!document.getElementById('logout-btn')) { 807 const nav = document.querySelector('header nav'); 808 const logoutBtn = document.createElement('button'); 809 logoutBtn.id = 'logout-btn'; 810 logoutBtn.className = 'nav-btn'; 811 logoutBtn.setAttribute('aria-label', 'log out'); 812 logoutBtn.setAttribute('title', 'log out'); 813 logoutBtn.innerHTML = ` 814 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 815 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 816 <polyline points="16 17 21 12 16 7"></polyline> 817 <line x1="21" y1="12" x2="9" y2="12"></line> 818 </svg> 819 `; 820 logoutBtn.addEventListener('click', async () => { 821 await client.logout(); 822 window.location.href = '/'; 823 }); 824 nav.appendChild(logoutBtn); 825 } 826 827 // Set page title with Bluesky profile link 828 document.getElementById('page-title').innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`; 829 830 let currentHtml = '<span class="big-emoji">-</span>'; 831 let historyHtml = ''; 832 833 if (statuses.length > 0) { 834 const current = statuses[0]; 835 const expiresHtml = current.expires ? `${formatExpiration(current.expires)}` : ''; 836 currentHtml = ` 837 <span class="big-emoji">${renderEmoji(current.emoji)}</span> 838 <div class="status-info"> 839 ${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''} 840 <span class="meta">since ${relativeTime(current.createdAt)}${expiresHtml}</span> 841 </div> 842 `; 843 if (statuses.length > 1) { 844 historyHtml = '<section class="history"><h2>history</h2><div id="history-list">'; 845 statuses.slice(1).forEach(s => { 846 // Extract rkey from URI (at://did/collection/rkey) 847 const rkey = s.uri.split('/').pop(); 848 historyHtml += ` 849 <div class="status-item"> 850 <span class="emoji">${renderEmoji(s.emoji)}</span> 851 <div class="content"> 852 <div>${s.text ? `<span class="text">${parseLinks(s.text)}</span>` : ''}</div> 853 <span class="time">${relativeTime(s.createdAt)}</span> 854 </div> 855 <button class="delete-btn" data-rkey="${escapeHtml(rkey)}" title="delete"> 856 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 857 <line x1="18" y1="6" x2="6" y2="18"></line> 858 <line x1="6" y1="6" x2="18" y2="18"></line> 859 </svg> 860 </button> 861 </div> 862 `; 863 }); 864 historyHtml += '</div></section>'; 865 } 866 } 867 868 const currentEmoji = statuses.length > 0 ? statuses[0].emoji : '😊'; 869 870 main.innerHTML = ` 871 <div class="profile-card"> 872 <div class="current-status">${currentHtml}</div> 873 </div> 874 <form id="status-form" class="status-form"> 875 <div class="emoji-input-row"> 876 <button type="button" id="emoji-trigger" class="emoji-trigger"> 877 <span id="selected-emoji">${renderEmoji(currentEmoji)}</span> 878 </button> 879 <input type="hidden" id="emoji-input" value="${escapeHtml(currentEmoji)}"> 880 <input type="text" id="text-input" placeholder="what's happening?" maxlength="256"> 881 </div> 882 <div class="form-actions"> 883 <select id="expires-select"> 884 <option value="">don't clear</option> 885 <option value="30">30 min</option> 886 <option value="60">1 hour</option> 887 <option value="120">2 hours</option> 888 <option value="240">4 hours</option> 889 <option value="480">8 hours</option> 890 <option value="1440">1 day</option> 891 <option value="10080">1 week</option> 892 <option value="custom">custom...</option> 893 </select> 894 <input type="datetime-local" id="custom-datetime" class="custom-datetime hidden"> 895 <button type="submit">set status</button> 896 </div> 897 </form> 898 ${historyHtml} 899 `; 900 901 // Set up emoji picker 902 const emojiInput = document.getElementById('emoji-input'); 903 const selectedEmojiEl = document.getElementById('selected-emoji'); 904 const emojiPicker = createEmojiPicker((emoji) => { 905 emojiInput.value = emoji; 906 selectedEmojiEl.innerHTML = renderEmoji(emoji); 907 }); 908 document.getElementById('emoji-trigger').addEventListener('click', () => emojiPicker.open()); 909 910 // Custom datetime toggle 911 const expiresSelect = document.getElementById('expires-select'); 912 const customDatetime = document.getElementById('custom-datetime'); 913 914 // Helper to format date for datetime-local input (local timezone) 915 function toLocalDatetimeString(date) { 916 const offset = date.getTimezoneOffset(); 917 const local = new Date(date.getTime() - offset * 60 * 1000); 918 return local.toISOString().slice(0, 16); 919 } 920 921 expiresSelect.addEventListener('change', () => { 922 if (expiresSelect.value === 'custom') { 923 customDatetime.classList.remove('hidden'); 924 // Set min to now (prevent past dates) 925 const now = new Date(); 926 customDatetime.min = toLocalDatetimeString(now); 927 // Default to 1 hour from now 928 const defaultTime = new Date(Date.now() + 60 * 60 * 1000); 929 customDatetime.value = toLocalDatetimeString(defaultTime); 930 } else { 931 customDatetime.classList.add('hidden'); 932 } 933 }); 934 935 document.getElementById('status-form').addEventListener('submit', async (e) => { 936 e.preventDefault(); 937 const emoji = document.getElementById('emoji-input').value.trim(); 938 const text = document.getElementById('text-input').value.trim(); 939 const expiresVal = document.getElementById('expires-select').value; 940 const customDt = document.getElementById('custom-datetime').value; 941 942 if (!emoji) return; 943 944 const input = { emoji, createdAt: new Date().toISOString() }; 945 if (text) input.text = text; 946 if (expiresVal === 'custom' && customDt) { 947 input.expires = new Date(customDt).toISOString(); 948 } else if (expiresVal && expiresVal !== 'custom') { 949 input.expires = new Date(Date.now() + parseInt(expiresVal) * 60 * 1000).toISOString(); 950 } 951 952 try { 953 await client.mutate(` 954 mutation CreateStatus($input: CreateIoZzstoatzzStatusRecordInput!) { 955 createIoZzstoatzzStatusRecord(input: $input) { uri } 956 } 957 `, { input }); 958 window.location.reload(); 959 } catch (err) { 960 console.error('Failed to create status:', err); 961 alert('Failed to set status: ' + err.message); 962 } 963 }); 964 965 // Delete buttons 966 document.querySelectorAll('.delete-btn').forEach(btn => { 967 btn.addEventListener('click', async () => { 968 const rkey = btn.dataset.rkey; 969 if (!confirm('Delete this status?')) return; 970 971 try { 972 await client.mutate(` 973 mutation DeleteStatus($rkey: String!) { 974 deleteIoZzstoatzzStatusRecord(rkey: $rkey) { uri } 975 } 976 `, { rkey }); 977 window.location.reload(); 978 } catch (err) { 979 console.error('Failed to delete status:', err); 980 alert('Failed to delete: ' + err.message); 981 } 982 }); 983 }); 984 } 985 } catch (e) { 986 console.error('Failed to init:', e); 987 main.innerHTML = '<div class="center">failed to initialize. check console.</div>'; 988 } 989} 990 991// Render feed page 992let feedCursor = null; 993let feedHasMore = true; 994 995async function renderFeed(append = false) { 996 const main = document.getElementById('main-content'); 997 document.getElementById('page-title').textContent = 'global feed'; 998 999 if (!append) { 1000 // Initialize auth UI for header elements 1001 await initAuthUI(); 1002 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>'; 1003 } 1004 1005 const feedList = document.getElementById('feed-list'); 1006 1007 try { 1008 const res = await fetch(`${CONFIG.server}/graphql`, { 1009 method: 'POST', 1010 headers: { 'Content-Type': 'application/json' }, 1011 body: JSON.stringify({ 1012 query: ` 1013 query GetFeed($after: String) { 1014 ioZzstoatzzStatusRecord(first: 20, after: $after, sortBy: [{ field: "createdAt", direction: DESC }]) { 1015 edges { node { uri did actorHandle emoji text createdAt } cursor } 1016 pageInfo { hasNextPage endCursor } 1017 } 1018 } 1019 `, 1020 variables: { after: append ? feedCursor : null } 1021 }) 1022 }); 1023 1024 const json = await res.json(); 1025 const data = json.data.ioZzstoatzzStatusRecord; 1026 const statuses = data.edges.map(e => e.node); 1027 feedCursor = data.pageInfo.endCursor; 1028 feedHasMore = data.pageInfo.hasNextPage; 1029 1030 if (!append) { 1031 feedList.innerHTML = ''; 1032 } 1033 1034 statuses.forEach((status) => { 1035 const handle = status.actorHandle || status.did.slice(8, 28); 1036 const div = document.createElement('div'); 1037 div.className = 'status-item'; 1038 div.innerHTML = ` 1039 <span class="emoji">${renderEmoji(status.emoji)}</span> 1040 <div class="content"> 1041 <div> 1042 <a href="/@${handle}" class="author">@${handle}</a> 1043 ${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''} 1044 </div> 1045 <span class="time">${relativeTime(status.createdAt)}</span> 1046 </div> 1047 `; 1048 feedList.appendChild(div); 1049 }); 1050 1051 const loadMore = document.getElementById('load-more'); 1052 const endOfFeed = document.getElementById('end-of-feed'); 1053 if (feedHasMore) { 1054 loadMore.classList.remove('hidden'); 1055 endOfFeed.classList.add('hidden'); 1056 } else { 1057 loadMore.classList.add('hidden'); 1058 endOfFeed.classList.remove('hidden'); 1059 } 1060 1061 // Attach load more handler 1062 const btn = document.getElementById('load-more-btn'); 1063 if (btn && !btn.dataset.bound) { 1064 btn.dataset.bound = 'true'; 1065 btn.addEventListener('click', () => renderFeed(true)); 1066 } 1067 } catch (e) { 1068 console.error('Failed to load feed:', e); 1069 if (!append) { 1070 feedList.innerHTML = '<div class="center">failed to load feed</div>'; 1071 } 1072 } 1073} 1074 1075// Render profile page 1076async function renderProfile(handle) { 1077 const main = document.getElementById('main-content'); 1078 const pageTitle = document.getElementById('page-title'); 1079 1080 // Initialize auth UI for header elements 1081 await initAuthUI(); 1082 1083 pageTitle.innerHTML = `<a href="https://bsky.app/profile/${handle}" target="_blank">@${handle}</a>`; 1084 1085 main.innerHTML = '<div class="center">loading...</div>'; 1086 1087 try { 1088 // Resolve handle to DID 1089 const did = await resolveHandle(handle); 1090 if (!did) { 1091 main.innerHTML = '<div class="center">user not found</div>'; 1092 return; 1093 } 1094 1095 const res = await fetch(`${CONFIG.server}/graphql`, { 1096 method: 'POST', 1097 headers: { 'Content-Type': 'application/json' }, 1098 body: JSON.stringify({ 1099 query: ` 1100 query GetUserStatuses($did: String!) { 1101 ioZzstoatzzStatusRecord(first: 20, where: { did: { eq: $did } }, sortBy: [{ field: "createdAt", direction: DESC }]) { 1102 edges { node { uri did emoji text createdAt expires } } 1103 } 1104 } 1105 `, 1106 variables: { did } 1107 }) 1108 }); 1109 1110 const json = await res.json(); 1111 const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); 1112 1113 if (statuses.length === 0) { 1114 main.innerHTML = '<div class="center">no statuses yet</div>'; 1115 return; 1116 } 1117 1118 const current = statuses[0]; 1119 const expiresHtml = current.expires ? `${formatExpiration(current.expires)}` : ''; 1120 let html = ` 1121 <div class="profile-card"> 1122 <div class="current-status"> 1123 <span class="big-emoji">${renderEmoji(current.emoji)}</span> 1124 <div class="status-info"> 1125 ${current.text ? `<span id="current-text">${parseLinks(current.text)}</span>` : ''} 1126 <span class="meta">${relativeTime(current.createdAt)}${expiresHtml}</span> 1127 </div> 1128 </div> 1129 </div> 1130 `; 1131 1132 if (statuses.length > 1) { 1133 html += '<section class="history"><h2>history</h2><div class="feed-list">'; 1134 statuses.slice(1).forEach(status => { 1135 html += ` 1136 <div class="status-item"> 1137 <span class="emoji">${renderEmoji(status.emoji)}</span> 1138 <div class="content"> 1139 <div>${status.text ? `<span class="text">${parseLinks(status.text)}</span>` : ''}</div> 1140 <span class="time">${relativeTime(status.createdAt)}</span> 1141 </div> 1142 </div> 1143 `; 1144 }); 1145 html += '</div></section>'; 1146 } 1147 1148 main.innerHTML = html; 1149 } catch (e) { 1150 console.error('Failed to load profile:', e); 1151 main.innerHTML = '<div class="center">failed to load profile</div>'; 1152 } 1153} 1154 1155// Update nav active state - hide current page icon, show the other 1156function updateNavActive(page) { 1157 const navHome = document.getElementById('nav-home'); 1158 const navFeed = document.getElementById('nav-feed'); 1159 // Hide the nav icon for the current page, show the other 1160 if (navHome) navHome.classList.toggle('hidden', page === 'home'); 1161 if (navFeed) navFeed.classList.toggle('hidden', page === 'feed'); 1162} 1163 1164// Initialize auth state for header (settings, logout) - used by all pages 1165async function initAuthUI() { 1166 if (typeof QuicksliceClient === 'undefined') return; 1167 1168 try { 1169 client = await QuicksliceClient.createQuicksliceClient({ 1170 server: CONFIG.server, 1171 clientId: CONFIG.clientId, 1172 redirectUri: window.location.origin + '/', 1173 }); 1174 1175 const isAuthed = await client.isAuthenticated(); 1176 if (!isAuthed) return; 1177 1178 const user = client.getUser(); 1179 if (!user) return; 1180 1181 // Load and apply preferences 1182 const prefs = await loadPreferences(); 1183 applyPreferences(prefs); 1184 1185 // Show settings button and set up modal 1186 const settingsBtn = document.getElementById('settings-btn'); 1187 settingsBtn.classList.remove('hidden'); 1188 const settingsModal = createSettingsModal(); 1189 settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); 1190 1191 // Add logout button to header nav (if not already there) 1192 if (!document.getElementById('logout-btn')) { 1193 const nav = document.querySelector('header nav'); 1194 const logoutBtn = document.createElement('button'); 1195 logoutBtn.id = 'logout-btn'; 1196 logoutBtn.className = 'nav-btn'; 1197 logoutBtn.setAttribute('aria-label', 'log out'); 1198 logoutBtn.setAttribute('title', 'log out'); 1199 logoutBtn.innerHTML = ` 1200 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1201 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 1202 <polyline points="16 17 21 12 16 7"></polyline> 1203 <line x1="21" y1="12" x2="9" y2="12"></line> 1204 </svg> 1205 `; 1206 logoutBtn.addEventListener('click', async () => { 1207 await client.logout(); 1208 window.location.href = '/'; 1209 }); 1210 nav.appendChild(logoutBtn); 1211 } 1212 1213 return { user, prefs }; 1214 } catch (e) { 1215 console.error('Failed to init auth UI:', e); 1216 return null; 1217 } 1218} 1219 1220// Init 1221document.addEventListener('DOMContentLoaded', () => { 1222 initTheme(); 1223 1224 const themeBtn = document.getElementById('theme-toggle'); 1225 if (themeBtn) { 1226 themeBtn.addEventListener('click', toggleTheme); 1227 } 1228 1229 const route = getRoute(); 1230 updateNavActive(route.page); 1231 1232 if (route.page === 'home') { 1233 renderHome(); 1234 } else if (route.page === 'feed') { 1235 renderFeed(); 1236 } else if (route.page === 'profile') { 1237 renderProfile(route.handle); 1238 } else { 1239 document.getElementById('main-content').innerHTML = '<div class="center">page not found</div>'; 1240 } 1241});