search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

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

add loading state handler for dashboard cold starts

portable loading.js module:
- skeleton shimmer while loading
- "waking up" message after 2s threshold (fly.io cold start)
- smooth reveal on data load

designed to be easily copied to other projects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 5d3f0368 54387fad

+172
+1
site/dashboard.html
··· 67 67 </footer> 68 68 </div> 69 69 70 + <script src="loading.js"></script> 70 71 <script src="dashboard.js"></script> 71 72 </body> 72 73 </html>
+11
site/dashboard.js
··· 2 2 3 3 let startedAt = 0; 4 4 5 + // loading state handler 6 + const loader = createLoader({ 7 + container: '.container', 8 + wakeThreshold: 2000, 9 + }); 10 + 5 11 function formatAge(ms) { 6 12 const s = Math.floor(ms / 1000); 7 13 const d = Math.floor(s / 86400); ··· 66 72 } 67 73 68 74 async function fetchDashboard() { 75 + loader.start(); 76 + 69 77 try { 70 78 const r = await fetch(API_BASE + '/api/dashboard'); 71 79 const data = await r.json(); ··· 81 89 renderTimeline(data.timeline); 82 90 renderPubs(data.topPubs); 83 91 renderTags(data.tags); 92 + 93 + loader.done(); 84 94 } catch (e) { 85 95 console.error('failed to fetch dashboard:', e); 96 + loader.done(); 86 97 } 87 98 } 88 99
+160
site/loading.js
··· 1 + /** 2 + * loading.js - portable loading state handler for dashboards 3 + * 4 + * handles cold-start backends gracefully: 5 + * - immediate: shows skeleton shimmer 6 + * - after threshold: shows "waking up" message 7 + * - on success: reveals content smoothly 8 + * 9 + * usage: 10 + * const loader = createLoader({ 11 + * container: '#my-dashboard', 12 + * wakeThreshold: 2000, // ms before showing "waking up" 13 + * onWake: () => {}, // optional callback when wake message shows 14 + * }); 15 + * 16 + * loader.start(); 17 + * await fetchData(); 18 + * loader.done(); 19 + */ 20 + 21 + function createLoader(opts = {}) { 22 + const threshold = opts.wakeThreshold || 2000; 23 + const onWake = opts.onWake || null; 24 + 25 + let wakeTimer = null; 26 + let wakeEl = null; 27 + let startTime = 0; 28 + 29 + function start() { 30 + startTime = Date.now(); 31 + 32 + // add loading class to body for global styling hooks 33 + document.body.classList.add('loading'); 34 + 35 + // schedule wake message 36 + wakeTimer = setTimeout(() => { 37 + showWakeMessage(); 38 + if (onWake) onWake(); 39 + }, threshold); 40 + } 41 + 42 + function showWakeMessage() { 43 + if (wakeEl) return; 44 + 45 + wakeEl = document.createElement('div'); 46 + wakeEl.className = 'wake-message'; 47 + wakeEl.innerHTML = '<span class="wake-dot"></span> waking up...'; 48 + 49 + // insert at top of container or body 50 + const container = opts.container 51 + ? document.querySelector(opts.container) 52 + : document.body; 53 + 54 + if (container && container.firstChild) { 55 + container.insertBefore(wakeEl, container.firstChild); 56 + } else if (container) { 57 + container.appendChild(wakeEl); 58 + } 59 + } 60 + 61 + function done() { 62 + if (wakeTimer) clearTimeout(wakeTimer); 63 + 64 + document.body.classList.remove('loading'); 65 + document.body.classList.add('loaded'); 66 + 67 + if (wakeEl) { 68 + wakeEl.classList.add('fade-out'); 69 + setTimeout(() => wakeEl.remove(), 300); 70 + } 71 + 72 + return Date.now() - startTime; 73 + } 74 + 75 + return { start, done }; 76 + } 77 + 78 + // css injected once 79 + (function injectStyles() { 80 + if (document.getElementById('loader-styles')) return; 81 + 82 + const style = document.createElement('style'); 83 + style.id = 'loader-styles'; 84 + style.textContent = ` 85 + /* skeleton shimmer for loading values */ 86 + .loading .metric-value, 87 + .loading .doc-count, 88 + .loading .pub-count { 89 + background: linear-gradient(90deg, #1a1a1a 25%, #252525 50%, #1a1a1a 75%); 90 + background-size: 200% 100%; 91 + animation: shimmer 1.5s infinite; 92 + border-radius: 3px; 93 + color: transparent !important; 94 + min-width: 3ch; 95 + display: inline-block; 96 + } 97 + 98 + @keyframes shimmer { 99 + 0% { background-position: 200% 0; } 100 + 100% { background-position: -200% 0; } 101 + } 102 + 103 + /* wake message */ 104 + .wake-message { 105 + position: fixed; 106 + top: 1rem; 107 + right: 1rem; 108 + font-size: 11px; 109 + color: #666; 110 + background: #111; 111 + border: 1px solid #222; 112 + padding: 6px 12px; 113 + border-radius: 4px; 114 + display: flex; 115 + align-items: center; 116 + gap: 8px; 117 + z-index: 1000; 118 + animation: fade-in 0.2s ease; 119 + } 120 + 121 + .wake-dot { 122 + width: 6px; 123 + height: 6px; 124 + background: #4ade80; 125 + border-radius: 50%; 126 + animation: pulse-dot 1s infinite; 127 + } 128 + 129 + @keyframes pulse-dot { 130 + 0%, 100% { opacity: 0.3; } 131 + 50% { opacity: 1; } 132 + } 133 + 134 + @keyframes fade-in { 135 + from { opacity: 0; transform: translateY(-4px); } 136 + to { opacity: 1; transform: translateY(0); } 137 + } 138 + 139 + .wake-message.fade-out { 140 + animation: fade-out 0.3s ease forwards; 141 + } 142 + 143 + @keyframes fade-out { 144 + to { opacity: 0; transform: translateY(-4px); } 145 + } 146 + 147 + /* loaded transition */ 148 + .loaded .metric-value, 149 + .loaded .doc-count, 150 + .loaded .pub-count { 151 + animation: reveal 0.3s ease; 152 + } 153 + 154 + @keyframes reveal { 155 + from { opacity: 0; } 156 + to { opacity: 1; } 157 + } 158 + `; 159 + document.head.appendChild(style); 160 + })();