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