From d1726a955931e4e9e567ed523188ebde2ac1730f Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 13 Dec 2025 17:26:34 -0600 Subject: [PATCH 1/5] initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 5 + fly.toml | 33 + lexicons.zip | Bin 0 -> 1140 bytes lexicons/preferences.json | 30 + lexicons/status.json | 38 + site/Caddyfile | 10 + site/Dockerfile | 9 + site/app.js | 1217 ++++++++++++++++++++++++++++ site/bufos.json | 1614 +++++++++++++++++++++++++++++++++++++ site/favicon.svg | 8 + site/fly.toml | 17 + site/index.html | 49 ++ site/styles.css | 791 ++++++++++++++++++ 13 files changed, 3821 insertions(+) create mode 100644 .gitignore create mode 100644 fly.toml create mode 100644 lexicons.zip create mode 100644 lexicons/preferences.json create mode 100644 lexicons/status.json create mode 100644 site/Caddyfile create mode 100644 site/Dockerfile create mode 100644 site/app.js create mode 100644 site/bufos.json create mode 100644 site/favicon.svg create mode 100644 site/fly.toml create mode 100644 site/index.html create mode 100644 site/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ec39d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# local dev files +site/Caddyfile.dev + +# notes +oauth-experience.md diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..9890b73 --- /dev/null +++ b/fly.toml @@ -0,0 +1,33 @@ +# fly.toml app configuration file generated for zzstoatzz-quickslice-status on 2025-12-13T16:42:55-06:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'zzstoatzz-quickslice-status' +primary_region = 'ewr' + +[build] + image = 'ghcr.io/bigmoves/quickslice:latest' + +[env] + DATABASE_URL = 'sqlite:/data/quickslice.db' + HOST = '0.0.0.0' + PORT = '8080' + EXTERNAL_BASE_URL = 'https://zzstoatzz-quickslice-status.fly.dev' + +[[mounts]] + source = 'quickslice_data' + destination = '/data' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 1 + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 + memory_mb = 1024 diff --git a/lexicons.zip b/lexicons.zip new file mode 100644 index 0000000000000000000000000000000000000000..67572131e4b630be1ad91c205a602973318525fa GIT binary patch literal 1140 zcmWIWW@h1H00EK4-e@obN^mmBFyy3GWG3h573+tFa56AIe{7ri28c^5xEUB(zA`c} zu!sN^2LO!#(Hsnuih83zzsOzY2b6ls3^Wqih~kpOlG0+mtm6DUuxXP|*=B+;nrT~u z4*DH7;Mx0Ld(9jNsi_Vd4(H9;p-}kX0Ary?^!l$6vnK3V^!iG@`pHw%czGuXOh0zM zZrl8~d1?$vt+JxK1dDgfVe?sP&iZoAd)e%hweu~f-Pv^}X<@FW(&{6vpZD}`w+)&! zOZL-@>ukCAufX7l8lx%wL)$eM4uAGJy1$9blyag$~=Y3O(z`!wyS z_aW0T=`B7l_*VTsVfOLD%q^ukD_2h1W*D_dTSs!)4xNt@)0sAkWN^E_zP{A$O;|2# z`i#(lFXP_1W{|rJ|;t6cDyY> zDCnH|sdag2@r%pDW=Vy{>{Fj}rEl&>c3ZoP{h!2dX-T$;Ew#6ueCR%(g!Cx}g(W{L zr!vO#?NBrC)Au`d_kY%Z-5)EDsa<7xf9c?je-8Jb+_<&xp|wvNKO$~G(aXNdZ>R|7` zcf{F8J-6)g5|zyt%3ju&Ptx7Jalv%kB{NS}fBrZ_j{De-Mn~`TpVMroY`ktdHD-6# zEGDbQ%OR{H6&#`O9UIxT6+fh`b!0V?U%70Z@U3~)uL|ENF0%1>{@P^Xz3&Fc(iaQv zF}Tg(7JKAq&A}9f?|Yd$)Acp?mfKcb5vcQ>ptOXgTmPKJ{z*m&4(oF#Gfs|jo)Gta z#pR$M(mXzHe8DAtcYcSNuU**OUBVq2ym5AK1>4eh*N*t{yibtM>HI#$v-sWPQ;~a& zUTZ7P5SnYIy!+4E|Br0){+(&oEh$J{(0}5@qMXQEQ75w=epg+5w$)IfB%9}kv-{`y zHa9Ch6Qnx6Kl*1V&Kv~>J0p`EGp>9k0nK3o3~wDlOr%`K3dv 0) { + userPreferences = edges[0].node; + return userPreferences; + } + return DEFAULT_PREFERENCES; + } catch (e) { + console.error('Failed to load preferences:', e); + return DEFAULT_PREFERENCES; + } +} + +// Save preferences to server +async function savePreferences(prefs) { + if (!client) return; + + try { + const user = client.getUser(); + if (!user) return; + + // First, delete any existing preferences records for this user + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetExistingPrefs($did: String!) { + ioZzstoatzzStatusPreferences(where: { did: { eq: $did } }, first: 50) { + edges { node { uri } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const existing = json.data?.ioZzstoatzzStatusPreferences?.edges || []; + + // Delete all existing preference records + for (const edge of existing) { + const rkey = edge.node.uri.split('/').pop(); + try { + await client.mutate(` + mutation DeletePref($rkey: String!) { + deleteIoZzstoatzzStatusPreferences(rkey: $rkey) { uri } + } + `, { rkey }); + } catch (e) { + console.warn('Failed to delete old pref:', e); + } + } + + // Create new preferences record + await client.mutate(` + mutation SavePreferences($input: CreateIoZzstoatzzStatusPreferencesInput!) { + createIoZzstoatzzStatusPreferences(input: $input) { uri } + } + `, { + input: { + accentColor: prefs.accentColor, + font: prefs.font, + theme: prefs.theme + } + }); + + userPreferences = prefs; + applyPreferences(prefs); + } catch (e) { + console.error('Failed to save preferences:', e); + alert('Failed to save preferences: ' + e.message); + } +} + +// Create settings modal +function createSettingsModal() { + const overlay = document.createElement('div'); + overlay.className = 'settings-overlay hidden'; + overlay.innerHTML = ` +
+
+

settings

+ +
+
+
+ +
+ ${ACCENT_COLORS.map(c => ` + + `).join('')} + +
+
+
+ + +
+
+ + +
+
+ +
+ `; + + const modal = overlay.querySelector('.settings-modal'); + const closeBtn = overlay.querySelector('.settings-close'); + const colorBtns = overlay.querySelectorAll('.color-btn'); + const customColor = overlay.querySelector('#custom-color'); + const fontSelect = overlay.querySelector('#font-select'); + const themeSelect = overlay.querySelector('#theme-select'); + const saveBtn = overlay.querySelector('#save-settings'); + + let currentPrefs = { ...DEFAULT_PREFERENCES }; + + function updateColorSelection(color) { + colorBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.color === color)); + customColor.value = color; + currentPrefs.accentColor = color; + } + + function open(prefs) { + currentPrefs = { ...DEFAULT_PREFERENCES, ...prefs }; + updateColorSelection(currentPrefs.accentColor); + fontSelect.value = currentPrefs.font; + themeSelect.value = currentPrefs.theme; + overlay.classList.remove('hidden'); + } + + function close() { + overlay.classList.add('hidden'); + } + + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + closeBtn.addEventListener('click', close); + + colorBtns.forEach(btn => { + btn.addEventListener('click', () => updateColorSelection(btn.dataset.color)); + }); + + customColor.addEventListener('input', () => { + updateColorSelection(customColor.value); + }); + + fontSelect.addEventListener('change', () => { + currentPrefs.font = fontSelect.value; + }); + + themeSelect.addEventListener('change', () => { + currentPrefs.theme = themeSelect.value; + }); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = 'saving...'; + await savePreferences(currentPrefs); + saveBtn.disabled = false; + saveBtn.textContent = 'save'; + close(); + }); + + document.body.appendChild(overlay); + return { open, close }; +} + +// Theme (fallback for non-logged-in users) +function initTheme() { + const saved = localStorage.getItem('theme') || 'dark'; + document.documentElement.setAttribute('data-theme', saved); +} + +function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + + // If logged in, also update preferences + if (userPreferences) { + userPreferences.theme = next; + savePreferences(userPreferences); + } +} + +// Timestamp formatting (ported from original status app) +const TimestampFormatter = { + formatRelative(date, now = new Date()) { + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMs < 30000) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) { + const remainingMins = diffMins % 60; + return remainingMins === 0 ? `${diffHours}h ago` : `${diffHours}h ${remainingMins}m ago`; + } + if (diffDays < 7) { + const remainingHours = diffHours % 24; + return remainingHours === 0 ? `${diffDays}d ago` : `${diffDays}d ${remainingHours}h ago`; + } + + const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; + }, + + formatCompact(date, now = new Date()) { + const diffMs = now - date; + const diffDays = Math.floor(diffMs / 86400000); + + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return 'yesterday, ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + if (diffDays < 7) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); + const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + return `${dayName}, ${time}`; + } + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + }, + + getFullTimestamp(date) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); + const monthDay = date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); + const tzAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop(); + return `${dayName}, ${monthDay} at ${time} ${tzAbbr}`; + } +}; + +function relativeTime(dateStr, format = 'relative') { + const date = new Date(dateStr); + return format === 'compact' + ? TimestampFormatter.formatCompact(date) + : TimestampFormatter.formatRelative(date); +} + +function relativeTimeFuture(dateStr) { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date - now; + + if (diffMs <= 0) return 'now'; + + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'in less than a minute'; + if (diffMins < 60) return `in ${diffMins}m`; + if (diffHours < 24) { + const remainingMins = diffMins % 60; + return remainingMins === 0 ? `in ${diffHours}h` : `in ${diffHours}h ${remainingMins}m`; + } + if (diffDays < 7) { + const remainingHours = diffHours % 24; + return remainingHours === 0 ? `in ${diffDays}d` : `in ${diffDays}d ${remainingHours}h`; + } + + // For longer times, show the date + const timeStr = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }).toLowerCase(); + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + timeStr; + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ', ' + timeStr; +} + +function fullTimestamp(dateStr) { + return TimestampFormatter.getFullTimestamp(new Date(dateStr)); +} + +// Emoji picker +let emojiData = null; +let bufoList = null; +let userFrequentEmojis = null; +const DEFAULT_FREQUENT_EMOJIS = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏', '😴', '🤔', '👀', '💻']; + +async function loadUserFrequentEmojis() { + if (userFrequentEmojis) return userFrequentEmojis; + if (!client) return DEFAULT_FREQUENT_EMOJIS; + + try { + const user = client.getUser(); + if (!user) return DEFAULT_FREQUENT_EMOJIS; + + // Fetch user's status history to count emoji usage + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserEmojis($did: String!) { + ioZzstoatzzStatusRecord( + first: 100 + where: { did: { eq: $did } } + ) { + edges { node { emoji } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const emojis = json.data?.ioZzstoatzzStatusRecord?.edges?.map(e => e.node.emoji) || []; + + if (emojis.length === 0) return DEFAULT_FREQUENT_EMOJIS; + + // Count emoji frequency + const counts = {}; + emojis.forEach(e => { counts[e] = (counts[e] || 0) + 1; }); + + // Sort by frequency and take top 16 + const sorted = Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 16) + .map(([emoji]) => emoji); + + userFrequentEmojis = sorted.length > 0 ? sorted : DEFAULT_FREQUENT_EMOJIS; + return userFrequentEmojis; + } catch (e) { + console.error('Failed to load frequent emojis:', e); + return DEFAULT_FREQUENT_EMOJIS; + } +} + +async function loadBufoList() { + if (bufoList) return bufoList; + const res = await fetch('/bufos.json'); + if (!res.ok) throw new Error('Failed to load bufos'); + bufoList = await res.json(); + return bufoList; +} + +async function loadEmojiData() { + if (emojiData) return emojiData; + try { + const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json'); + if (!response.ok) throw new Error('Failed to fetch'); + const data = await response.json(); + + const emojis = {}; + const categories = { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] }; + const categoryMap = { + 'Smileys & Emotion': 'people', 'People & Body': 'people', 'Animals & Nature': 'nature', + 'Food & Drink': 'food', 'Activities': 'activity', 'Travel & Places': 'travel', + 'Objects': 'objects', 'Symbols': 'symbols', 'Flags': 'flags' + }; + + data.forEach(emoji => { + const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join(''); + const keywords = [...(emoji.short_names || []), ...(emoji.name ? emoji.name.toLowerCase().split(/[\s_-]+/) : [])]; + emojis[char] = keywords; + const cat = categoryMap[emoji.category]; + if (cat && categories[cat]) categories[cat].push(char); + }); + + emojiData = { emojis, categories }; + return emojiData; + } catch (e) { + console.error('Failed to load emoji data:', e); + return { emojis: {}, categories: { frequent: DEFAULT_FREQUENT_EMOJIS, people: [], nature: [], food: [], activity: [], travel: [], objects: [], symbols: [], flags: [] } }; + } +} + +function searchEmojis(query, data) { + if (!query) return []; + const q = query.toLowerCase(); + return Object.entries(data.emojis) + .filter(([char, keywords]) => keywords.some(k => k.includes(q))) + .map(([char]) => char) + .slice(0, 50); +} + +function createEmojiPicker(onSelect) { + const overlay = document.createElement('div'); + overlay.className = 'emoji-picker-overlay hidden'; + overlay.innerHTML = ` +
+
+

pick an emoji

