slack status without the slack
status.zzstoatzz.io/
quickslice
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});