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

feat: add demo mode and educational content to login page

- add collapsible educational section explaining atproto, data ownership, and the silo problem
- implement demo mode that loads paul frazee's account for exploration without login
- add demo banner with exit functionality
- adjust UI elements (info, watch live, logout buttons) to avoid overlapping with demo banner
- clear localStorage when exiting demo to prevent auto-restore behavior

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

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

Changed files
+284 -5
src
static
+2
src/main.rs
··· 38 38 .app_data(web::Data::new(firehose_manager.clone())) 39 39 .service(routes::index) 40 40 .service(routes::login) 41 + .service(routes::demo) 42 + .service(routes::demo_exit) 41 43 .service(routes::callback) 42 44 .service(routes::client_metadata) 43 45 .service(routes::logout)
+47 -3
src/routes.rs
··· 28 28 let did: Option<String> = session.get("did").unwrap_or(None); 29 29 30 30 match did { 31 - Some(did) => HttpResponse::Ok() 32 - .content_type("text/html") 33 - .body(templates::app_page(&did)), 31 + Some(did) => { 32 + let demo_mode: bool = session.get("demo_mode").unwrap_or(Some(false)).unwrap_or(false); 33 + let demo_handle: Option<String> = session.get("demo_handle").unwrap_or(None); 34 + 35 + HttpResponse::Ok() 36 + .content_type("text/html") 37 + .body(templates::app_page(&did, demo_mode, demo_handle.as_deref())) 38 + }, 34 39 None => HttpResponse::Ok() 35 40 .content_type("text/html") 36 41 .body(templates::login_page()), ··· 62 67 .finish(), 63 68 Err(_) => HttpResponse::InternalServerError().body("oauth error"), 64 69 } 70 + } 71 + 72 + #[get("/demo")] 73 + pub async fn demo(session: Session) -> HttpResponse { 74 + let demo_handle = "pfrazee.com"; 75 + 76 + // Resolve handle to DID 77 + let resolve_url = format!( 78 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", 79 + demo_handle 80 + ); 81 + 82 + let did = match reqwest::get(&resolve_url).await { 83 + Ok(response) => match response.json::<serde_json::Value>().await { 84 + Ok(data) => match data["did"].as_str() { 85 + Some(did) => did.to_string(), 86 + None => return HttpResponse::InternalServerError().body("failed to resolve demo handle"), 87 + }, 88 + Err(_) => return HttpResponse::InternalServerError().body("failed to parse response"), 89 + }, 90 + Err(_) => return HttpResponse::InternalServerError().body("failed to resolve demo handle"), 91 + }; 92 + 93 + // Store in session with demo flag 94 + session.insert("did", &did).unwrap(); 95 + session.insert("demo_mode", true).unwrap(); 96 + session.insert("demo_handle", demo_handle).unwrap(); 97 + 98 + HttpResponse::SeeOther() 99 + .append_header(("Location", "/")) 100 + .finish() 101 + } 102 + 103 + #[get("/demo/exit")] 104 + pub async fn demo_exit(session: Session) -> HttpResponse { 105 + session.purge(); 106 + HttpResponse::SeeOther() 107 + .append_header(("Location", "/?clear_demo=true")) 108 + .finish() 65 109 } 66 110 67 111 #[get("/oauth/callback")]
+203 -2
src/templates.rs
··· 170 170 .hidden { display: none; } 171 171 .loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; } 172 172 173 + .demo-btn { 174 + font-family: inherit; 175 + font-size: 0.9rem; 176 + padding: 0.75rem 2rem; 177 + cursor: pointer; 178 + background: transparent; 179 + border: 1px solid rgba(255, 255, 255, 0.15); 180 + border-radius: 4px; 181 + color: rgba(255, 255, 255, 0.6); 182 + transition: all 0.2s; 183 + width: 100%; 184 + margin-top: 0.75rem; 185 + } 186 + 187 + .demo-btn:hover { 188 + background: rgba(10, 10, 15, 0.5); 189 + border-color: rgba(255, 255, 255, 0.3); 190 + color: rgba(255, 255, 255, 0.8); 191 + } 192 + 193 + .divider { 194 + display: flex; 195 + align-items: center; 196 + gap: 1rem; 197 + margin: 1.5rem 0 1rem; 198 + color: rgba(255, 255, 255, 0.3); 199 + font-size: 0.7rem; 200 + } 201 + 202 + .divider::before, 203 + .divider::after { 204 + content: ''; 205 + flex: 1; 206 + height: 1px; 207 + background: rgba(255, 255, 255, 0.1); 208 + } 209 + 210 + .info-toggle { 211 + margin-top: 1.5rem; 212 + color: rgba(255, 255, 255, 0.5); 213 + font-size: 0.75rem; 214 + cursor: pointer; 215 + border: none; 216 + background: none; 217 + padding: 0.5rem; 218 + transition: color 0.2s; 219 + text-decoration: underline; 220 + text-underline-offset: 2px; 221 + } 222 + 223 + .info-toggle:hover { 224 + color: rgba(255, 255, 255, 0.8); 225 + } 226 + 227 + .info-content { 228 + max-height: 0; 229 + overflow: hidden; 230 + transition: max-height 0.3s ease; 231 + margin-top: 1rem; 232 + } 233 + 234 + .info-content.expanded { 235 + max-height: 500px; 236 + } 237 + 238 + .info-section { 239 + background: rgba(10, 10, 15, 0.6); 240 + border: 1px solid rgba(255, 255, 255, 0.1); 241 + border-radius: 4px; 242 + padding: 1.5rem; 243 + text-align: left; 244 + } 245 + 246 + .info-section h3 { 247 + font-size: 0.85rem; 248 + font-weight: 500; 249 + margin-bottom: 0.75rem; 250 + color: rgba(255, 255, 255, 0.9); 251 + } 252 + 253 + .info-section p { 254 + font-size: 0.7rem; 255 + line-height: 1.6; 256 + color: rgba(255, 255, 255, 0.6); 257 + margin-bottom: 1rem; 258 + } 259 + 260 + .info-section p:last-child { 261 + margin-bottom: 0; 262 + } 263 + 264 + .info-section strong { 265 + color: rgba(255, 255, 255, 0.85); 266 + } 267 + 268 + .info-section a { 269 + color: rgba(255, 255, 255, 0.8); 270 + text-decoration: underline; 271 + text-underline-offset: 2px; 272 + } 273 + 274 + .info-section a:hover { 275 + color: rgba(255, 255, 255, 1); 276 + } 277 + 173 278 .footer { 174 279 position: fixed; 175 280 bottom: 1rem; ··· 202 307 <div class="subtitle">explore the atmosphere</div> 203 308 <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus> 204 309 <button type="submit">enter</button> 310 + 311 + <div class="divider">or</div> 312 + <button type="button" class="demo-btn" id="demoBtn">explore demo</button> 313 + 314 + <button type="button" class="info-toggle" id="infoToggle">what is this?</button> 315 + 316 + <div class="info-content" id="infoContent"> 317 + <div class="info-section"> 318 + <h3>visualize your atproto identity</h3> 319 + <p>see all the apps writing to your <strong>Personal Data Server</strong> and explore the records they've created. your content, your server, your control.</p> 320 + 321 + <h3>the problem with silos</h3> 322 + <p>traditional social platforms lock your content in. switching means starting over, losing your network and history. you build their platform, they control everything.</p> 323 + 324 + <h3>the atproto solution</h3> 325 + <p>on <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a>, you own your data. it lives on <strong>your</strong> server. apps like bluesky, whitewind, and frontpage just read and write to your space. switch apps anytime, take everything with you.</p> 326 + 327 + <h3>see it yourself</h3> 328 + <p>this isn't just theory. click "explore demo" to see a real atproto account, or log in to visualize your own identity.</p> 329 + </div> 330 + </div> 205 331 </form> 206 332 </div> 207 333 </div> ··· 216 342 "# 217 343 } 218 344 219 - pub fn app_page(did: &str) -> String { 345 + pub fn app_page(did: &str, demo_mode: bool, demo_handle: Option<&str>) -> String { 346 + let demo_banner = if demo_mode && demo_handle.is_some() { 347 + format!(r#" 348 + <div class="demo-banner" id="demoBanner"> 349 + <span>demo mode - viewing <strong>{}</strong></span> 350 + <a href="/demo/exit" class="demo-exit">exit demo</a> 351 + </div>"#, demo_handle.unwrap()) 352 + } else { 353 + String::new() 354 + }; 355 + 220 356 format!(r#" 221 357 <!DOCTYPE html> 222 358 <html> ··· 1304 1440 max-width: none; 1305 1441 }} 1306 1442 }} 1443 + 1444 + .demo-banner {{ 1445 + position: fixed; 1446 + top: 0; 1447 + left: 0; 1448 + right: 0; 1449 + background: rgba(255, 165, 0, 0.15); 1450 + border-bottom: 1px solid rgba(255, 165, 0, 0.3); 1451 + padding: 0.5rem 1rem; 1452 + display: flex; 1453 + align-items: center; 1454 + justify-content: center; 1455 + gap: 1rem; 1456 + z-index: 200; 1457 + font-size: 0.7rem; 1458 + color: var(--text); 1459 + }} 1460 + 1461 + .demo-banner strong {{ 1462 + color: var(--text); 1463 + font-weight: 600; 1464 + }} 1465 + 1466 + .demo-exit {{ 1467 + color: var(--text-light); 1468 + text-decoration: none; 1469 + border: 1px solid var(--border); 1470 + padding: 0.25rem 0.75rem; 1471 + border-radius: 2px; 1472 + transition: all 0.2s ease; 1473 + font-size: 0.65rem; 1474 + }} 1475 + 1476 + .demo-exit:hover {{ 1477 + background: var(--surface); 1478 + border-color: var(--text-light); 1479 + color: var(--text); 1480 + }} 1481 + 1482 + @media (prefers-color-scheme: dark) {{ 1483 + .demo-banner {{ 1484 + background: rgba(255, 165, 0, 0.1); 1485 + border-bottom-color: rgba(255, 165, 0, 0.25); 1486 + }} 1487 + }} 1488 + 1489 + /* Adjust elements when demo banner is present */ 1490 + .demo-banner ~ .info {{ 1491 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1492 + }} 1493 + 1494 + .demo-banner ~ .watch-live-btn {{ 1495 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1496 + }} 1497 + 1498 + .demo-banner ~ .logout {{ 1499 + top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem); 1500 + }} 1501 + 1502 + @media (max-width: 768px) {{ 1503 + .demo-banner ~ .watch-live-btn {{ 1504 + top: calc(clamp(4rem, 8vmin, 5rem) + 2.5rem); 1505 + }} 1506 + }} 1307 1507 </style> 1308 1508 </head> 1309 1509 <body> 1510 + {} 1310 1511 <div class="info" id="infoBtn">?</div> 1311 1512 <button class="watch-live-btn" id="watchLiveBtn"> 1312 1513 <span class="watch-indicator"></span> ··· 1356 1557 <script src="/static/onboarding.js"></script> 1357 1558 </body> 1358 1559 </html> 1359 - "#, did) 1560 + "#, demo_banner, did) 1360 1561 }
+32
static/login.js
··· 1 + // Check if we're exiting demo mode 2 + const urlParams = new URLSearchParams(window.location.search); 3 + if (urlParams.get('clear_demo') === 'true') { 4 + localStorage.removeItem('atme_did'); 5 + // Clear the query param from the URL 6 + window.history.replaceState({}, document.title, '/'); 7 + } 8 + 1 9 // Check for saved session 2 10 const savedDid = localStorage.getItem('atme_did'); 3 11 if (savedDid) { ··· 155 163 } 156 164 157 165 renderAtmosphere(); 166 + 167 + // Info toggle 168 + document.getElementById('infoToggle').addEventListener('click', () => { 169 + const content = document.getElementById('infoContent'); 170 + const toggle = document.getElementById('infoToggle'); 171 + 172 + if (content.classList.contains('expanded')) { 173 + content.classList.remove('expanded'); 174 + toggle.textContent = 'what is this?'; 175 + } else { 176 + content.classList.add('expanded'); 177 + toggle.textContent = 'close'; 178 + } 179 + }); 180 + 181 + // Demo mode 182 + document.getElementById('demoBtn').addEventListener('click', () => { 183 + // Store demo flag and navigate 184 + sessionStorage.setItem('atme_demo_mode', 'true'); 185 + sessionStorage.setItem('atme_demo_handle', 'pfrazee.com'); 186 + 187 + // Navigate to demo - this will trigger the login flow with the demo handle 188 + window.location.href = '/demo'; 189 + });