interactive intro to open social at-me.zzstoatzz.io

make login page fun

Changed files
+313 -13
src
+313 -13
src/templates.rs
··· 8 8 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 9 9 <style> 10 10 * { margin: 0; padding: 0; box-sizing: border-box; } 11 - body { font-family: 'Monaco', 'Courier New', monospace; display: flex; align-items: center; justify-content: center; height: 100vh; background: #000; color: #0f0; } 12 - .container { text-align: center; } 13 - h1 { font-size: 2rem; margin-bottom: 2rem; } 14 - input { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem; margin: 0.5rem; background: #000; border: 1px solid #0f0; color: #0f0; } 15 - button { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem 1rem; cursor: pointer; background: #000; border: 1px solid #0f0; color: #0f0; } 16 - button:hover { background: #0f0; color: #000; } 11 + 12 + body { 13 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 14 + height: 100vh; 15 + background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%); 16 + color: #e5e5e5; 17 + overflow: hidden; 18 + perspective: 1000px; 19 + } 20 + 21 + .atmosphere { 22 + position: fixed; 23 + inset: 0; 24 + transform-style: preserve-3d; 25 + animation: rotate 120s infinite linear; 26 + } 27 + 28 + @keyframes rotate { 29 + from { transform: rotateY(0deg); } 30 + to { transform: rotateY(360deg); } 31 + } 32 + 33 + .app-orb { 34 + position: absolute; 35 + border-radius: 50%; 36 + display: flex; 37 + align-items: center; 38 + justify-content: center; 39 + transition: all 0.3s ease; 40 + cursor: pointer; 41 + backdrop-filter: blur(4px); 42 + } 43 + 44 + .app-orb:hover { 45 + transform: scale(1.2) !important; 46 + z-index: 100; 47 + } 48 + 49 + .app-orb img { 50 + width: 100%; 51 + height: 100%; 52 + border-radius: 50%; 53 + object-fit: cover; 54 + } 55 + 56 + .app-orb .fallback { 57 + font-size: 1.5rem; 58 + font-weight: 600; 59 + color: rgba(255, 255, 255, 0.9); 60 + } 61 + 62 + .app-tooltip { 63 + position: absolute; 64 + background: rgba(10, 10, 15, 0.95); 65 + border: 1px solid rgba(255, 255, 255, 0.1); 66 + padding: 0.5rem 0.75rem; 67 + border-radius: 4px; 68 + font-size: 0.7rem; 69 + white-space: nowrap; 70 + pointer-events: none; 71 + opacity: 0; 72 + transition: opacity 0.2s; 73 + z-index: 1000; 74 + } 75 + 76 + .app-orb:hover .app-tooltip { 77 + opacity: 1; 78 + } 79 + 80 + .container { 81 + position: fixed; 82 + inset: 0; 83 + display: flex; 84 + align-items: center; 85 + justify-content: center; 86 + z-index: 10; 87 + } 88 + 89 + .login-card { 90 + background: rgba(10, 10, 15, 0.8); 91 + border: 1px solid rgba(255, 255, 255, 0.1); 92 + padding: 2.5rem 3rem; 93 + border-radius: 8px; 94 + backdrop-filter: blur(10px); 95 + text-align: center; 96 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 97 + } 98 + 99 + h1 { 100 + font-size: 2rem; 101 + margin-bottom: 0.5rem; 102 + font-weight: 300; 103 + letter-spacing: 0.05em; 104 + } 105 + 106 + .subtitle { 107 + font-size: 0.75rem; 108 + color: rgba(255, 255, 255, 0.5); 109 + margin-bottom: 2rem; 110 + } 111 + 112 + input { 113 + font-family: inherit; 114 + font-size: 0.9rem; 115 + padding: 0.75rem 1rem; 116 + margin-bottom: 1rem; 117 + background: rgba(255, 255, 255, 0.05); 118 + border: 1px solid rgba(255, 255, 255, 0.1); 119 + border-radius: 4px; 120 + color: #e5e5e5; 121 + width: 100%; 122 + min-width: 300px; 123 + transition: all 0.2s; 124 + } 125 + 126 + input:focus { 127 + outline: none; 128 + border-color: rgba(255, 255, 255, 0.3); 129 + background: rgba(255, 255, 255, 0.08); 130 + } 131 + 132 + input::placeholder { 133 + color: rgba(255, 255, 255, 0.3); 134 + } 135 + 136 + button { 137 + font-family: inherit; 138 + font-size: 0.9rem; 139 + padding: 0.75rem 2rem; 140 + cursor: pointer; 141 + background: rgba(255, 255, 255, 0.1); 142 + border: 1px solid rgba(255, 255, 255, 0.2); 143 + border-radius: 4px; 144 + color: #e5e5e5; 145 + transition: all 0.2s; 146 + width: 100%; 147 + } 148 + 149 + button:hover { 150 + background: rgba(255, 255, 255, 0.15); 151 + border-color: rgba(255, 255, 255, 0.3); 152 + } 153 + 17 154 .hidden { display: none; } 18 - .loading { color: #0f0; opacity: 0.5; } 155 + .loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; } 156 + 157 + .footer { 158 + position: fixed; 159 + bottom: 1rem; 160 + left: 50%; 161 + transform: translateX(-50%); 162 + font-size: 0.7rem; 163 + color: rgba(255, 255, 255, 0.3); 164 + z-index: 20; 165 + } 166 + 167 + .footer a { 168 + color: rgba(255, 255, 255, 0.5); 169 + text-decoration: none; 170 + transition: color 0.2s; 171 + } 172 + 173 + .footer a:hover { 174 + color: rgba(255, 255, 255, 0.8); 175 + } 19 176 </style> 20 177 </head> 21 178 <body> 179 + <div class="atmosphere" id="atmosphere"></div> 180 + 22 181 <div class="container"> 23 - <div id="restoring" class="loading hidden">restoring session...</div> 24 - <form id="loginForm" method="post" action="/login"> 25 - <h1>@me</h1> 26 - <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus> 27 - <button type="submit">login</button> 28 - </form> 182 + <div class="login-card"> 183 + <div id="restoring" class="loading hidden">restoring session...</div> 184 + <form id="loginForm" method="post" action="/login"> 185 + <h1>@me</h1> 186 + <div class="subtitle">explore the atmosphere</div> 187 + <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus> 188 + <button type="submit">enter</button> 189 + </form> 190 + </div> 191 + </div> 192 + 193 + <div class="footer"> 194 + by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a> 29 195 </div> 196 + 30 197 <script> 198 + // Check for saved session 31 199 const savedDid = localStorage.getItem('atme_did'); 32 200 if (savedDid) { 33 201 document.getElementById('loginForm').classList.add('hidden'); ··· 51 219 document.getElementById('restoring').classList.add('hidden'); 52 220 }); 53 221 } 222 + 223 + // Fetch and cache atmosphere data 224 + async function fetchAtmosphere() { 225 + const CACHE_KEY = 'atme_atmosphere'; 226 + const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 227 + 228 + const cached = localStorage.getItem(CACHE_KEY); 229 + if (cached) { 230 + const { data, timestamp } = JSON.parse(cached); 231 + if (Date.now() - timestamp < CACHE_DURATION) { 232 + return data; 233 + } 234 + } 235 + 236 + try { 237 + const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50'); 238 + const json = await response.json(); 239 + 240 + // Group by namespace (first two segments) 241 + const namespaces = {}; 242 + json.collections.forEach(col => { 243 + const parts = col.nsid.split('.'); 244 + if (parts.length >= 2) { 245 + const ns = `${parts[0]}.${parts[1]}`; 246 + if (!namespaces[ns]) { 247 + namespaces[ns] = { 248 + namespace: ns, 249 + dids_total: 0, 250 + records_total: 0, 251 + collections: [] 252 + }; 253 + } 254 + namespaces[ns].dids_total += col.dids_estimate; 255 + namespaces[ns].records_total += col.creates; 256 + namespaces[ns].collections.push(col.nsid); 257 + } 258 + }); 259 + 260 + const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30); 261 + 262 + localStorage.setItem(CACHE_KEY, JSON.stringify({ 263 + data, 264 + timestamp: Date.now() 265 + })); 266 + 267 + return data; 268 + } catch (e) { 269 + console.error('Failed to fetch atmosphere data:', e); 270 + return []; 271 + } 272 + } 273 + 274 + // Try to fetch app avatar 275 + async function fetchAppAvatar(namespace) { 276 + const reversed = namespace.split('.').reverse().join('.'); 277 + const handles = [reversed, `${reversed}.bsky.social`]; 278 + 279 + for (const handle of handles) { 280 + try { 281 + const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 282 + if (!didRes.ok) continue; 283 + 284 + const { did } = await didRes.json(); 285 + const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 286 + if (!profileRes.ok) continue; 287 + 288 + const profile = await profileRes.json(); 289 + if (profile.avatar) return profile.avatar; 290 + } catch (e) { 291 + continue; 292 + } 293 + } 294 + return null; 295 + } 296 + 297 + // Render atmosphere 298 + async function renderAtmosphere() { 299 + const data = await fetchAtmosphere(); 300 + if (!data.length) return; 301 + 302 + const atmosphere = document.getElementById('atmosphere'); 303 + const maxSize = Math.max(...data.map(d => d.dids_total)); 304 + 305 + data.forEach((app, i) => { 306 + const orb = document.createElement('div'); 307 + orb.className = 'app-orb'; 308 + 309 + // Size based on user count (20-80px) 310 + const size = 20 + (app.dids_total / maxSize) * 60; 311 + 312 + // Position in 3D space 313 + const angle = (i / data.length) * Math.PI * 2; 314 + const radius = 250 + (i % 3) * 100; 315 + const y = (i % 5) * 80 - 160; 316 + const x = Math.cos(angle) * radius; 317 + const z = Math.sin(angle) * radius; 318 + 319 + orb.style.width = `${size}px`; 320 + orb.style.height = `${size}px`; 321 + orb.style.left = `calc(50% + ${x}px)`; 322 + orb.style.top = `calc(50% + ${y}px)`; 323 + orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`; 324 + orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`; 325 + orb.style.border = '1px solid rgba(255,255,255,0.1)'; 326 + orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)'; 327 + 328 + // Fallback letter 329 + const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase(); 330 + orb.innerHTML = `<div class="fallback">${letter}</div>`; 331 + 332 + // Tooltip 333 + const tooltip = document.createElement('div'); 334 + tooltip.className = 'app-tooltip'; 335 + const users = app.dids_total >= 1000000 336 + ? `${(app.dids_total / 1000000).toFixed(1)}M users` 337 + : `${(app.dids_total / 1000).toFixed(0)}K users`; 338 + tooltip.textContent = `${app.namespace} • ${users}`; 339 + orb.appendChild(tooltip); 340 + 341 + atmosphere.appendChild(orb); 342 + 343 + // Fetch and apply avatar 344 + fetchAppAvatar(app.namespace).then(avatarUrl => { 345 + if (avatarUrl) { 346 + orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`; 347 + orb.appendChild(tooltip); 348 + } 349 + }); 350 + }); 351 + } 352 + 353 + renderAtmosphere(); 54 354 </script> 55 355 </body> 56 356 </html>