+ +
+ +
+ + + + + + + + + + +
+
+ +
+ `; + + const picker = overlay.querySelector('.emoji-picker'); + const grid = overlay.querySelector('.emoji-grid'); + const search = overlay.querySelector('.emoji-search'); + const closeBtn = overlay.querySelector('.emoji-picker-close'); + const categoryBtns = overlay.querySelectorAll('.category-btn'); + const bufoHelper = overlay.querySelector('.bufo-helper'); + + let currentCategory = 'frequent'; + let data = null; + + async function renderCategory(cat) { + currentCategory = cat; + categoryBtns.forEach(b => b.classList.toggle('active', b.dataset.category === cat)); + bufoHelper.classList.toggle('hidden', cat !== 'custom'); + + if (cat === 'custom') { + grid.classList.add('bufo-grid'); + grid.innerHTML = '
loading bufos...
'; + try { + const bufos = await loadBufoList(); + grid.innerHTML = bufos.map(name => ` + + `).join(''); + } catch (e) { + grid.innerHTML = '
failed to load bufos
'; + } + return; + } + + grid.classList.remove('bufo-grid'); + + // Load user's frequent emojis for the frequent category + if (cat === 'frequent') { + grid.innerHTML = '
loading...
'; + const frequentEmojis = await loadUserFrequentEmojis(); + grid.innerHTML = frequentEmojis.map(e => { + if (e.startsWith('custom:')) { + const name = e.replace('custom:', ''); + return ``; + } + return ``; + }).join(''); + return; + } + + if (!data) data = await loadEmojiData(); + const emojis = data.categories[cat] || []; + grid.innerHTML = emojis.map(e => ``).join(''); + } + + function close() { + overlay.classList.add('hidden'); + search.value = ''; + } + + function open() { + overlay.classList.remove('hidden'); + renderCategory('frequent'); + search.focus(); + } + + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + closeBtn.addEventListener('click', close); + categoryBtns.forEach(btn => btn.addEventListener('click', () => renderCategory(btn.dataset.category))); + + grid.addEventListener('click', e => { + const btn = e.target.closest('.emoji-btn'); + if (btn) { + onSelect(btn.dataset.emoji); + close(); + } + }); + + search.addEventListener('input', async () => { + const q = search.value.trim(); + if (!q) { renderCategory(currentCategory); return; } + + // Search both emojis and bufos + if (!data) data = await loadEmojiData(); + const emojiResults = searchEmojis(q, data); + + // Search bufos by name + let bufoResults = []; + try { + const bufos = await loadBufoList(); + const qLower = q.toLowerCase(); + bufoResults = bufos.filter(name => name.toLowerCase().includes(qLower)).slice(0, 30); + } catch (e) { /* ignore */ } + + grid.classList.remove('bufo-grid'); + bufoHelper.classList.add('hidden'); + + if (emojiResults.length === 0 && bufoResults.length === 0) { + grid.innerHTML = '
no emojis found
'; + return; + } + + let html = ''; + // Show emoji results first + html += emojiResults.map(e => ``).join(''); + // Then bufo results + html += bufoResults.map(name => ` + + `).join(''); + + grid.innerHTML = html; + }); + + document.body.appendChild(overlay); + return { open, close }; +} + +// Render emoji (handles custom:name format) +function renderEmoji(emoji) { + if (emoji && emoji.startsWith('custom:')) { + const name = emoji.slice(7); + return `${name}`; + } + return emoji || '-'; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Parse markdown links [text](url) and return HTML +function parseLinks(text) { + if (!text) return ''; + // First escape HTML, then parse markdown links + const escaped = escapeHtml(text); + // Match [text](url) pattern + return escaped.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { + // Validate URL (basic check) + if (url.startsWith('http://') || url.startsWith('https://')) { + return `${linkText}`; + } + return match; + }); +} + +// Resolve handle to DID +async function resolveHandle(handle) { + const res = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); + if (!res.ok) return null; + const data = await res.json(); + return data.did; +} + +// Resolve DID to handle +async function resolveDidToHandle(did) { + const res = await fetch(`https://plc.directory/${did}`); + if (!res.ok) return null; + const data = await res.json(); + // alsoKnownAs is like ["at://handle"] + if (data.alsoKnownAs && data.alsoKnownAs.length > 0) { + return data.alsoKnownAs[0].replace('at://', ''); + } + return null; +} + +// Router +function getRoute() { + const path = window.location.pathname; + if (path === '/' || path === '/index.html') return { page: 'home' }; + if (path === '/feed' || path === '/feed.html') return { page: 'feed' }; + if (path.startsWith('/@')) { + const handle = path.slice(2); + return { page: 'profile', handle }; + } + return { page: '404' }; +} + +// Render home page +async function renderHome() { + const main = document.getElementById('main-content'); + document.getElementById('page-title').textContent = 'status'; + + if (typeof QuicksliceClient === 'undefined') { + main.innerHTML = '
failed to load. check console.
'; + return; + } + + try { + client = await QuicksliceClient.createQuicksliceClient({ + server: CONFIG.server, + clientId: CONFIG.clientId, + redirectUri: window.location.origin + '/', + }); + console.log('Client created with server:', CONFIG.server, 'clientId:', CONFIG.clientId); + + if (window.location.search.includes('code=')) { + console.log('Got OAuth callback with code, handling...'); + try { + const result = await client.handleRedirectCallback(); + console.log('handleRedirectCallback result:', result); + } catch (err) { + console.error('handleRedirectCallback error:', err); + } + window.history.replaceState({}, document.title, '/'); + } + + const isAuthed = await client.isAuthenticated(); + + if (!isAuthed) { + main.innerHTML = ` +
+

share your status on the atproto network

+
+ + +
+
+ `; + document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const handle = document.getElementById('handle-input').value.trim(); + if (handle && client) { + await client.loginWithRedirect({ handle }); + } + }); + } else { + const user = client.getUser(); + if (!user) { + // Token might be invalid, log out + await client.logout(); + window.location.reload(); + return; + } + const handle = await resolveDidToHandle(user.did) || user.did; + + // Load and apply preferences, set up settings/logout buttons + const prefs = await loadPreferences(); + applyPreferences(prefs); + + // Show settings button and set up modal + const settingsBtn = document.getElementById('settings-btn'); + settingsBtn.classList.remove('hidden'); + const settingsModal = createSettingsModal(); + settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); + + // Add logout button to header nav (if not already there) + if (!document.getElementById('logout-btn')) { + const nav = document.querySelector('header nav'); + const logoutBtn = document.createElement('button'); + logoutBtn.id = 'logout-btn'; + logoutBtn.className = 'nav-btn'; + logoutBtn.setAttribute('aria-label', 'log out'); + logoutBtn.setAttribute('title', 'log out'); + logoutBtn.innerHTML = ` + + + + + + `; + logoutBtn.addEventListener('click', async () => { + await client.logout(); + window.location.href = '/'; + }); + nav.appendChild(logoutBtn); + } + + // Set page title with Bluesky profile link + document.getElementById('page-title').innerHTML = `@${handle}`; + + // Load user's statuses (full history) + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserStatuses($did: String!) { + ioZzstoatzzStatusRecord( + first: 100 + where: { did: { eq: $did } } + sortBy: [{ field: "createdAt", direction: DESC }] + ) { + edges { node { uri did emoji text createdAt expires } } + } + } + `, + variables: { did: user.did } + }) + }); + const json = await res.json(); + const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); + + let currentHtml = '-'; + let historyHtml = ''; + + if (statuses.length > 0) { + const current = statuses[0]; + const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; + currentHtml = ` + ${renderEmoji(current.emoji)} +
+ ${current.text ? `${parseLinks(current.text)}` : ''} + since ${relativeTime(current.createdAt)}${expiresHtml} +
+ `; + if (statuses.length > 1) { + historyHtml = '

history

'; + statuses.slice(1).forEach(s => { + // Extract rkey from URI (at://did/collection/rkey) + const rkey = s.uri.split('/').pop(); + historyHtml += ` +
+ ${renderEmoji(s.emoji)} +
+
${s.text ? `${parseLinks(s.text)}` : ''}
+ ${relativeTime(s.createdAt)} +
+ +
+ `; + }); + historyHtml += '
'; + } + } + + const currentEmoji = statuses.length > 0 ? statuses[0].emoji : '😊'; + + main.innerHTML = ` +
+
${currentHtml}
+
+
+
+ + + +
+
+ + + +
+
+ ${historyHtml} + `; + + // Set up emoji picker + const emojiInput = document.getElementById('emoji-input'); + const selectedEmojiEl = document.getElementById('selected-emoji'); + const emojiPicker = createEmojiPicker((emoji) => { + emojiInput.value = emoji; + selectedEmojiEl.innerHTML = renderEmoji(emoji); + }); + document.getElementById('emoji-trigger').addEventListener('click', () => emojiPicker.open()); + + // Custom datetime toggle + const expiresSelect = document.getElementById('expires-select'); + const customDatetime = document.getElementById('custom-datetime'); + + // Helper to format date for datetime-local input (local timezone) + function toLocalDatetimeString(date) { + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); + } + + expiresSelect.addEventListener('change', () => { + if (expiresSelect.value === 'custom') { + customDatetime.classList.remove('hidden'); + // Set min to now (prevent past dates) + const now = new Date(); + customDatetime.min = toLocalDatetimeString(now); + // Default to 1 hour from now + const defaultTime = new Date(Date.now() + 60 * 60 * 1000); + customDatetime.value = toLocalDatetimeString(defaultTime); + } else { + customDatetime.classList.add('hidden'); + } + }); + + document.getElementById('status-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const emoji = document.getElementById('emoji-input').value.trim(); + const text = document.getElementById('text-input').value.trim(); + const expiresVal = document.getElementById('expires-select').value; + const customDt = document.getElementById('custom-datetime').value; + + if (!emoji) return; + + const input = { emoji, createdAt: new Date().toISOString() }; + if (text) input.text = text; + if (expiresVal === 'custom' && customDt) { + input.expires = new Date(customDt).toISOString(); + } else if (expiresVal && expiresVal !== 'custom') { + input.expires = new Date(Date.now() + parseInt(expiresVal) * 60 * 1000).toISOString(); + } + + try { + await client.mutate(` + mutation CreateStatus($input: CreateIoZzstoatzzStatusRecordInput!) { + createIoZzstoatzzStatusRecord(input: $input) { uri } + } + `, { input }); + window.location.reload(); + } catch (err) { + console.error('Failed to create status:', err); + alert('Failed to set status: ' + err.message); + } + }); + + // Delete buttons + document.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const rkey = btn.dataset.rkey; + if (!confirm('Delete this status?')) return; + + try { + await client.mutate(` + mutation DeleteStatus($rkey: String!) { + deleteIoZzstoatzzStatusRecord(rkey: $rkey) { uri } + } + `, { rkey }); + window.location.reload(); + } catch (err) { + console.error('Failed to delete status:', err); + alert('Failed to delete: ' + err.message); + } + }); + }); + } + } catch (e) { + console.error('Failed to init:', e); + main.innerHTML = '
failed to initialize. check console.
'; + } +} + +// Render feed page +let feedCursor = null; +let feedHasMore = true; + +async function renderFeed(append = false) { + const main = document.getElementById('main-content'); + document.getElementById('page-title').textContent = 'global feed'; + + if (!append) { + // Initialize auth UI for header elements + await initAuthUI(); + main.innerHTML = '
loading...
'; + } + + const feedList = document.getElementById('feed-list'); + + try { + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetFeed($after: String) { + ioZzstoatzzStatusRecord(first: 20, after: $after, sortBy: [{ field: "createdAt", direction: DESC }]) { + edges { node { uri did emoji text createdAt } cursor } + pageInfo { hasNextPage endCursor } + } + } + `, + variables: { after: append ? feedCursor : null } + }) + }); + + const json = await res.json(); + const data = json.data.ioZzstoatzzStatusRecord; + const statuses = data.edges.map(e => e.node); + feedCursor = data.pageInfo.endCursor; + feedHasMore = data.pageInfo.hasNextPage; + + // Resolve all handles in parallel + const handlePromises = statuses.map(s => resolveDidToHandle(s.did)); + const handles = await Promise.all(handlePromises); + + if (!append) { + feedList.innerHTML = ''; + } + + statuses.forEach((status, i) => { + const handle = handles[i] || status.did.slice(8, 28); + const div = document.createElement('div'); + div.className = 'status-item'; + div.innerHTML = ` + ${renderEmoji(status.emoji)} +
+
+ @${handle} + ${status.text ? `${parseLinks(status.text)}` : ''} +
+ ${relativeTime(status.createdAt)} +
+ `; + feedList.appendChild(div); + }); + + const loadMore = document.getElementById('load-more'); + const endOfFeed = document.getElementById('end-of-feed'); + if (feedHasMore) { + loadMore.classList.remove('hidden'); + endOfFeed.classList.add('hidden'); + } else { + loadMore.classList.add('hidden'); + endOfFeed.classList.remove('hidden'); + } + + // Attach load more handler + const btn = document.getElementById('load-more-btn'); + if (btn && !btn.dataset.bound) { + btn.dataset.bound = 'true'; + btn.addEventListener('click', () => renderFeed(true)); + } + } catch (e) { + console.error('Failed to load feed:', e); + if (!append) { + feedList.innerHTML = '
failed to load feed
'; + } + } +} + +// Render profile page +async function renderProfile(handle) { + const main = document.getElementById('main-content'); + const pageTitle = document.getElementById('page-title'); + + // Initialize auth UI for header elements + await initAuthUI(); + + pageTitle.innerHTML = `@${handle}`; + + main.innerHTML = '
loading...
'; + + try { + // Resolve handle to DID + const did = await resolveHandle(handle); + if (!did) { + main.innerHTML = '
user not found
'; + return; + } + + const res = await fetch(`${CONFIG.server}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query GetUserStatuses($did: String!) { + ioZzstoatzzStatusRecord(first: 20, where: { did: { eq: $did } }, sortBy: [{ field: "createdAt", direction: DESC }]) { + edges { node { uri did emoji text createdAt expires } } + } + } + `, + variables: { did } + }) + }); + + const json = await res.json(); + const statuses = json.data.ioZzstoatzzStatusRecord.edges.map(e => e.node); + + if (statuses.length === 0) { + main.innerHTML = '
no statuses yet
'; + return; + } + + const current = statuses[0]; + const expiresHtml = current.expires ? ` • clears ${relativeTimeFuture(current.expires)}` : ''; + let html = ` +
+
+ ${renderEmoji(current.emoji)} +
+ ${current.text ? `${parseLinks(current.text)}` : ''} + ${relativeTime(current.createdAt)}${expiresHtml} +
+
+
+ `; + + if (statuses.length > 1) { + html += '

history

'; + statuses.slice(1).forEach(status => { + html += ` +
+ ${renderEmoji(status.emoji)} +
+
${status.text ? `${parseLinks(status.text)}` : ''}
+ ${relativeTime(status.createdAt)} +
+
+ `; + }); + html += '
'; + } + + main.innerHTML = html; + } catch (e) { + console.error('Failed to load profile:', e); + main.innerHTML = '
failed to load profile
'; + } +} + +// Update nav active state - hide current page icon, show the other +function updateNavActive(page) { + const navHome = document.getElementById('nav-home'); + const navFeed = document.getElementById('nav-feed'); + // Hide the nav icon for the current page, show the other + if (navHome) navHome.classList.toggle('hidden', page === 'home'); + if (navFeed) navFeed.classList.toggle('hidden', page === 'feed'); +} + +// Initialize auth state for header (settings, logout) - used by all pages +async function initAuthUI() { + if (typeof QuicksliceClient === 'undefined') return; + + try { + client = await QuicksliceClient.createQuicksliceClient({ + server: CONFIG.server, + clientId: CONFIG.clientId, + redirectUri: window.location.origin + '/', + }); + + const isAuthed = await client.isAuthenticated(); + if (!isAuthed) return; + + const user = client.getUser(); + if (!user) return; + + // Load and apply preferences + const prefs = await loadPreferences(); + applyPreferences(prefs); + + // Show settings button and set up modal + const settingsBtn = document.getElementById('settings-btn'); + settingsBtn.classList.remove('hidden'); + const settingsModal = createSettingsModal(); + settingsBtn.addEventListener('click', () => settingsModal.open(userPreferences || prefs)); + + // Add logout button to header nav (if not already there) + if (!document.getElementById('logout-btn')) { + const nav = document.querySelector('header nav'); + const logoutBtn = document.createElement('button'); + logoutBtn.id = 'logout-btn'; + logoutBtn.className = 'nav-btn'; + logoutBtn.setAttribute('aria-label', 'log out'); + logoutBtn.setAttribute('title', 'log out'); + logoutBtn.innerHTML = ` + + + + + + `; + logoutBtn.addEventListener('click', async () => { + await client.logout(); + window.location.href = '/'; + }); + nav.appendChild(logoutBtn); + } + + return { user, prefs }; + } catch (e) { + console.error('Failed to init auth UI:', e); + return null; + } +} + +// Init +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + + const themeBtn = document.getElementById('theme-toggle'); + if (themeBtn) { + themeBtn.addEventListener('click', toggleTheme); + } + + const route = getRoute(); + updateNavActive(route.page); + + if (route.page === 'home') { + renderHome(); + } else if (route.page === 'feed') { + renderFeed(); + } else if (route.page === 'profile') { + renderProfile(route.handle); + } else { + document.getElementById('main-content').innerHTML = '
page not found
'; + } +}); diff --git a/site/bufos.json b/site/bufos.json new file mode 100644 index 0000000..d83886c --- /dev/null +++ b/site/bufos.json @@ -0,0 +1,1614 @@ +[ + "according-to-all-known-laws-of-aviation-there-is-no-way-a-bufo-should-be-able-to-fly", + "add-bufo", + "all-the-bufo", + "angry-karen-bufo-would-like-to-speak-with-your-manager", + "australian-bufo", + "awesomebufo", + "be-the-bufo-you-want-to-see", + "bigbufo_0_0", + "bigbufo_0_1", + "bigbufo_0_2", + "bigbufo_0_3", + "bigbufo_1_0", + "bigbufo_1_1", + "bigbufo_1_2", + "bigbufo_1_3", + "bigbufo_2_0", + "bigbufo_2_1", + "bigbufo_2_2", + "bigbufo_2_3", + "bigbufo_3_0", + "bigbufo_3_1", + "bigbufo_3_2", + "bigbufo_3_3", + "blockheads-bufo", + "breaking-bufo", + "bronze-bufo", + "buff-bufo", + "bufo", + "bufo_wants_his_money", + "bufo-0-10", + "bufo-10", + "bufo-10-4", + "bufo-2022", + "bufo-achieving-coding-flow", + "bufo-ack", + "bufo-actually", + "bufo-adding-bugs-to-the-code", + "bufo-adidas", + "bufo-ages-rapidly-in-the-void", + "bufo-aight-imma-head-out", + "bufo-airpods", + "bufo-alarma", + "bufo-all-good", + "bufo-all-warm-and-fuzzy-inside", + "bufo-am-i", + "bufo-amaze", + "bufo-ambiently-existing", + "bufo-american-football", + "bufo-android", + "bufo-angel", + "bufo-angrily-gives-you-a-birthday-gift", + "bufo-angrily-gives-you-white-elephant-gift", + "bufo-angry", + "bufo-angry-at-fly", + "bufo-angry-bullfrog-screech", + "bufo-angryandfrozen", + "bufo-anime-glasses", + "bufo-appears", + "bufo-apple", + "bufo-appreciates-jwst-pillars-of-creation", + "bufo-approve", + "bufo-arabicus", + "bufo-are-you-seeing-this", + "bufo-arr", + "bufo-arrr", + "bufo-arrrrrr", + "bufo-arrrrrrr", + "bufo-arrrrrrrrr", + "bufo-arrrrrrrrrrrrrrr", + "bufo-artist", + "bufo-asks-politely-to-stop", + "bufo-assists-with-the-landing", + "bufo-atc", + "bufo-away", + "bufo-awkward-smile", + "bufo-awkward-smile-nod", + "bufo-ayy", + "bufo-baby", + "bufo-babysits-an-urgent-ticket", + "bufo-back-pat", + "bufo-backpack", + "bufo-backpat", + "bufo-bag-of-bufos", + "bufo-bait", + "bufo-baker", + "bufo-baller", + "bufo-bandana", + "bufo-banging-head-against-the-wall", + "bufo-barbie", + "bufo-barney", + "bufo-barrister", + "bufo-baseball", + "bufo-basketball", + "bufo-batman", + "bufo-be-my-valentine", + "bufo-became-a-stranger-whose-laugh-you-can-recognize-anywhere", + "bufo-bee", + "bufo-bee-leaf", + "bufo-bee-sad", + "bufo-beer", + "bufo-begrudgingly-offers-you-a-plus", + "bufo-begs-for-ethernet-cable", + "bufo-behind-bars", + "bufo-bell-pepper", + "bufo-betray", + "bufo-betray-but-its-a-hotdog", + "bufo-big-eyes-stare", + "bufo-bigfoot", + "bufo-bill-pay", + "bufo-bird", + "bufo-birthday-but-not-particularly-happy", + "bufo-black-history", + "bufo-black-tea", + "bufo-blank-stare", + "bufo-blank-stare_0_0", + "bufo-blank-stare_0_1", + "bufo-blank-stare_1_0", + "bufo-blank-stare_1_1", + "bufo-blanket", + "bufo-blem", + "bufo-blep", + "bufo-bless", + "bufo-bless-back", + "bufo-blesses-this-pr", + "bufo-block", + "bufo-blogging", + "bufo-bloody-mary", + "bufo-blows-the-magic-conch", + "bufo-blue", + "bufo-blueberries", + "bufo-blush", + "bufo-boba", + "bufo-boba-army", + "bufo-boi", + "bufo-boiii", + "bufo-bongo", + "bufo-bonk", + "bufo-bops-you-on-the-head-with-a-baguette", + "bufo-bops-you-on-the-head-with-a-rolled-up-newspaper", + "bufo-bouge", + "bufo-bouncer-says-its-time-to-go-now", + "bufo-bouquet", + "bufo-bourgeoisie", + "bufo-bowser", + "bufo-box-of-chocolates", + "bufo-brain", + "bufo-brain-damage", + "bufo-brain-damage-escalates-to-new-heights", + "bufo-brain-damage-intensifies", + "bufo-brain-damage-intesifies-more", + "bufo-brain-exploding", + "bufo-breakdown", + "bufo-breaks-tech-bros-heart", + "bufo-breaks-up-with-you", + "bufo-breaks-your-heart", + "bufo-brick", + "bufo-brings-a-new-meaning-to-brain-freeze-by-bopping-you-on-the-head-with-a-popsicle", + "bufo-brings-a-new-meaning-to-gaveled-by-slamming-the-hammer-very-loud", + "bufo-brings-magic-to-the-riot", + "bufo-broccoli", + "bufo-broke", + "bufo-broke-his-toe-and-isn't-sure-what-to-do-about-the-12k-he-signed-up-for", + "bufo-broom", + "bufo-brought-a-taco", + "bufo-bufo", + "bufo-but-anatomically-correct", + "bufo-but-instead-of-green-its-hotdogs", + "bufo-but-instead-of-green-its-pizza", + "bufo-but-you-can-feel-the-electro-house-music-in-the-gif-and-oh-yea-theres-also-a-dapper-chicken", + "bufo-but-you-can-see-the-bufo-in-bufos-eyes", + "bufo-but-you-can-see-the-hotdog-in-their-eyes", + "bufo-buy-high-sell-low", + "bufo-buy-low-sell-high", + "bufo-cache-buddy", + "bufo-cackle", + "bufo-call-for-help", + "bufo-came-into-the-office-just-to-use-the-printer", + "bufo-can't-believe-heartbreak-feels-good-in-a-place-like-this", + "bufo-can't-help-but-wonder-who-watches-the-watchmen", + "bufo-canada", + "bufo-cant-believe-your-audacity", + "bufo-cant-find-a-pull-request", + "bufo-cant-find-an-issue", + "bufo-cant-stop-thinking-about-usher-killing-it-on-roller-skates", + "bufo-cant-take-it-anymore", + "bufo-cantelope", + "bufo-capri-sun", + "bufo-captain-obvious", + "bufo-caribou", + "bufo-carnage", + "bufo-carrot", + "bufo-cash-money", + "bufo-cash-squint", + "bufo-casts-a-spell-on-you", + "bufo-catch", + "bufo-caught-a-radioactive-bufo", + "bufo-caught-a-small-bufo", + "bufo-caused-an-incident", + "bufo-celebrate", + "bufo-censored", + "bufo-chappell-roan", + "bufo-chatting", + "bufo-check", + "bufo-checks-out-the-vibe", + "bufo-cheese", + "bufo-chef", + "bufo-chefkiss", + "bufo-chefkiss-with-hat", + "bufo-cherries", + "bufo-chicken", + "bufo-chomp", + "bufo-christmas", + "bufo-chungus", + "bufo-churns-the-butter", + "bufo-clap", + "bufo-clap-hd", + "bufo-claus", + "bufo-clown", + "bufo-coconut", + "bufo-code-freeze", + "bufo-coding", + "bufo-coffee-happy", + "bufo-coin", + "bufo-come-to-the-dark-side", + "bufo-comfy", + "bufo-commits-digital-piracy", + "bufo-competes-in-the-bufo-bracket", + "bufo-complies-with-the-chinese-government", + "bufo-concerned", + "bufo-cone-of-shame", + "bufo-confetti", + "bufo-confused", + "bufo-congrats", + "bufo-cookie", + "bufo-cool-glasses", + "bufo-corn", + "bufo-cornucopia", + "bufo-covid", + "bufo-cowboy", + "bufo-cozy-blanky", + "bufo-crewmate-blue", + "bufo-crewmate-blue-bounce", + "bufo-crewmate-cyan", + "bufo-crewmate-cyan-bounce", + "bufo-crewmate-green", + "bufo-crewmate-green-bounce", + "bufo-crewmate-lime", + "bufo-crewmate-lime-bounce", + "bufo-crewmate-orange", + "bufo-crewmate-orange-bounce", + "bufo-crewmate-pink", + "bufo-crewmate-pink-bounce", + "bufo-crewmate-purple", + "bufo-crewmate-purple-bounce", + "bufo-crewmate-red", + "bufo-crewmate-red-bounce", + "bufo-crewmate-yellow", + "bufo-crewmate-yellow-bounce", + "bufo-crewmates", + "bufo-cries-into-his-beer", + "bufo-crikey", + "bufo-croptop", + "bufo-crumbs", + "bufo-crustacean", + "bufo-cry", + "bufo-cry-pray", + "bufo-crying", + "bufo-crying-in-the-rain", + "bufo-crying-jail", + "bufo-crying-stop", + "bufo-crying-tears-of-crying-tears-of-joy", + "bufo-crying-why", + "bufo-cubo", + "bufo-cucumber", + "bufo-cuddle", + "bufo-cupcake", + "bufo-cuppa", + "bufo-cute", + "bufo-cute-dance", + "bufo-dab", + "bufo-dancing", + "bufo-dapper", + "bufo-dbz", + "bufo-deal-with-it", + "bufo-declines-your-suppository-offer", + "bufo-deep-hmm", + "bufo-defend", + "bufo-delurk", + "bufo-demands-more-nom-noms", + "bufo-demure", + "bufo-desperately-needs-mavis-beacon", + "bufo-detective", + "bufo-develops-clairvoyance-while-trapped-in-the-void", + "bufo-devil", + "bufo-devouring-his-son", + "bufo-di-beppo", + "bufo-did-not-make-it-through-the-heatwave", + "bufo-didnt-get-any-sleep", + "bufo-didnt-listen-to-willy-wonka", + "bufo-disappointed", + "bufo-disco", + "bufo-discombobulated", + "bufo-disguise", + "bufo-ditto", + "bufo-dizzy", + "bufo-do-not-panic", + "bufo-dodge", + "bufo-doesnt-believe-you", + "bufo-doesnt-understand-how-this-meeting-isnt-an-email", + "bufo-doesnt-wanna-get-out-of-the-bath-yet", + "bufo-dog", + "bufo-domo", + "bufo-done-check", + "bufo-dont", + "bufo-dont-even-see-the-code-anymore", + "bufo-dont-trust-whats-over-there", + "bufo-double-chin", + "bufo-double-vaccinated", + "bufo-doubt", + "bufo-dough", + "bufo-downvote", + "bufo-dr-depper", + "bufo-dragon", + "bufo-drags-knee", + "bufo-drake-no", + "bufo-drake-yes", + "bufo-drifts-through-the-void", + "bufo-drinking-baja-blast", + "bufo-drinking-boba", + "bufo-drinking-coffee", + "bufo-drinking-coke", + "bufo-drinking-pepsi", + "bufo-drinking-pumpkin-spice-latte", + "bufo-drinks-from-the-fire-hose", + "bufo-drops-everything-now", + "bufo-drowning-in-leeks", + "bufo-drowns-in-memories-of-ocean", + "bufo-drowns-in-tickets-but-ok", + "bufo-drumroll", + "bufo-easter-bunny", + "bufo-eating-hotdog", + "bufo-eating-lollipop", + "bufo-eats-a-bufo-taco", + "bufo-eats-all-your-honey", + "bufo-eats-bufo-taco", + "bufo-egg", + "bufo-elite", + "bufo-emo", + "bufo-ends-the-holy-war-by-offering-the-objectively-best-programming-language", + "bufo-enjoys-life", + "bufo-enjoys-life-in-the-windows-xp-background", + "bufo-enraged", + "bufo-enter", + "bufo-enters-the-void", + "bufo-entrance", + "bufo-ethereum", + "bufo-everything-is-on-fire", + "bufo-evil", + "bufo-excited", + "bufo-excited-but-sad", + "bufo-existential-dread-sets-in", + "bufo-exit", + "bufo-experiences-euneirophrenia", + "bufo-extra-cool", + "bufo-eye-twitch", + "bufo-eyeballs", + "bufo-eyeballs-bloodshot", + "bufo-eyes", + "bufo-fab", + "bufo-facepalm", + "bufo-failed-the-load-test", + "bufo-fails-the-vibe-check", + "bufo-fancy-tea", + "bufo-farmer", + "bufo-fastest-rubber-stamp-in-the-west", + "bufo-fedora", + "bufo-feel-better", + "bufo-feeling-pretty-might-delete-later", + "bufo-feels-appreciated", + "bufo-feels-nothing", + "bufo-fell-asleep", + "bufo-fellow-kids", + "bufo-fieri", + "bufo-fight", + "bufo-fine-art", + "bufo-fingerguns", + "bufo-fingerguns-back", + "bufo-fire", + "bufo-fire-engine", + "bufo-firefighter", + "bufo-fish", + "bufo-fish-bulb", + "bufo-fistbump", + "bufo-flex", + "bufo-flipoff", + "bufo-flips-table", + "bufo-folder", + "bufo-fomo", + "bufo-food-please", + "bufo-football", + "bufo-for-dummies", + "bufo-forgot-how-to-type", + "bufo-forgot-that-you-existed-it-isnt-love-it-isnt-hate-its-just-indifference", + "bufo-found-some-more-leeks", + "bufo-found-the-leeks", + "bufo-found-yet-another-juicebox", + "bufo-french", + "bufo-friends", + "bufo-frustrated-with-flower", + "bufo-fu%C3%9Fball", + "bufo-fun-is-over", + "bufo-furiously-tries-to-write-python", + "bufo-furiously-writes-an-epic-update", + "bufo-furiously-writes-you-a-peer-review", + "bufo-futbol", + "bufo-gamer", + "bufo-gaming", + "bufo-gandalf", + "bufo-gandalf-has-seen-things", + "bufo-gandalf-wat", + "bufo-gardener", + "bufo-garlic", + "bufo-gavel", + "bufo-gavel-dual-wield", + "bufo-gen-z", + "bufo-gentleman", + "bufo-germany", + "bufo-get-in-loser-were-going-shopping", + "bufo-gets-downloaded-from-the-cloud", + "bufo-gets-hit-in-the-face-with-an-egg", + "bufo-gets-uploaded-to-the-cloud", + "bufo-gets-whiplash", + "bufo-ghost", + "bufo-ghost-costume", + "bufo-giggling-in-a-cat-onesie", + "bufo-give", + "bufo-give-money", + "bufo-give-pack-of-ice", + "bufo-gives-a-fake-moustache", + "bufo-gives-a-magic-number", + "bufo-gives-an-idea", + "bufo-gives-approval", + "bufo-gives-can-of-worms", + "bufo-gives-databricks", + "bufo-gives-j", + "bufo-gives-star", + "bufo-gives-you-a-feature-flag", + "bufo-gives-you-a-hotdog", + "bufo-gives-you-some-extra-brain", + "bufo-gives-you-some-rice", + "bufo-glasses", + "bufo-glitch", + "bufo-goal", + "bufo-goes-super-saiyan", + "bufo-goes-to-space", + "bufo-goggles-are-too-tight", + "bufo-good-morning", + "bufo-good-vibe", + "bufo-goose-hat-happy-dance", + "bufo-got-a-tan", + "bufo-got-zapped", + "bufo-grapes", + "bufo-grasping-at-straws", + "bufo-grenade", + "bufo-grimaces-with-eyebrows", + "bufo-guitar", + "bufo-ha-ha", + "bufo-hacker", + "bufo-hackerman", + "bufo-haha-yes-haha-yes", + "bufo-hahabusiness", + "bufo-halloween", + "bufo-halloween-pumpkin", + "bufo-hands", + "bufo-hands-on-hips-annoyed", + "bufo-hangs-ten", + "bufo-hangs-up", + "bufo-hannibal-lecter", + "bufo-hanson", + "bufo-happy", + "bufo-happy-hour", + "bufo-happy-new-year", + "bufo-hardhat", + "bufo-has-a-5-dollar-footlong", + "bufo-has-a-banana", + "bufo-has-a-bbq", + "bufo-has-a-big-wrench", + "bufo-has-a-blue-wrench", + "bufo-has-a-crush", + "bufo-has-a-dr-pepper", + "bufo-has-a-fresh-slice", + "bufo-has-a-headache", + "bufo-has-a-hot-take", + "bufo-has-a-question", + "bufo-has-a-sandwich", + "bufo-has-a-spoon", + "bufo-has-a-timtam", + "bufo-has-accepted-its-horrible-fate", + "bufo-has-activated", + "bufo-has-another-sandwich", + "bufo-has-been-cleaning", + "bufo-has-gotta-poop-but-hes-stuck-in-a-long-meeting", + "bufo-has-infiltrated-your-secure-system", + "bufo-has-midas-touch", + "bufo-has-read-enough-documentation-for-today", + "bufo-has-some-ketchup", + "bufo-has-thread-for-guts", + "bufo-hasnt-worked-a-full-week-so-far-this-year", + "bufo-hat", + "bufo-hazmat", + "bufo-headbang", + "bufo-headphones", + "bufo-heart", + "bufo-heart-but-its-anatomically-correct", + "bufo-hearts", + "bufo-hehe", + "bufo-hell", + "bufo-hello", + "bufo-heralds-an-incident", + "bufo-heralds-taco-taking", + "bufo-heralds-your-success", + "bufo-here-to-make-a-dill-for-more-pickles", + "bufo-hides", + "bufo-high-speed-train", + "bufo-highfive-1", + "bufo-highfive-2", + "bufo-hipster", + "bufo-hmm", + "bufo-hmm-no", + "bufo-hmm-yes", + "bufo-holding-space-for-defying-gravity", + "bufo-holds-pumpkin", + "bufo-homologates", + "bufo-hop-in-we're-going-to-flavortown", + "bufo-hopes-you-also-are-having-a-good-day", + "bufo-hopes-you-are-having-a-good-day", + "bufo-hot-pocket", + "bufo-hotdog-rocket", + "bufo-howdy", + "bufo-hug", + "bufo-hugs-moo-deng", + "bufo-hype", + "bufo-i-just-love-it-so-much", + "bufo-ice-cream", + "bufo-idk", + "bufo-idk-but-okay-i-guess-so", + "bufo-im-in-danger", + "bufo-imposter", + "bufo-in-a-pear-tree", + "bufo-in-his-cozy-bed-hoping-he-never-gets-capitated", + "bufo-in-rome", + "bufo-inception", + "bufo-increases-his-dimensionality-while-trapped-in-the-void", + "bufo-innocent", + "bufo-inspecting", + "bufo-inspired", + "bufo-instigates-a-dramatic-turn-of-events", + "bufo-intensifies", + "bufo-intern", + "bufo-investigates", + "bufo-iphone", + "bufo-irl", + "bufo-iron-throne", + "bufo-ironside", + "bufo-is-a-little-worried-but-still-trying-to-be-supportive", + "bufo-is-a-part-of-gen-z", + "bufo-is-about-to-zap-you", + "bufo-is-all-ears", + "bufo-is-angry-at-the-water-cooler-bottle-company-for-missing-yet-another-delivery", + "bufo-is-at-his-wits-end", + "bufo-is-at-the-dentist", + "bufo-is-better-known-for-the-things-he-does-on-the-mattress", + "bufo-is-exhausted-rooting-for-the-antihero", + "bufo-is-flying-and-is-the-plane", + "bufo-is-getting-abducted", + "bufo-is-getting-paged-now", + "bufo-is-glad-the-british-were-kicked-out", + "bufo-is-happy-youre-happy", + "bufo-is-having-a-really-bad-time", + "bufo-is-in-a-never-ending-meeting", + "bufo-is-in-on-the-joke", + "bufo-is-inhaling-this-popcorn", + "bufo-is-it-done", + "bufo-is-jealous-its-your-birthday", + "bufo-is-jean-baptise-emanuel-zorg", + "bufo-is-keeping-his-eye-on-you", + "bufo-is-lonely", + "bufo-is-lost", + "bufo-is-lost-in-the-void", + "bufo-is-omniscient", + "bufo-is-on-a-sled", + "bufo-is-panicking", + "bufo-is-petting-your-cat", + "bufo-is-petting-your-dog", + "bufo-is-proud-of-you", + "bufo-is-ready-for-xmas", + "bufo-is-ready-to-build-when-you-are", + "bufo-is-ready-to-burn-down-the-mta-because-their-train-skipped-their-station-again", + "bufo-is-ready-to-consume-his-daily-sodium-intake-in-one-sitting", + "bufo-is-ready-to-eat", + "bufo-is-ready-to-riot", + "bufo-is-ready-to-slay-the-dragon", + "bufo-is-romantic", + "bufo-is-sad-no-one-complimented-their-agent-47-cosplay", + "bufo-is-safe-behind-bars", + "bufo-is-so-happy-youre-here", + "bufo-is-the-perfect-human-form", + "bufo-is-trapped-in-a-cameron-winter-phase", + "bufo-is-unconcerned", + "bufo-is-up-to-something", + "bufo-is-very-upset-now", + "bufo-is-watching-you", + "bufo-is-working-through-the-tears", + "bufo-is-working-too-much", + "bufo-isitdone", + "bufo-isnt-angry-just-disappointed", + "bufo-isnt-going-to-rewind-the-vhs-before-returning-it", + "bufo-isnt-reading-all-that", + "bufo-it-bar", + "bufo-italian", + "bufo-its-over-9000", + "bufo-its-too-early-for-this", + "bufo-jam", + "bufo-jammies", + "bufo-jammin", + "bufo-jealous", + "bufo-jedi", + "bufo-jomo", + "bufo-judge", + "bufo-judges", + "bufo-juice", + "bufo-juicebox", + "bufo-juicy", + "bufo-just-a-little-sad", + "bufo-just-a-little-salty", + "bufo-just-checking", + "bufo-just-finished-a-workout", + "bufo-just-got-back-from-the-dentist", + "bufo-just-ice", + "bufo-just-walked-into-an-awkward-conversation-and-is-now-trying-to-figure-out-how-to-leave", + "bufo-just-wanted-you-to-know-this-is-him-trying", + "bufo-justice", + "bufo-karen", + "bufo-keeps-his-password-written-on-a-post-it-note-stuck-to-his-monitor", + "bufo-keyboard", + "bufo-kills-you-with-kindness", + "bufo-king", + "bufo-kiwi", + "bufo-knife", + "bufo-knife-cries-right", + "bufo-knife-crying", + "bufo-knife-crying-left", + "bufo-knife-crying-right", + "bufo-knows-age-is-just-a-number", + "bufo-knows-his-customers", + "bufo-knows-this-is-a-total-bop", + "bufo-knuckle-sandwich", + "bufo-knuckles", + "bufo-koi", + "bufo-kudo", + "bufo-kuzco", + "bufo-kuzco-has-not-learned-his-lesson-yet", + "bufo-laser-eyes", + "bufo-late-to-the-convo", + "bufo-laugh-xd", + "bufo-laughing-popcorn", + "bufo-laughs-to-mask-the-pain", + "bufo-leads-the-way-to-better-docs", + "bufo-leaves-you-on-seen", + "bufo-left-a-comment", + "bufo-left-multiple-comments", + "bufo-legal-entities", + "bufo-lemon", + "bufo-leprechaun", + "bufo-let-them-eat-cake", + "bufo-lgtm", + "bufo-liberty", + "bufo-liberty-forgot-her-torch", + "bufo-librarian", + "bufo-lick", + "bufo-licks-his-hway-out-of-prison", + "bufo-lies-awake-in-panic", + "bufo-life-saver", + "bufo-likes-that-idea", + "bufo-link", + "bufo-listens-to-his-conscience", + "bufo-lit", + "bufo-littlefoot-is-upset", + "bufo-loading", + "bufo-lol", + "bufo-lol-cry", + "bufo-lolsob", + "bufo-long", + "bufo-lookin-dope", + "bufo-looking-very-much", + "bufo-looks-a-little-closer", + "bufo-looks-for-a-pull-request", + "bufo-looks-for-an-issue", + "bufo-looks-like-hes-listening-but-hes-not", + "bufo-looks-out-of-the-window", + "bufo-loves-blobs", + "bufo-loves-disco", + "bufo-loves-doges", + "bufo-loves-pho", + "bufo-loves-rice-and-beans", + "bufo-loves-ruby", + "bufo-loves-this-song", + "bufo-luigi", + "bufo-lunch", + "bufo-lurk", + "bufo-lurk-delurk", + "bufo-macbook", + "bufo-made-salad", + "bufo-made-you-a-burrito", + "bufo-magician", + "bufo-make-it-rain", + "bufo-makes-it-rain", + "bufo-makes-the-dream-work", + "bufo-mama-mia-thatsa-one-spicy-a-meatball", + "bufo-marine", + "bufo-mario", + "bufo-mask", + "bufo-matrix", + "bufo-medal", + "bufo-meltdown", + "bufo-melting", + "bufo-micdrop", + "bufo-midsommar", + "bufo-midwest-princess", + "bufo-mild-panic", + "bufo-mildly-aggravated", + "bufo-milk", + "bufo-mindblown", + "bufo-minecraft-attack", + "bufo-minecraft-defend", + "bufo-mischievous", + "bufo-mitosis", + "bufo-mittens", + "bufo-modern-art", + "bufo-monocle", + "bufo-monstera", + "bufo-morning", + "bufo-morning-starbucks", + "bufo-morning-sun", + "bufo-mrtayto", + "bufo-mushroom", + "bufo-mustache", + "bufo-my-pho", + "bufo-nah", + "bufo-naked", + "bufo-naptime", + "bufo-needs-some-hot-tea-to-process-this-news", + "bufo-needs-to-vent", + "bufo-nefarious", + "bufo-nervous", + "bufo-nervous-but-cute", + "bufo-night", + "bufo-ninja", + "bufo-no", + "bufo-no-capes", + "bufo-no-more-today-thank-you", + "bufo-no-prob", + "bufo-no-problem", + "bufo-no-ragrets", + "bufo-no-sleep", + "bufo-no-u", + "bufo-nod", + "bufo-noodles", + "bufo-nope", + "bufo-nosy", + "bufo-not-bad-by-dalle", + "bufo-not-my-problem", + "bufo-not-respecting-your-personal-space", + "bufo-notice-me-senpai", + "bufo-notification", + "bufo-np", + "bufo-nun", + "bufo-nyc", + "bufo-oatly", + "bufo-oblivious-and-innocent", + "bufo-of-liberty", + "bufo-offering-bufo-offering-bufo-offering-bufo", + "bufo-offers-1", + "bufo-offers-13", + "bufo-offers-2", + "bufo-offers-200", + "bufo-offers-21", + "bufo-offers-3", + "bufo-offers-5", + "bufo-offers-8", + "bufo-offers-a-bagel", + "bufo-offers-a-ball-of-mud", + "bufo-offers-a-banana-in-these-trying-times", + "bufo-offers-a-beer", + "bufo-offers-a-bicycle", + "bufo-offers-a-bolillo-para-el-susto", + "bufo-offers-a-book", + "bufo-offers-a-brain", + "bufo-offers-a-bufo-egg-in-this-trying-time", + "bufo-offers-a-burger", + "bufo-offers-a-cake", + "bufo-offers-a-clover", + "bufo-offers-a-comment", + "bufo-offers-a-cookie", + "bufo-offers-a-deploy-lock", + "bufo-offers-a-factory", + "bufo-offers-a-flan", + "bufo-offers-a-flowchart-to-help-you-navigate-this-workflow", + "bufo-offers-a-focaccia", + "bufo-offers-a-furby", + "bufo-offers-a-gavel", + "bufo-offers-a-generator", + "bufo-offers-a-hario-scale", + "bufo-offers-a-hot-take", + "bufo-offers-a-jetpack-zebra", + "bufo-offers-a-kakapo", + "bufo-offers-a-like", + "bufo-offers-a-little-band-aid-for-a-big-problem", + "bufo-offers-a-llama", + "bufo-offers-a-loading-spinner", + "bufo-offers-a-loading-spinner-spinning", + "bufo-offers-a-lock", + "bufo-offers-a-mac-m1-chip", + "bufo-offers-a-pager", + "bufo-offers-a-piece-of-cake", + "bufo-offers-a-pr", + "bufo-offers-a-pull-request", + "bufo-offers-a-rock", + "bufo-offers-a-roomba", + "bufo-offers-a-ruby", + "bufo-offers-a-sandbox", + "bufo-offers-a-shocked-pikachu", + "bufo-offers-a-speedy-recovery", + "bufo-offers-a-status", + "bufo-offers-a-taco", + "bufo-offers-a-telescope", + "bufo-offers-a-tiny-wood-stove", + "bufo-offers-a-torta-ahogada", + "bufo-offers-a-webhook", + "bufo-offers-a-webhook-but-the-logo-is-canonically-correct", + "bufo-offers-a-wednesday", + "bufo-offers-a11y", + "bufo-offers-ai", + "bufo-offers-airwrap", + "bufo-offers-an-airpod-pro", + "bufo-offers-an-easter-egg", + "bufo-offers-an-eclair", + "bufo-offers-an-egg-in-this-trying-time", + "bufo-offers-an-ethernet-cable", + "bufo-offers-an-export-of-your-data", + "bufo-offers-an-extinguisher", + "bufo-offers-an-idea", + "bufo-offers-an-incident", + "bufo-offers-an-issue", + "bufo-offers-an-outage", + "bufo-offers-approval", + "bufo-offers-avocado", + "bufo-offers-bento", + "bufo-offers-big-band-aid-for-a-little-problem", + "bufo-offers-bitcoin", + "bufo-offers-boba", + "bufo-offers-boss-coffee", + "bufo-offers-box", + "bufo-offers-bufo", + "bufo-offers-bufo-cubo", + "bufo-offers-bufo-offers", + "bufo-offers-bufomelon", + "bufo-offers-calculated-decision-to-leave-tech-debt-for-now-and-clean-it-up-later", + "bufo-offers-caribufo", + "bufo-offers-chart-with-upwards-trend", + "bufo-offers-chatgpt", + "bufo-offers-chrome", + "bufo-offers-coffee", + "bufo-offers-copilot", + "bufo-offers-corn", + "bufo-offers-corporate-red-tape", + "bufo-offers-covid", + "bufo-offers-csharp", + "bufo-offers-d20", + "bufo-offers-datadog", + "bufo-offers-discord", + "bufo-offers-dnd", + "bufo-offers-empty-wallet", + "bufo-offers-f5", + "bufo-offers-factorio", + "bufo-offers-falafel", + "bufo-offers-fart-cloud", + "bufo-offers-firefox", + "bufo-offers-flatbread", + "bufo-offers-footsie", + "bufo-offers-friday", + "bufo-offers-fud", + "bufo-offers-gatorade", + "bufo-offers-git-mailing-list", + "bufo-offers-golden-handcuffs", + "bufo-offers-google-doc", + "bufo-offers-google-drive", + "bufo-offers-google-sheets", + "bufo-offers-hello-kitty", + "bufo-offers-help", + "bufo-offers-hotdog", + "bufo-offers-jira", + "bufo-offers-ldap", + "bufo-offers-lego", + "bufo-offers-model-1857-12-pounder-napoleon-cannon", + "bufo-offers-moneybag", + "bufo-offers-new-jira", + "bufo-offers-nothing", + "bufo-offers-notion", + "bufo-offers-oatmilk", + "bufo-offers-openai", + "bufo-offers-pancakes", + "bufo-offers-peanuts", + "bufo-offers-pineapple", + "bufo-offers-power", + "bufo-offers-prescription-strength-painkillers", + "bufo-offers-python", + "bufo-offers-securifriend", + "bufo-offers-solar-eclipse", + "bufo-offers-spam", + "bufo-offers-stash-of-tea-from-the-office-for-the-weekend", + "bufo-offers-tayto", + "bufo-offers-terraform", + "bufo-offers-the-cloud", + "bufo-offers-the-power", + "bufo-offers-the-weeknd", + "bufo-offers-thoughts-and-prayers", + "bufo-offers-thread", + "bufo-offers-thundercats", + "bufo-offers-tim-tams", + "bufo-offers-tree", + "bufo-offers-turkish-delights", + "bufo-offers-ube", + "bufo-offers-watermelon", + "bufo-offers-you-a-comically-oversized-waffle", + "bufo-offers-you-a-db-for-your-customer-data", + "bufo-offers-you-a-gdpr-compliant-cookie", + "bufo-offers-you-a-kfc-16-piece-family-size-bucket-of-fried-chicken", + "bufo-offers-you-a-monster-early-in-the-morning", + "bufo-offers-you-a-pint-m8", + "bufo-offers-you-a-red-bull-early-in-the-morning", + "bufo-offers-you-a-suspiciously-not-urgent-ticket", + "bufo-offers-you-an-urgent-ticket", + "bufo-offers-you-dangerously-high-rate-limits", + "bufo-offers-you-his-crypto-before-he-pumps-and-dumps-it", + "bufo-offers-you-logs", + "bufo-offers-you-money-in-this-trying-time", + "bufo-offers-you-the-best-emoji-culture-ever", + "bufo-offers-you-the-moon", + "bufo-offers-you-the-world", + "bufo-offers-yubikey", + "bufo-office", + "bufo-oh-hai", + "bufo-oh-no", + "bufo-oh-yeah", + "bufo-ok", + "bufo-okay-pretty-salty-now", + "bufo-old", + "bufo-olives", + "bufo-omg", + "bufo-on-fire-but-still-excited", + "bufo-on-the-ceiling", + "bufo-oncall-secondary", + "bufo-onion", + "bufo-open-mic", + "bufo-opens-a-haberdashery", + "bufo-orange", + "bufo-oreilly", + "bufo-pager-duty", + "bufo-pajama-party", + "bufo-palpatine", + "bufo-panic", + "bufo-parrot", + "bufo-party", + "bufo-party-birthday", + "bufo-party-conga-line", + "bufo-passed-the-load-test", + "bufo-passes-the-vibe-check", + "bufo-pat", + "bufo-peaks-on-you-from-above", + "bufo-peaky-blinder", + "bufo-pear", + "bufo-pearly-whites", + "bufo-peek", + "bufo-peek-wall", + "bufo-peeking", + "bufo-pensivity-turned-discomfort-upon-realization-of-reality", + "bufo-phew", + "bufo-phonecall", + "bufo-photographer", + "bufo-picked-you-a-flower", + "bufo-pikmin", + "bufo-pilgrim", + "bufo-pilot", + "bufo-pinch-hitter", + "bufo-pineapple", + "bufo-ping", + "bufo-pirate", + "bufo-pitchfork", + "bufo-pitchforks", + "bufo-pizza-hut", + "bufo-placeholder", + "bufo-platformizes", + "bufo-plays-some-smooth-jazz", + "bufo-plays-some-smooth-jazz-intensity-1", + "bufo-pleading", + "bufo-pleading-1", + "bufo-please", + "bufo-pog", + "bufo-pog-surprise", + "bufo-pointing-down-there", + "bufo-pointing-over-there", + "bufo-pointing-right-there", + "bufo-pointing-up-there", + "bufo-police", + "bufo-poliwhirl", + "bufo-ponders", + "bufo-ponders-2", + "bufo-ponders-3", + "bufo-poo", + "bufo-poof", + "bufo-popcorn", + "bufo-popping-out-of-the-coffee", + "bufo-popping-out-of-the-coffee-upsidedown", + "bufo-popping-out-of-the-toilet", + "bufo-pops-by", + "bufo-pops-out-for-a-quick-bite-to-eat", + "bufo-possessed", + "bufo-potato", + "bufo-pours-one-out", + "bufo-praise", + "bufo-pray", + "bufo-pray-partying", + "bufo-praying-his-qa-is-on-point", + "bufo-prays-for-this-to-be-over-already", + "bufo-prays-for-this-to-be-over-already-intensifies", + "bufo-prays-to-azure", + "bufo-prays-to-nvidia", + "bufo-prays-to-pagerduty", + "bufo-preach", + "bufo-presents-to-the-bufos", + "bufo-pretends-to-have-authority", + "bufo-pretty-dang-sad", + "bufo-pride", + "bufo-psychic", + "bufo-pumpkin", + "bufo-pumpkin-head", + "bufo-pushes-to-prod", + "bufo-put-on-active-noise-cancelling-headphones-but-can-still-hear-you", + "bufo-quadruple-vaccinated", + "bufo-question", + "bufo-rad", + "bufo-rainbow", + "bufo-rainbow-moustache", + "bufo-raised-hand", + "bufo-ramen", + "bufo-reading", + "bufo-reads-and-analyzes-doc", + "bufo-reads-and-analyzes-doc-intensifies", + "bufo-red-flags", + "bufo-redacted", + "bufo-regret", + "bufo-remains-perturbed-from-the-void", + "bufo-remembers-bad-time", + "bufo-returns-to-the-void", + "bufo-retweet", + "bufo-reverse", + "bufo-review", + "bufo-revokes-his-approval", + "bufo-rich", + "bufo-rick", + "bufo-rides-in-style", + "bufo-riding-goose", + "bufo-riot", + "bufo-rip", + "bufo-roasted", + "bufo-robs-you", + "bufo-rocket", + "bufo-rofl", + "bufo-roll", + "bufo-roll-fast", + "bufo-roll-safe", + "bufo-roll-the-dice", + "bufo-rolling-out", + "bufo-rose", + "bufo-ross", + "bufo-royalty", + "bufo-royalty-sparkle", + "bufo-rude", + "bufo-rudolph", + "bufo-run", + "bufo-run-right", + "bufo-rush", + "bufo-sad", + "bufo-sad-baguette", + "bufo-sad-but-ok", + "bufo-sad-rain", + "bufo-sad-swinging", + "bufo-sad-vibe", + "bufo-sailor-moon", + "bufo-salad", + "bufo-salivating", + "bufo-salty", + "bufo-salute", + "bufo-same", + "bufo-santa", + "bufo-saves-hyrule", + "bufo-says-good-morning-to-test-the-waters", + "bufo-scheduled", + "bufo-science", + "bufo-science-intensifies", + "bufo-scientist", + "bufo-scientist-intensifies", + "bufo-screams-into-the-ambient-void", + "bufo-security-jacket", + "bufo-sees-what-you-did-there", + "bufo-segway", + "bufo-sends-a-demand-signal", + "bufo-sends-to-print", + "bufo-sends-you-to-the-shadow-realm", + "bufo-shakes-up-your-etch-a-sketch", + "bufo-shaking-eyes", + "bufo-shaking-head", + "bufo-shame", + "bufo-shares-his-banana", + "bufo-sheesh", + "bufo-shh", + "bufo-shh-barking-puppy", + "bufo-shifty", + "bufo-ship", + "bufo-shipit", + "bufo-shipping", + "bufo-shower", + "bufo-showing-off-baby", + "bufo-showing-off-babypilot", + "bufo-shredding", + "bufo-shrek", + "bufo-shrek-but-canonically-correct", + "bufo-shrooms", + "bufo-shrug", + "bufo-shy", + "bufo-sigh", + "bufo-silly", + "bufo-silly-goose-dance", + "bufo-simba", + "bufo-single-tear", + "bufo-sinks", + "bufo-sip", + "bufo-sipping-on-juice", + "bufo-sips-coffee", + "bufo-siren", + "bufo-sit", + "bufo-sith", + "bufo-skeledance", + "bufo-skellington", + "bufo-skellington-1", + "bufo-skiing", + "bufo-slay", + "bufo-sleep", + "bufo-slinging-bagels", + "bufo-slowly-heads-out", + "bufo-slowly-lurks-in", + "bufo-smile", + "bufo-smirk", + "bufo-smol", + "bufo-smug", + "bufo-smugo", + "bufo-snail", + "bufo-snaps-a-pic", + "bufo-snore", + "bufo-snow", + "bufo-sobbing", + "bufo-soccer", + "bufo-softball", + "bufo-sombrero", + "bufo-speaking-math", + "bufo-spider", + "bufo-spit", + "bufo-spooky-szn", + "bufo-sports", + "bufo-squad", + "bufo-squash", + "bufo-sriracha", + "bufo-stab", + "bufo-stab-murder", + "bufo-stab-reverse", + "bufo-stamp", + "bufo-standing", + "bufo-stare", + "bufo-stargazing", + "bufo-stars-in-a-old-timey-talkie", + "bufo-starstruck", + "bufo-stay-puft-marshmallow", + "bufo-steals-your-thunder", + "bufo-stick", + "bufo-stick-reverse", + "bufo-stole-caribufos-antler", + "bufo-stole-your-crunchwrap-before-you-could-finish-it", + "bufo-stoned", + "bufo-stonks", + "bufo-stonks2", + "bufo-stop", + "bufo-stopsign", + "bufo-strains-his-neck", + "bufo-strange", + "bufo-strawberry", + "bufo-strikes-a-deal", + "bufo-strikes-the-match-he's-ready-for-inferno", + "bufo-stripe", + "bufo-stuffed", + "bufo-style", + "bufo-sun-bless", + "bufo-sunny-side-up", + "bufo-surf", + "bufo-sus", + "bufo-sushi", + "bufo-sussy-eyebrows", + "bufo-sweat", + "bufo-sweep", + "bufo-sweet-dreams", + "bufo-sweet-potato", + "bufo-swims", + "bufo-sword", + "bufo-taco", + "bufo-tada", + "bufo-take-my-money", + "bufo-takes-a-bath", + "bufo-takes-bufo-give", + "bufo-takes-five-corndogs-to-the-movies-by-himself-as-his-me-time", + "bufo-takes-hotdog", + "bufo-takes-slack", + "bufo-takes-spam", + "bufo-takes-your-approval", + "bufo-takes-your-boba", + "bufo-takes-your-bufo-taco", + "bufo-takes-your-burrito", + "bufo-takes-your-copilot", + "bufo-takes-your-fud-away", + "bufo-takes-your-golden-handcuffs", + "bufo-takes-your-incident", + "bufo-takes-your-nose", + "bufo-takes-your-pizza", + "bufo-takes-yubikey", + "bufo-takes-zoom", + "bufo-talks-to-brick-wall", + "bufo-tapioca-pearl", + "bufo-tea", + "bufo-teal", + "bufo-tears-of-joy", + "bufo-tense", + "bufo-tequila", + "bufo-thanks", + "bufo-thanks-bufo-for-thanking-bufo", + "bufo-thanks-the-sr-bufo-for-their-wisdom", + "bufo-thanks-you-for-the-approval", + "bufo-thanks-you-for-the-bufo", + "bufo-thanks-you-for-the-comment", + "bufo-thanks-you-for-the-new-bufo", + "bufo-thanks-you-for-your-issue", + "bufo-thanks-you-for-your-pr", + "bufo-thanks-you-for-your-service", + "bufo-thanksgiving", + "bufo-thanos", + "bufo-thats-a-knee-slapper", + "bufo-the-builder", + "bufo-the-crying-osha-compliant-builder", + "bufo-the-osha-compliant-builder", + "bufo-think", + "bufo-thinking", + "bufo-thinking-about-holidays", + "bufo-thinks-about-a11y", + "bufo-thinks-about-azure", + "bufo-thinks-about-azure-front-door", + "bufo-thinks-about-azure-front-door-intensifies", + "bufo-thinks-about-cheeky-nandos", + "bufo-thinks-about-chocolate", + "bufo-thinks-about-climbing", + "bufo-thinks-about-docs", + "bufo-thinks-about-fishsticks", + "bufo-thinks-about-mountains", + "bufo-thinks-about-omelette", + "bufo-thinks-about-pancakes", + "bufo-thinks-about-quarter", + "bufo-thinks-about-redis", + "bufo-thinks-about-rubberduck", + "bufo-thinks-about-steak", + "bufo-thinks-about-steakholder", + "bufo-thinks-about-teams", + "bufo-thinks-about-telemetry", + "bufo-thinks-about-terraform", + "bufo-thinks-about-ufo", + "bufo-thinks-about-vacation", + "bufo-thinks-he-gets-paid-too-much-to-work-here", + "bufo-thinks-of-shamenun", + "bufo-thinks-this-is-a-total-bop", + "bufo-this", + "bufo-this-is-fine", + "bufo-this2", + "bufo-thonk", + "bufo-thonks-from-the-void", + "bufo-threatens-to-hit-you-with-the-chancla-and-he-means-it", + "bufo-threatens-to-thwack-you-with-a-slipper-and-he-means-it", + "bufo-throws-brick", + "bufo-thumbsup", + "bufo-thunk", + "bufo-thwack", + "bufo-timeout", + "bufo-tin-foil-hat", + "bufo-tin-foil-hat2", + "bufo-tips-hat", + "bufo-tired", + "bufo-tired-of-rooting-for-the-anti-hero", + "bufo-tired-yes", + "bufo-toad", + "bufo-tofu", + "bufo-toilet-rocket", + "bufo-tomato", + "bufo-tongue", + "bufo-too-many-pings", + "bufo-took-too-much", + "bufo-tooth", + "bufo-tophat", + "bufo-tortoise", + "bufo-torus", + "bufo-trailhead", + "bufo-train", + "bufo-transfixed", + "bufo-transmutes-reality", + "bufo-trash-can", + "bufo-travels", + "bufo-tries-some-yummy-yummy-crossplane", + "bufo-tries-to-fight-you-but-his-arms-are-too-short-so-count-yourself-lucky", + "bufo-tries-to-hug-you-back-but-his-arms-are-too-short", + "bufo-tries-to-hug-you-but-his-arms-are-too-short", + "bufo-triple-vaccinated", + "bufo-tripping", + "bufo-trying-to-relax-while-procrastinating-but-its-not-working", + "bufo-turns-the-tables", + "bufo-tux", + "bufo-typing", + "bufo-u-dead", + "bufo-ufo", + "bufo-ugh", + "bufo-uh-okay-i-guess-so", + "bufo-uhhh", + "bufo-underpaid-postage-at-usps-and-now-they're-coming-after-him-for-the-money-he-owes", + "bufo-unicorn", + "bufo-universe", + "bufo-unlocked-transdimensional-travel-while-in-the-void", + "bufo-uno", + "bufo-upvote", + "bufo-uses-100-percent-of-his-brain", + "bufo-uwu", + "bufo-vaccinated", + "bufo-vaccinates-you", + "bufo-vampire", + "bufo-venom", + "bufo-ventilator", + "bufo-very-angry", + "bufo-vibe", + "bufo-vibe-dance", + "bufo-vomit", + "bufo-voted", + "bufo-waddle", + "bufo-waiting-for-aws-to-deep-archive-our-data", + "bufo-waiting-for-azure", + "bufo-waits-in-queue", + "bufo-waldo", + "bufo-walk-away", + "bufo-wallop", + "bufo-wants-a-refund", + "bufo-wants-to-have-a-calm-and-civilized-conversation-with-you", + "bufo-wants-to-know-your-spaghetti-policy-at-the-movies", + "bufo-wants-to-return-his-vacuum-that-he-bought-at-costco-four-years-ago-for-a-full-refund", + "bufo-wants-you-to-buy-his-crypto", + "bufo-wards-off-the-evil-spirits", + "bufo-warhol", + "bufo-was-eavesdropping-and-got-offended-by-your-convo-but-now-has-to-pretend-he-didnt-hear-you", + "bufo-was-in-paris", + "bufo-wat", + "bufo-watches-from-a-distance", + "bufo-watches-the-rain", + "bufo-watching-the-clock", + "bufo-watermelon", + "bufo-wave", + "bufo-waves-hello-from-the-void", + "bufo-wears-a-paper-crown", + "bufo-wears-the-cone-of-shame", + "bufo-wedding", + "bufo-welcome", + "bufo-welp", + "bufo-whack", + "bufo-what-are-you-doing-with-that", + "bufo-what-did-you-just-say", + "bufo-what-have-i-done", + "bufo-what-have-you-done", + "bufo-what-if", + "bufo-whatever", + "bufo-whew", + "bufo-whisky", + "bufo-who-me", + "bufo-wholesome", + "bufo-why-must-it-all-be-this-way", + "bufo-why-must-it-be-this-way", + "bufo-wicked", + "bufo-wide", + "bufo-wider-01", + "bufo-wider-02", + "bufo-wider-03", + "bufo-wider-04", + "bufo-wields-mjolnir", + "bufo-wields-the-hylian-shield", + "bufo-will-miss-you", + "bufo-will-never-walk-cornelia-street-again", + "bufo-will-not-be-going-to-space-today", + "bufo-wine", + "bufo-wink", + "bufo-wishes-you-a-happy-valentines-day", + "bufo-with-a-drive-by-hot-take", + "bufo-with-a-fresh-do", + "bufo-with-a-pearl-earring", + "bufo-wizard", + "bufo-wizard-magic-charge", + "bufo-wonders-if-deliciousness-of-this-cheese-is-worth-the-pain-his-lactose-intolerance-will-cause", + "bufo-workin-up-a-sweat-after-eating-a-wendys-double-loaded-double-baked-baked-potato-during-summer", + "bufo-worldstar", + "bufo-worried", + "bufo-worry", + "bufo-worry-coffee", + "bufo-would-like-a-bite-of-your-cookie", + "bufo-writes-a-doc", + "bufo-wtf", + "bufo-wut", + "bufo-yah", + "bufo-yay", + "bufo-yay-awkward-eyes", + "bufo-yay-confetti", + "bufo-yay-judge", + "bufo-yayy", + "bufo-yeehaw", + "bufo-yells-at-old-bufo", + "bufo-yes", + "bufo-yismail", + "bufo-you-sure-about-that", + "bufo-yugioh", + "bufo-yummy", + "bufo-zoom", + "bufo-zoom-right", + "bufo's-a-gamer-girl-but-specifically-nyt-games", + "bufo+1", + "bufobot", + "bufochu", + "bufocopter", + "bufoda", + "bufodile", + "bufofoop", + "bufoheimer", + "bufohub", + "bufolatro", + "bufoling", + "bufolo", + "bufolta", + "bufonana", + "bufone", + "bufonomical", + "bufopilot", + "bufopoof", + "buforang", + "buforce-be-with-you", + "buforead", + "buforever", + "bufos-got-your-back", + "bufos-in-love", + "bufos-jumping-on-the-bed", + "bufos-lips-are-sealed", + "bufovacado", + "bufowhirl", + "bufrogu", + "but-wait-theres-bufo", + "child-bufo-only-has-deku-sticks-to-save-hyrule", + "chonky-bufo-wants-to-be-held", + "christmas-bufo-on-a-goose", + "circle-of-bufo", + "confused-math-bufo", + "constipated-bufo-is-trying-his-hardest", + "copper-bufo", + "corrupted-bufo", + "count-bufo", + "daily-dose-of-bufo-vitamins", + "dalmatian-bufo", + "death-by-a-thousand-bufo-stabs", + "doctor-bufo", + "dont-make-bufo-tap-the-sign", + "double-bufo-sideeye", + "egg-bufo", + "eggplant-bufo", + "et-tu-bufo", + "everybody-loves-bufo", + "existential-bufo", + "feelsgoodbufo", + "fix-it-bufo", + "friendly-neighborhood-bufo", + "future-bufos", + "get-in-lets-bufo", + "get-out-of-bufos-swamp", + "ghost-bufo-of-future-past-is-disappointed-in-your-lack-of-foresight", + "gold-bufo", + "good-news-bufo-offers-suppository", + "google-sheet-bufo", + "great-white-bufo", + "happy-bufo-brings-you-a-deescalation-coffee", + "happy-bufo-brings-you-a-deescalation-tea", + "heavy-is-the-bufo-that-wears-the-crown", + "holiday-bufo-offers-you-a-candy-cane", + "house-of-bufo", + "i-dont-trust-bufo", + "i-heart-bufo", + "i-think-you-should-leave-with-bufo", + "if-bufo-fits-bufo-sits", + "interdimensional-bufo-rests-atop-the-terrarium-of-existence", + "it-takes-a-bufo-to-know-a-bufo", + "its-been-such-a-long-day-that-bufo-doesnt-really-care-anymore", + "just-a-bunch-of-bufos", + "just-hear-bufo-out-for-a-sec", + "kermit-the-bufo", + "king-bufo", + "kirbufo", + "le-bufo", + "live-laugh-bufo", + "loch-ness-bufo", + "looks-good-to-bufo", + "low-fidelity-bufo-cant-believe-youve-done-this", + "low-fidelity-bufo-concerned", + "low-fidelity-bufo-excited", + "low-fidelity-bufo-gets-whiplash", + "m-bufo", + "maam-this-is-a-bufo", + "many-bufos", + "maybe-a-bufo-bigfoot", + "mega-bufo", + "mrs-bufo", + "my-name-is-buford-and-i-am-bufo's-father", + "nobufo", + "not-bufo", + "nothing-inauthentic-bout-this-bufo-yeah-hes-the-real-thing-baby", + "old-bufo-yells-at-cloud", + "old-bufo-yells-at-hubble", + "old-man-yells-at-bufo", + "old-man-yells-at-old-bufo", + "one-of-101-bufos", + "our-bufo-is-in-another-castle", + "paper-bufo", + "party-bufo", + "pixel-bufo", + "planet-bufo", + "please-converse-using-only-bufo", + "poison-dart-bufo", + "pour-one-out-for-bufo", + "press-x-to-bufo", + "princebufo", + "proud-bufo-is-excited", + "radioactive-bufo", + "sad-bufo", + "safe-driver-bufo", + "se%C3%B1or-bufo", + "sen%CC%83or-bufo", + "shiny-bufo", + "shut-up-and-take-my-bufo", + "silver-bufo", + "sir-bufo-esquire", + "sir-this-is-a-bufo", + "sleepy-bufo", + "smol-bufo-feels-blessed", + "smol-bufo-has-a-smol-pull-request-that-needs-reviews-and-he-promises-it-will-only-take-a-minute", + "so-bufoful", + "spider-bufo", + "spotify-wrapped-reminded-bufo-his-listening-patterns-are-a-little-unhinged", + "super-bufo", + "super-bufo-bros", + "tabufo", + "teamwork-makes-the-bufo-work", + "ted-bufo", + "the_bufo_formerly_know_as_froge", + "the-bufo-nightmare-before-christmas", + "the-bufo-we-deserve", + "the-bufos-new-groove", + "the-creation-of-bufo", + "the-more-you-bufo", + "the-pinkest-bufo-there-ever-was", + "theres-a-bufo-for-that", + "this-8-dollar-starbucks-drink-isnt-helping-bufo-feel-any-better", + "this-is-bufo", + "this-will-be-bufos-little-secret", + "triumphant-bufo", + "tsa-bufo-gropes-you", + "two-bufos-beefin", + "up-and-to-the-bufo", + "vin-bufo", + "vintage-bufo", + "whatever-youre-doing-its-attracting-the-bufos", + "when-bufo-falls-in-love", + "whenlifegetsatbufo", + "with-friends-like-this-bufo-doesnt-need-enemies", + "wreck-it-bufo", + "wrong-frog", + "yay-bufo-1", + "yay-bufo-2", + "yay-bufo-3", + "yay-bufo-4", + "you-have-awoken-the-bufo", + "you-have-exquisite-taste-in-bufo", + "you-left-your-typewriter-at-bufos-apartment" +] diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..42eb403 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/site/fly.toml b/site/fly.toml new file mode 100644 index 0000000..29dd762 --- /dev/null +++ b/site/fly.toml @@ -0,0 +1,17 @@ +app = "quickslice-status" +primary_region = "ewr" + +[build] + dockerfile = "Dockerfile" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 256 diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..04e5b65 --- /dev/null +++ b/site/index.html @@ -0,0 +1,49 @@ + + + + + + status + + + + + +
+
+

status

+ +
+ +
+
loading...
+
+
+ + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..9739ea1 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,791 @@ +:root { + --bg: #0a0a0a; + --bg-card: #1a1a1a; + --text: #ffffff; + --text-secondary: #888; + --accent: #4a9eff; + --border: #2a2a2a; + --radius: 12px; + --font-family: ui-monospace, "SF Mono", Monaco, monospace; +} + +[data-theme="light"] { + --bg: #ffffff; + --bg-card: #f5f5f5; + --text: #1a1a1a; + --text-secondary: #666; + --border: #e0e0e0; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Theme-aware scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg); +} + +body { + font-family: var(--font-family); + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; +} + +#app { + max-width: 600px; + margin: 0 auto; + padding: 2rem 1rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +header h1 { + font-size: 1.5rem; + font-weight: 600; +} + +nav { + display: flex; + gap: 1rem; + align-items: center; +} + +nav a { + color: var(--text-secondary); + text-decoration: none; +} + +nav a:hover { + color: var(--accent); +} + +.nav-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + border-radius: 8px; + transition: background 0.15s, color 0.15s; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; +} + +.nav-btn:hover { + background: var(--bg-card); + color: var(--accent); +} + +.nav-btn.active { + color: var(--accent); +} + +.nav-btn svg { + display: block; +} + +#theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + font-size: 1rem; +} + +#theme-toggle .sun { display: none; } +#theme-toggle .moon { display: inline; color: var(--text); } +[data-theme="light"] #theme-toggle .sun { display: inline; color: var(--text); } +[data-theme="light"] #theme-toggle .moon { display: none; } + +.hidden { display: none !important; } +.center { text-align: center; padding: 2rem; } + +/* Login form */ +#login-form { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + justify-content: center; +} + +#login-form input { + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + font-family: inherit; + font-size: 1rem; + width: 200px; +} + +#login-form button, button[type="submit"] { + padding: 0.75rem 1.5rem; + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-family: inherit; + font-size: 1rem; +} + +#login-form button:hover, button[type="submit"]:hover { + opacity: 0.9; +} + +/* Profile card */ +.profile-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + margin-bottom: 1.5rem; +} + +.current-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; +} + +.big-emoji { + font-size: 4rem; + line-height: 1; +} + +.big-emoji img { + width: 4rem; + height: 4rem; + object-fit: contain; +} + +.status-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +#current-text { + font-size: 1.25rem; +} + +.meta { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Status form */ +.status-form { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + margin-bottom: 1.5rem; +} + +.emoji-input-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.emoji-input-row input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 1rem; +} + +#emoji-input { + max-width: 150px; +} + +.form-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.form-actions select { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +.custom-datetime { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +/* History */ +.history { + margin-bottom: 2rem; +} + +.history h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 1rem; +} + +#history-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Feed list */ +.feed-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Status item (used in both history and feed) */ +.status-item { + display: flex; + gap: 1rem; + padding: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + align-items: flex-start; +} + +.status-item:hover { + border-color: var(--accent); +} + +.status-item .emoji { + font-size: 1.5rem; + line-height: 1; + flex-shrink: 0; +} + +.status-item .emoji img { + width: 1.5rem; + height: 1.5rem; + object-fit: contain; +} + +.status-item .content { + flex: 1; + min-width: 0; +} + +.status-item .author { + color: var(--text-secondary); + font-weight: 600; + text-decoration: none; +} + +.status-item .author:hover { + color: var(--accent); +} + +.status-item .text { + margin-left: 0.5rem; +} + +.status-item .time { + display: block; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.delete-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s, color 0.15s; + flex-shrink: 0; +} + +.status-item:hover .delete-btn { + opacity: 1; +} + +.delete-btn:hover { + color: #e74c3c; +} + +/* Logout */ +.logout-btn { + display: block; + margin: 0 auto; + padding: 0.5rem 1rem; + background: none; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + font-family: inherit; +} + +.logout-btn:hover { + border-color: var(--text); + color: var(--text); +} + +/* Load more */ +#load-more-btn { + padding: 0.75rem 1.5rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-family: inherit; +} + +#load-more-btn:hover { + border-color: var(--accent); +} + +/* Emoji trigger button */ +.emoji-trigger { + width: 3rem; + height: 3rem; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + flex-shrink: 0; +} + +.emoji-trigger:hover { + background: var(--bg-card); +} + +.emoji-trigger img { + width: 2.5rem; + height: 2.5rem; + object-fit: contain; +} + +/* Emoji picker overlay */ +.emoji-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.emoji-picker { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 600px; + height: 90vh; + max-height: 700px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.emoji-picker-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.emoji-picker-header h3 { + font-size: 1rem; + font-weight: 600; +} + +.emoji-picker-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.25rem; + padding: 0.25rem; +} + +.emoji-picker-close:hover { + color: var(--text); +} + +.emoji-search { + margin: 0.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.875rem; +} + +.emoji-categories { + display: flex; + gap: 0.25rem; + padding: 0 0.75rem; + overflow-x: auto; + flex-shrink: 0; +} + +.category-btn { + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + font-size: 1.25rem; + border-radius: 8px; + opacity: 0.5; + transition: opacity 0.15s; +} + +.category-btn:hover, .category-btn.active { + opacity: 1; + background: var(--bg); +} + +.emoji-grid { + padding: 0.75rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); + gap: 0.25rem; + overflow-y: auto; + flex: 1; + min-height: 200px; + align-content: start; +} + +.emoji-grid.bufo-grid { + grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); + gap: 0.5rem; +} + +.emoji-btn { + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + font-size: 1.5rem; + border-radius: 8px; + transition: background 0.15s; +} + +.emoji-btn:hover { + background: var(--bg); +} + +/* Consistent sizing for mixed emoji/bufo grids (frequent tab) */ +.emoji-grid .emoji-btn { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; +} + +.bufo-btn { + padding: 0.25rem; +} + +.bufo-grid .bufo-btn { + width: 64px; + height: 64px; +} + +.bufo-btn img { + width: 100%; + height: 100%; + max-width: 48px; + max-height: 48px; + object-fit: contain; +} + +.loading { + grid-column: 1 / -1; + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +.no-results { + grid-column: 1 / -1; + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +/* Custom emoji input */ +.custom-emoji-input { + grid-column: 1 / -1; + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.custom-emoji-input input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; +} + +.custom-emoji-input button { + padding: 0.5rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-family: inherit; +} + +.custom-emoji-preview { + grid-column: 1 / -1; + display: flex; + justify-content: center; + min-height: 80px; + align-items: center; +} + +.bufo-helper { + padding: 0.75rem; + text-align: center; + border-top: 1px solid var(--border); +} + +.bufo-helper a { + color: var(--accent); + font-size: 0.875rem; +} + +/* Settings Modal */ +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.settings-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.settings-header h3 { + font-size: 1.1rem; + font-weight: 500; +} + +.settings-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.25rem; + padding: 0.25rem; +} + +.settings-close:hover { + color: var(--text); +} + +.settings-content { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.setting-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.setting-group label { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.setting-group select { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 1rem; +} + +.color-picker { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.color-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.color-btn:hover { + transform: scale(1.1); +} + +.color-btn.active { + border-color: var(--text); +} + +.custom-color-input { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + cursor: pointer; + background: none; + padding: 0; +} + +.custom-color-input::-webkit-color-swatch-wrapper { + padding: 0; +} + +.custom-color-input::-webkit-color-swatch { + border: 2px solid var(--border); + border-radius: 50%; +} + +.settings-footer { + padding: 1rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; +} + +.settings-footer .save-btn { + padding: 0.75rem 1.5rem; + background: var(--accent); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 1rem; +} + +.settings-footer .save-btn:hover { + opacity: 0.9; +} + +.settings-footer .save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mobile */ +@media (max-width: 480px) { + .emoji-input-row { + flex-direction: row; + } + + .form-actions { + flex-direction: column; + } + + .emoji-grid { + grid-template-columns: repeat(6, 1fr); + } +} From 18d044156ebca08e43f38fb37d724cff4e27f35e Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 13 Dec 2025 17:27:07 -0600 Subject: [PATCH 2/5] rename site to docs for github pages --- {site => docs}/Caddyfile | 0 docs/Caddyfile.dev | 10 ++++++++++ {site => docs}/Dockerfile | 0 {site => docs}/app.js | 0 {site => docs}/bufos.json | 0 {site => docs}/favicon.svg | 0 {site => docs}/fly.toml | 0 {site => docs}/index.html | 0 {site => docs}/styles.css | 0 9 files changed, 10 insertions(+) rename {site => docs}/Caddyfile (100%) create mode 100644 docs/Caddyfile.dev rename {site => docs}/Dockerfile (100%) rename {site => docs}/app.js (100%) rename {site => docs}/bufos.json (100%) rename {site => docs}/favicon.svg (100%) rename {site => docs}/fly.toml (100%) rename {site => docs}/index.html (100%) rename {site => docs}/styles.css (100%) diff --git a/site/Caddyfile b/docs/Caddyfile similarity index 100% rename from site/Caddyfile rename to docs/Caddyfile diff --git a/docs/Caddyfile.dev b/docs/Caddyfile.dev new file mode 100644 index 0000000..302ba9a --- /dev/null +++ b/docs/Caddyfile.dev @@ -0,0 +1,10 @@ +{ + admin off +} + +:8000 { + root * . + encode gzip + file_server + try_files {path} /index.html +} diff --git a/site/Dockerfile b/docs/Dockerfile similarity index 100% rename from site/Dockerfile rename to docs/Dockerfile diff --git a/site/app.js b/docs/app.js similarity index 100% rename from site/app.js rename to docs/app.js diff --git a/site/bufos.json b/docs/bufos.json similarity index 100% rename from site/bufos.json rename to docs/bufos.json diff --git a/site/favicon.svg b/docs/favicon.svg similarity index 100% rename from site/favicon.svg rename to docs/favicon.svg diff --git a/site/fly.toml b/docs/fly.toml similarity index 100% rename from site/fly.toml rename to docs/fly.toml diff --git a/site/index.html b/docs/index.html similarity index 100% rename from site/index.html rename to docs/index.html diff --git a/site/styles.css b/docs/styles.css similarity index 100% rename from site/styles.css rename to docs/styles.css From 3eb8f5c058520a0905f116c28a6bcb2a4af43269 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 13 Dec 2025 17:48:54 -0600 Subject: [PATCH 3/5] deploy to fly.io + cloudflare pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build quickslice from source at v0.17.3 (includes sub claim fix) - frontend on cloudflare pages, backend on fly.io - add readme with deployment docs - clean up old deployment artifacts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 +- Dockerfile | 74 +++++++++++++++++++++++++++++++++++++ README.md | 74 +++++++++++++++++++++++++++++++++++++ docs/Caddyfile | 10 ----- docs/Caddyfile.dev | 10 ----- docs/Dockerfile | 9 ----- docs/fly.toml | 17 --------- fly.toml | 2 +- lexicons.zip | Bin 1140 -> 0 bytes {docs => site}/app.js | 3 ++ {docs => site}/bufos.json | 0 {docs => site}/favicon.svg | 0 {docs => site}/index.html | 0 {docs => site}/styles.css | 0 14 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 Dockerfile create mode 100644 README.md delete mode 100644 docs/Caddyfile delete mode 100644 docs/Caddyfile.dev delete mode 100644 docs/Dockerfile delete mode 100644 docs/fly.toml delete mode 100644 lexicons.zip rename {docs => site}/app.js (99%) rename {docs => site}/bufos.json (100%) rename {docs => site}/favicon.svg (100%) rename {docs => site}/index.html (100%) rename {docs => site}/styles.css (100%) diff --git a/.gitignore b/.gitignore index 62ec39d..89fd582 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# local dev files -site/Caddyfile.dev +# wrangler/cloudflare +.wrangler/ # notes oauth-experience.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0830073 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +ARG GLEAM_VERSION=v1.13.0 + +# Build stage - compile the application +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder + +# Install build dependencies (including PostgreSQL client for multi-database support) +RUN apk add --no-cache \ + bash \ + git \ + nodejs \ + npm \ + build-base \ + sqlite-dev \ + postgresql-dev + +# Configure git for non-interactive use +ENV GIT_TERMINAL_PROMPT=0 + +# Clone quickslice at the v0.17.3 tag (includes sub claim fix) +RUN git clone --depth 1 --branch v0.17.3 https://github.com/bigmoves/quickslice.git /build + +# Install dependencies for all projects +RUN cd /build/client && gleam deps download +RUN cd /build/lexicon_graphql && gleam deps download +RUN cd /build/server && gleam deps download + +# Apply patches to dependencies +RUN cd /build && patch -p1 < patches/mist-websocket-protocol.patch + +# Install JavaScript dependencies for client +RUN cd /build/client && npm install + +# Compile the client code and output to server's static directory +RUN cd /build/client \ + && gleam add --dev lustre_dev_tools \ + && gleam run -m lustre/dev build quickslice_client --minify --outdir=/build/server/priv/static + +# Compile the server code +RUN cd /build/server \ + && gleam export erlang-shipment + +# Runtime stage - slim image with only what's needed to run +FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine + +# Install runtime dependencies and dbmate for migrations +ARG TARGETARCH +RUN apk add --no-cache sqlite-libs sqlite libpq curl \ + && DBMATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \ + && curl -fsSL -o /usr/local/bin/dbmate https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-${DBMATE_ARCH} \ + && chmod +x /usr/local/bin/dbmate + +# Copy the compiled server code from the builder stage +COPY --from=builder /build/server/build/erlang-shipment /app + +# Copy database migrations and config +COPY --from=builder /build/server/db /app/db +COPY --from=builder /build/server/.dbmate.yml /app/.dbmate.yml +COPY --from=builder /build/server/docker-entrypoint.sh /app/docker-entrypoint.sh + +# Set up the entrypoint +WORKDIR /app + +# Create the data directory for the SQLite database and Fly.io volume mount +RUN mkdir -p /data && chmod 755 /data + +# Set environment variables +ENV HOST=0.0.0.0 +ENV PORT=8080 + +# Expose the port the server will run on +EXPOSE $PORT + +# Run the server +CMD ["/app/docker-entrypoint.sh", "run"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0c90df --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# quickslice-status + +a status app for bluesky, built with [quickslice](https://github.com/bigmoves/quickslice). + +**live:** https://quickslice-status.pages.dev + +## architecture + +- **backend**: [quickslice](https://github.com/bigmoves/quickslice) on fly.io - handles oauth, graphql api, jetstream ingestion +- **frontend**: static site on cloudflare pages - vanilla js spa + +## deployment + +### backend (fly.io) + +builds quickslice from source at v0.17.3 tag. + +```bash +fly deploy +``` + +required secrets: +```bash +fly secrets set SECRET_KEY_BASE="$(openssl rand -base64 64 | tr -d '\n')" +fly secrets set OAUTH_SIGNING_KEY="$(goat key generate -t p256 | tail -1)" +``` + +### frontend (cloudflare pages) + +```bash +cd site +npx wrangler pages deploy . --project-name=quickslice-status +``` + +## oauth client registration + +register an oauth client in the quickslice admin ui at `https://zzstoatzz-quickslice-status.fly.dev/` + +redirect uri: `https://quickslice-status.pages.dev/callback` + +## lexicon + +uses `io.zzstoatzz.status` lexicon for user statuses. + +```json +{ + "lexicon": 1, + "id": "io.zzstoatzz.status", + "defs": { + "main": { + "type": "record", + "key": "self", + "record": { + "type": "object", + "required": ["status", "createdAt"], + "properties": { + "status": { "type": "string", "maxLength": 128 }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} +``` + +## local development + +serve the frontend locally: +```bash +cd site +python -m http.server 8000 +``` + +for oauth to work locally, you'd need to register a separate oauth client with `http://localhost:8000/callback` as the redirect uri and update `CONFIG.clientId` in `app.js`. diff --git a/docs/Caddyfile b/docs/Caddyfile deleted file mode 100644 index a87a48c..0000000 --- a/docs/Caddyfile +++ /dev/null @@ -1,10 +0,0 @@ -{ - admin off -} - -:8000 { - root * /srv - encode gzip - file_server - try_files {path} /index.html -} diff --git a/docs/Caddyfile.dev b/docs/Caddyfile.dev deleted file mode 100644 index 302ba9a..0000000 --- a/docs/Caddyfile.dev +++ /dev/null @@ -1,10 +0,0 @@ -{ - admin off -} - -:8000 { - root * . - encode gzip - file_server - try_files {path} /index.html -} diff --git a/docs/Dockerfile b/docs/Dockerfile deleted file mode 100644 index afaf6ff..0000000 --- a/docs/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM caddy:2-alpine - -COPY Caddyfile /etc/caddy/Caddyfile -COPY index.html /srv/index.html -COPY app.js /srv/app.js -COPY styles.css /srv/styles.css -COPY favicon.svg /srv/favicon.svg - -EXPOSE 8000 diff --git a/docs/fly.toml b/docs/fly.toml deleted file mode 100644 index 29dd762..0000000 --- a/docs/fly.toml +++ /dev/null @@ -1,17 +0,0 @@ -app = "quickslice-status" -primary_region = "ewr" - -[build] - dockerfile = "Dockerfile" - -[http_service] - internal_port = 8000 - force_https = true - auto_stop_machines = "stop" - auto_start_machines = true - min_machines_running = 0 - -[[vm]] - cpu_kind = "shared" - cpus = 1 - memory_mb = 256 diff --git a/fly.toml b/fly.toml index 9890b73..50013c9 100644 --- a/fly.toml +++ b/fly.toml @@ -7,7 +7,7 @@ app = 'zzstoatzz-quickslice-status' primary_region = 'ewr' [build] - image = 'ghcr.io/bigmoves/quickslice:latest' + dockerfile = 'Dockerfile' [env] DATABASE_URL = 'sqlite:/data/quickslice.db' diff --git a/lexicons.zip b/lexicons.zip deleted file mode 100644 index 67572131e4b630be1ad91c205a602973318525fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1140 zcmWIWW@h1H00EK4-e@obN^mmBFyy3GWG3h573+tFa56AIe{7ri28c^5xEUB(zA`c} zu!sN^2LO!#(Hsnuih83zzsOzY2b6ls3^Wqih~kpOlG0+mtm6DUuxXP|*=B+;nrT~u z4*DH7;Mx0Ld(9jNsi_Vd4(H9;p-}kX0Ary?^!l$6vnK3V^!iG@`pHw%czGuXOh0zM zZrl8~d1?$vt+JxK1dDgfVe?sP&iZoAd)e%hweu~f-Pv^}X<@FW(&{6vpZD}`w+)&! zOZL-@>ukCAufX7l8lx%wL)$eM4uAGJy1$9blyag$~=Y3O(z`!wyS z_aW0T=`B7l_*VTsVfOLD%q^ukD_2h1W*D_dTSs!)4xNt@)0sAkWN^E_zP{A$O;|2# z`i#(lFXP_1W{|rJ|;t6cDyY> zDCnH|sdag2@r%pDW=Vy{>{Fj}rEl&>c3ZoP{h!2dX-T$;Ew#6ueCR%(g!Cx}g(W{L zr!vO#?NBrC)Au`d_kY%Z-5)EDsa<7xf9c?je-8Jb+_<&xp|wvNKO$~G(aXNdZ>R|7` zcf{F8J-6)g5|zyt%3ju&Ptx7Jalv%kB{NS}fBrZ_j{De-Mn~`TpVMroY`ktdHD-6# zEGDbQ%OR{H6&#`O9UIxT6+fh`b!0V?U%70Z@U3~)uL|ENF0%1>{@P^Xz3&Fc(iaQv zF}Tg(7JKAq&A}9f?|Yd$)Acp?mfKcb5vcQ>ptOXgTmPKJ{z*m&4(oF#Gfs|jo)Gta z#pR$M(mXzHe8DAtcYcSNuU**OUBVq2ym5AK1>4eh*N*t{yibtM>HI#$v-sWPQ;~a& zUTZ7P5SnYIy!+4E|Br0){+(&oEh$J{(0}5@qMXQEQ75w=epg+5w$)IfB%9}kv-{`y zHa9Ch6Qnx6Kl*1V&Kv~>J0p`EGp>9k0nK3o3~wDlOr%`K3dv Date: Sat, 13 Dec 2025 17:54:43 -0600 Subject: [PATCH 4/5] remove rust CI and fly review app workflows --- .github/workflows/ci.yml | 33 -------------------- .github/workflows/fly-review.yml | 52 -------------------------------- .github/workflows/fly.yml | 16 ---------- 3 files changed, 101 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/fly-review.yml delete mode 100644 .github/workflows/fly.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e56b39a..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -env: - CARGO_TERM_COLOR: always - -jobs: - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt -- --check - - - name: Build - run: cargo build --verbose - - - name: Run clippy - run: cargo clippy -- -D warnings - - - name: Run tests - run: cargo test --verbose \ No newline at end of file diff --git a/.github/workflows/fly-review.yml b/.github/workflows/fly-review.yml deleted file mode 100644 index a7051f5..0000000 --- a/.github/workflows/fly-review.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy Review App -on: - # Run this workflow on every PR event. Existing review apps will be updated when the PR is updated. - pull_request: - types: [opened, reopened, synchronize, closed] - paths: - - 'src/**' - - 'templates/**' - - 'static/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'Dockerfile' - - 'fly.toml' - - 'fly.review.toml' - - 'build.rs' - - 'sqlx-data.json' -env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - # Set these to your Fly.io organization and preferred region. - FLY_REGION: ewr - FLY_ORG: personal - -jobs: - review_app: - runs-on: ubuntu-latest - outputs: - url: ${{ steps.deploy.outputs.url }} - # Only run one deployment at a time per PR. - concurrency: - group: pr-${{ github.event.number }} - - # Deploying apps with this "review" environment allows the URL for the app to be displayed in the PR UI. - environment: - name: review - # The script in the `deploy` sets the URL output for each review app. - url: ${{ steps.deploy.outputs.url }} - steps: - - name: Get code - uses: actions/checkout@v4 - - - name: Deploy PR app to Fly.io - id: deploy - uses: superfly/fly-pr-review-apps@1.2.1 - with: - name: zzstoatzz-status-pr-${{ github.event.number }} - config: fly.review.toml - # Use smaller resources for review apps - vmsize: shared-cpu-1x - memory: 256 - # Set OAUTH_REDIRECT_BASE dynamically for OAuth redirects - secrets: | - OAUTH_REDIRECT_BASE=https://zzstoatzz-status-pr-${{ github.event.number }}.fly.dev \ No newline at end of file diff --git a/.github/workflows/fly.yml b/.github/workflows/fly.yml deleted file mode 100644 index b5e71da..0000000 --- a/.github/workflows/fly.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Fly Deploy -on: - push: - branches: - - main -jobs: - deploy: - name: Deploy app - runs-on: ubuntu-latest - concurrency: deploy-group # ensure only one action runs at a time - steps: - - uses: actions/checkout@v4 - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} \ No newline at end of file From 6bbb9a2a8c1e9645c97b763df39b11994c258bc8 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 13 Dec 2025 17:56:53 -0600 Subject: [PATCH 5/5] pin quickslice client to v0.17.3 --- site/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/index.html b/site/index.html index 04e5b65..51ab031 100644 --- a/site/index.html +++ b/site/index.html @@ -6,7 +6,7 @@ status - +