interactive intro to open social

feat: add onboarding overlay and refactor js to static files

- extract javascript from templates.rs into modular static files
- implement 3-step onboarding overlay for first-time users
- add user profile picture to center identity circle
- make entire ui responsive with viewport-relative units using clamp()
- add help button to restart onboarding tour
- improve technical accuracy of onboarding text about atproto
- add actix-files for static file serving
- reduce templates.rs by 600+ lines

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

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

authored by zzstoatzz.io Claude and committed by Tangled be270e37 1febc1bc

+52
Cargo.lock
··· 20 20 ] 21 21 22 22 [[package]] 23 + name = "actix-files" 24 + version = "0.6.8" 25 + source = "registry+https://github.com/rust-lang/crates.io-index" 26 + checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576" 27 + dependencies = [ 28 + "actix-http", 29 + "actix-service", 30 + "actix-utils", 31 + "actix-web", 32 + "bitflags", 33 + "bytes", 34 + "derive_more 2.0.1", 35 + "futures-core", 36 + "http-range", 37 + "log", 38 + "mime", 39 + "mime_guess", 40 + "percent-encoding", 41 + "pin-project-lite", 42 + "v_htmlescape", 43 + ] 44 + 45 + [[package]] 23 46 name = "actix-http" 24 47 version = "3.11.2" 25 48 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 386 409 name = "at-me" 387 410 version = "0.1.0" 388 411 dependencies = [ 412 + "actix-files", 389 413 "actix-session", 390 414 "actix-web", 391 415 "atrium-api", ··· 1416 1440 ] 1417 1441 1418 1442 [[package]] 1443 + name = "http-range" 1444 + version = "0.1.5" 1445 + source = "registry+https://github.com/rust-lang/crates.io-index" 1446 + checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1447 + 1448 + [[package]] 1419 1449 name = "httparse" 1420 1450 version = "1.10.1" 1421 1451 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1895 1925 version = "0.3.17" 1896 1926 source = "registry+https://github.com/rust-lang/crates.io-index" 1897 1927 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1928 + 1929 + [[package]] 1930 + name = "mime_guess" 1931 + version = "2.0.5" 1932 + source = "registry+https://github.com/rust-lang/crates.io-index" 1933 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1934 + dependencies = [ 1935 + "mime", 1936 + "unicase", 1937 + ] 1898 1938 1899 1939 [[package]] 1900 1940 name = "miniz_oxide" ··· 2945 2985 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2946 2986 2947 2987 [[package]] 2988 + name = "unicase" 2989 + version = "2.8.1" 2990 + source = "registry+https://github.com/rust-lang/crates.io-index" 2991 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 2992 + 2993 + [[package]] 2948 2994 name = "unicode-ident" 2949 2995 version = "1.0.19" 2950 2996 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3006 3052 "js-sys", 3007 3053 "wasm-bindgen", 3008 3054 ] 3055 + 3056 + [[package]] 3057 + name = "v_htmlescape" 3058 + version = "0.15.8" 3059 + source = "registry+https://github.com/rust-lang/crates.io-index" 3060 + checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3009 3061 3010 3062 [[package]] 3011 3063 name = "vcpkg"
+1
Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 actix-web = "4.10" 8 + actix-files = "0.6" 8 9 actix-session = { version = "0.10", features = ["cookie-session"] } 9 10 atrium-api = "0.25" 10 11 atrium-common = "0.1"
+2
src/main.rs
··· 1 1 use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore}; 2 2 use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 3 + use actix_files::Files; 3 4 4 5 mod oauth; 5 6 mod routes; ··· 36 37 .service(routes::logout) 37 38 .service(routes::restore_session) 38 39 .service(routes::favicon) 40 + .service(Files::new("/static", "./static")) 39 41 }) 40 42 .bind(("0.0.0.0", 8080))? 41 43 .run()
+156 -617
src/templates.rs
··· 194 194 by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a> 195 195 </div> 196 196 197 - <script> 198 - // Check for saved session 199 - const savedDid = localStorage.getItem('atme_did'); 200 - if (savedDid) { 201 - document.getElementById('loginForm').classList.add('hidden'); 202 - document.getElementById('restoring').classList.remove('hidden'); 203 - 204 - fetch('/api/restore-session', { 205 - method: 'POST', 206 - headers: { 'Content-Type': 'application/json' }, 207 - body: JSON.stringify({ did: savedDid }) 208 - }).then(r => { 209 - if (r.ok) { 210 - window.location.href = '/'; 211 - } else { 212 - localStorage.removeItem('atme_did'); 213 - document.getElementById('loginForm').classList.remove('hidden'); 214 - document.getElementById('restoring').classList.add('hidden'); 215 - } 216 - }).catch(() => { 217 - localStorage.removeItem('atme_did'); 218 - document.getElementById('loginForm').classList.remove('hidden'); 219 - document.getElementById('restoring').classList.add('hidden'); 220 - }); 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(); 354 - </script> 197 + <script src="/static/login.js"></script> 355 198 </body> 356 199 </html> 357 200 "# ··· 413 256 414 257 .logout {{ 415 258 position: fixed; 416 - top: 1.5rem; 417 - right: 1.5rem; 418 - font-size: 0.7rem; 259 + top: clamp(1rem, 2vmin, 1.5rem); 260 + right: clamp(1rem, 2vmin, 1.5rem); 261 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 419 262 color: var(--text-light); 420 263 text-decoration: none; 421 264 border: 1px solid var(--border); 422 - padding: 0.4rem 0.8rem; 265 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 423 266 transition: all 0.2s ease; 424 267 z-index: 100; 425 268 -webkit-tap-highlight-color: transparent; ··· 433 276 border-color: var(--text-light); 434 277 }} 435 278 436 - @media (max-width: 768px) {{ 437 - .logout {{ 438 - padding: 0.6rem 1rem; 439 - font-size: 0.75rem; 440 - top: 1rem; 441 - right: 1rem; 442 - }} 443 - }} 444 - 445 279 .info {{ 446 280 position: fixed; 447 - top: 1.5rem; 448 - left: 1.5rem; 449 - width: 32px; 450 - height: 32px; 281 + top: clamp(1rem, 2vmin, 1.5rem); 282 + left: clamp(1rem, 2vmin, 1.5rem); 283 + width: clamp(32px, 6vmin, 40px); 284 + height: clamp(32px, 6vmin, 40px); 451 285 border-radius: 50%; 452 286 border: 1px solid var(--border); 453 287 display: flex; 454 288 align-items: center; 455 289 justify-content: center; 456 - font-size: 0.75rem; 290 + font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 457 291 color: var(--text-light); 458 292 cursor: pointer; 459 293 transition: all 0.2s ease; ··· 467 301 border-color: var(--text-light); 468 302 }} 469 303 470 - @media (max-width: 768px) {{ 471 - .info {{ 472 - width: 40px; 473 - height: 40px; 474 - font-size: 0.85rem; 475 - top: 1rem; 476 - left: 1rem; 477 - }} 478 - }} 479 - 480 304 .info-modal {{ 481 305 position: fixed; 482 306 top: 50%; ··· 570 394 background: var(--surface); 571 395 border: 2px solid var(--text-light); 572 396 border-radius: 50%; 573 - width: 120px; 574 - height: 120px; 397 + width: clamp(100px, 20vmin, 140px); 398 + height: clamp(100px, 20vmin, 140px); 575 399 display: flex; 576 400 flex-direction: column; 577 401 align-items: center; 578 402 justify-content: center; 579 - gap: 0.3rem; 403 + gap: clamp(0.2rem, 1vmin, 0.3rem); 404 + padding: clamp(0.4rem, 1vmin, 0.6rem); 580 405 z-index: 10; 581 406 cursor: pointer; 582 407 transition: all 0.2s ease; ··· 589 414 box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); 590 415 }} 591 416 592 - @media (max-width: 768px) {{ 593 - .identity {{ 594 - width: 100px; 595 - height: 100px; 596 - }} 597 - }} 598 - 599 417 .identity-label {{ 600 - font-size: 1.2rem; 418 + font-size: clamp(1rem, 2vmin, 1.2rem); 601 419 color: var(--text); 602 420 font-weight: 600; 603 421 line-height: 1; 604 422 }} 605 423 606 424 .identity-value {{ 607 - font-size: 0.7rem; 425 + font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 608 426 color: var(--text-lighter); 609 427 text-align: center; 610 428 word-break: break-word; 611 - max-width: 100px; 429 + max-width: 90%; 612 430 font-weight: 400; 613 - }} 614 - 615 - @media (max-width: 768px) {{ 616 - .identity-label {{ 617 - font-size: 1.1rem; 618 - }} 619 - 620 - .identity-value {{ 621 - font-size: 0.65rem; 622 - }} 431 + line-height: 1.2; 623 432 }} 624 433 625 434 .identity-hint {{ 626 - font-size: 0.4rem; 435 + font-size: clamp(0.35rem, 0.8vmin, 0.45rem); 627 436 color: var(--text-lighter); 628 437 margin-top: 0.2rem; 629 438 letter-spacing: 0.05em; 630 439 }} 631 440 441 + .identity-avatar {{ 442 + width: clamp(30px, 6vmin, 45px); 443 + height: clamp(30px, 6vmin, 45px); 444 + border-radius: 50%; 445 + object-fit: cover; 446 + border: 2px solid var(--text-light); 447 + margin-bottom: clamp(0.2rem, 1vmin, 0.3rem); 448 + }} 449 + 632 450 .app-view {{ 633 451 position: absolute; 634 452 display: flex; 635 453 flex-direction: column; 636 454 align-items: center; 637 - gap: 0.4rem; 455 + gap: clamp(0.3rem, 1vmin, 0.5rem); 638 456 cursor: pointer; 639 457 transition: all 0.2s ease; 640 458 opacity: 0.7; ··· 650 468 background: var(--surface-hover); 651 469 border: 1px solid var(--border); 652 470 border-radius: 50%; 653 - width: 60px; 654 - height: 60px; 471 + width: clamp(45px, 8vmin, 60px); 472 + height: clamp(45px, 8vmin, 60px); 655 473 display: flex; 656 474 align-items: center; 657 475 justify-content: center; 658 476 transition: all 0.2s ease; 659 477 overflow: hidden; 478 + font-size: clamp(1rem, 2vmin, 1.5rem); 660 479 }} 661 480 662 481 .app-logo {{ ··· 671 490 }} 672 491 673 492 .app-name {{ 674 - font-size: 0.65rem; 493 + font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 675 494 color: var(--text); 676 495 text-align: center; 677 - max-width: 100px; 496 + max-width: clamp(80px, 15vmin, 120px); 678 497 }} 679 498 680 499 .detail-panel {{ ··· 902 721 903 722 .footer {{ 904 723 position: fixed; 905 - bottom: 1rem; 724 + bottom: clamp(0.75rem, 2vmin, 1rem); 906 725 left: 50%; 907 726 transform: translateX(-50%); 908 - font-size: 0.65rem; 909 - color: var(--text-light); 727 + font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 728 + color: var(--text); 910 729 z-index: 100; 730 + background: var(--surface); 731 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.75rem, 2vmin, 1rem); 732 + border-radius: 4px; 733 + border: 1px solid var(--border); 911 734 }} 912 735 913 736 .footer a {{ 914 - color: var(--text-light); 737 + color: var(--text); 915 738 text-decoration: none; 916 739 border-bottom: 1px solid transparent; 917 - transition: border-color 0.2s ease; 740 + transition: all 0.2s ease; 918 741 }} 919 742 920 743 .footer a:hover {{ 921 - border-bottom-color: var(--text-light); 744 + border-bottom-color: var(--text); 922 745 }} 923 746 924 747 .loading {{ color: var(--text-light); font-size: 0.75rem; }} 748 + 749 + .onboarding-overlay {{ 750 + position: fixed; 751 + inset: 0; 752 + background: transparent; 753 + z-index: 3000; 754 + display: none; 755 + opacity: 0; 756 + transition: opacity 0.3s ease; 757 + pointer-events: none; 758 + }} 759 + 760 + .onboarding-overlay.active {{ 761 + display: block; 762 + opacity: 1; 763 + }} 764 + 765 + .onboarding-spotlight {{ 766 + position: absolute; 767 + border: 2px solid rgba(255, 255, 255, 0.9); 768 + border-radius: 50%; 769 + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5); 770 + pointer-events: none; 771 + transition: all 0.5s ease; 772 + }} 773 + 774 + .onboarding-content {{ 775 + position: fixed; 776 + background: var(--surface); 777 + border: 2px solid var(--border); 778 + padding: clamp(1rem, 3vmin, 2rem); 779 + max-width: min(400px, 90vw); 780 + z-index: 3001; 781 + border-radius: 4px; 782 + transition: all 0.3s ease; 783 + pointer-events: auto; 784 + }} 785 + 786 + .onboarding-content h3 {{ 787 + font-size: clamp(0.9rem, 2vmin, 1.1rem); 788 + margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem); 789 + color: var(--text); 790 + font-weight: 500; 791 + }} 792 + 793 + .onboarding-content p {{ 794 + font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 795 + color: var(--text-light); 796 + line-height: 1.5; 797 + margin-bottom: clamp(1rem, 2vmin, 1.25rem); 798 + }} 799 + 800 + .onboarding-actions {{ 801 + display: flex; 802 + gap: clamp(0.5rem, 1.5vmin, 0.75rem); 803 + justify-content: flex-end; 804 + }} 805 + 806 + .onboarding-actions button {{ 807 + font-family: inherit; 808 + font-size: clamp(0.7rem, 1.5vmin, 0.8rem); 809 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 810 + background: transparent; 811 + border: 1px solid var(--border); 812 + color: var(--text); 813 + cursor: pointer; 814 + transition: all 0.2s ease; 815 + border-radius: 2px; 816 + }} 817 + 818 + .onboarding-actions button:hover {{ 819 + background: var(--surface-hover); 820 + border-color: var(--text-light); 821 + }} 822 + 823 + .onboarding-actions button.primary {{ 824 + background: var(--surface-hover); 825 + border-color: var(--text-light); 826 + }} 827 + 828 + .onboarding-progress {{ 829 + display: flex; 830 + gap: clamp(0.4rem, 1vmin, 0.5rem); 831 + justify-content: center; 832 + margin-top: clamp(0.75rem, 2vmin, 1rem); 833 + }} 834 + 835 + .onboarding-progress span {{ 836 + width: clamp(6px, 1.5vmin, 8px); 837 + height: clamp(6px, 1.5vmin, 8px); 838 + border-radius: 50%; 839 + background: var(--border); 840 + transition: background 0.3s ease; 841 + }} 842 + 843 + .onboarding-progress span.active {{ 844 + background: var(--text); 845 + }} 846 + 847 + .onboarding-progress span.done {{ 848 + background: var(--text-light); 849 + }} 925 850 </style> 926 851 </head> 927 852 <body> 928 - <div class="info" id="infoBtn">i</div> 853 + <div class="info" id="infoBtn">?</div> 929 854 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 930 855 931 856 <div class="overlay" id="overlay"></div> ··· 935 860 <p>third-party applications create records in your repository using different lexicons (data schemas). for example, bluesky creates posts, white wind stores blog entries, tangled.org hosts code repositories, and frontpage aggregates links - all in the same place.</p> 936 861 <p>this visualization shows your identity at the center, surrounded by the third-party apps that have created data for you. click an app to see what types of records it stores, then click a record type to see the actual data.</p> 937 862 <button id="closeInfo">got it</button> 863 + <button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 864 + </div> 865 + 866 + <div class="onboarding-overlay" id="onboardingOverlay"> 867 + <div class="onboarding-spotlight" id="onboardingSpotlight"></div> 868 + <div class="onboarding-content" id="onboardingContent"></div> 938 869 </div> 939 870 940 871 <div class="canvas"> ··· 951 882 <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a> 952 883 </div> 953 884 <script> 954 - const did = '{}'; 955 - localStorage.setItem('atme_did', did); 956 - 957 - let globalPds = null; 958 - let globalHandle = null; 959 - 960 - // Try to fetch app avatar from their bsky profile 961 - async function fetchAppAvatar(namespace) {{ 962 - try {{ 963 - // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 964 - const reversed = namespace.split('.').reverse().join('.'); 965 - // Try reversed domain, then reversed.bsky.social 966 - const handles = [reversed, `${{reversed}}.bsky.social`]; 967 - 968 - for (const handle of handles) {{ 969 - try {{ 970 - const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${{handle}}`); 971 - if (!didRes.ok) continue; 972 - 973 - const {{ did }} = await didRes.json(); 974 - const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{did}}`); 975 - if (!profileRes.ok) continue; 976 - 977 - const profile = await profileRes.json(); 978 - if (profile.avatar) {{ 979 - return profile.avatar; 980 - }} 981 - }} catch (e) {{ 982 - continue; 983 - }} 984 - }} 985 - }} catch (e) {{ 986 - console.log('Could not fetch avatar for', namespace); 987 - }} 988 - return null; 989 - }} 990 - 991 - // Logout handler 992 - document.getElementById('logoutBtn').addEventListener('click', (e) => {{ 993 - e.preventDefault(); 994 - localStorage.removeItem('atme_did'); 995 - window.location.href = '/logout'; 996 - }}); 997 - 998 - // Info modal handlers 999 - document.getElementById('infoBtn').addEventListener('click', () => {{ 1000 - document.getElementById('infoModal').classList.add('visible'); 1001 - document.getElementById('overlay').classList.add('visible'); 1002 - }}); 1003 - 1004 - document.getElementById('closeInfo').addEventListener('click', () => {{ 1005 - document.getElementById('infoModal').classList.remove('visible'); 1006 - document.getElementById('overlay').classList.remove('visible'); 1007 - }}); 1008 - 1009 - document.getElementById('overlay').addEventListener('click', () => {{ 1010 - document.getElementById('infoModal').classList.remove('visible'); 1011 - document.getElementById('overlay').classList.remove('visible'); 1012 - const detail = document.getElementById('detail'); 1013 - detail.classList.remove('visible'); 1014 - }}); 1015 - 1016 - // First resolve DID to get PDS endpoint and handle 1017 - fetch('https://plc.directory/' + did) 1018 - .then(r => r.json()) 1019 - .then(didDoc => {{ 1020 - const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 1021 - const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did; 1022 - 1023 - globalPds = pds; 1024 - globalHandle = handle; 1025 - 1026 - // Update identity display with handle 1027 - document.getElementById('handle').textContent = handle; 1028 - 1029 - // Add identity click handler to show PDS info 1030 - document.querySelector('.identity').addEventListener('click', () => {{ 1031 - const detail = document.getElementById('detail'); 1032 - const pdsHost = pds.replace('https://', '').replace('http://', ''); 1033 - detail.innerHTML = ` 1034 - <button class="detail-close" id="detailClose">×</button> 1035 - <h3>your identity</h3> 1036 - <div class="subtitle">decentralized identifier & storage</div> 1037 - <div class="tree-item"> 1038 - <div class="tree-item-header"> 1039 - <span style="color: var(--text-light);">did</span> 1040 - <span style="font-size: 0.6rem; color: var(--text);">${{did}}</span> 1041 - </div> 1042 - </div> 1043 - <div class="tree-item"> 1044 - <div class="tree-item-header"> 1045 - <span style="color: var(--text-light);">handle</span> 1046 - <span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span> 1047 - </div> 1048 - </div> 1049 - <div class="tree-item"> 1050 - <div class="tree-item-header"> 1051 - <span style="color: var(--text-light);">personal data server</span> 1052 - <span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span> 1053 - </div> 1054 - </div> 1055 - <div style="margin-top: 1rem; padding: 0.6rem; background: var(--bg); border-radius: 4px; font-size: 0.65rem; line-height: 1.5; color: var(--text-lighter);"> 1056 - your data lives at <strong style="color: var(--text);">${{pdsHost}}</strong>. apps like bluesky write to and read from this server. you control @<strong style="color: var(--text);">${{handle}}</strong> and can move it to a different server anytime. 1057 - </div> 1058 - `; 1059 - detail.classList.add('visible'); 1060 - 1061 - // Add close button handler 1062 - document.getElementById('detailClose').addEventListener('click', (e) => {{ 1063 - e.stopPropagation(); 1064 - detail.classList.remove('visible'); 1065 - }}); 1066 - }}); 1067 - 1068 - // Get all collections from PDS 1069 - return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`); 1070 - }}) 1071 - .then(r => r.json()) 1072 - .then(repo => {{ 1073 - const collections = repo.collections || []; 1074 - 1075 - // Group by app namespace (first two parts of lexicon) 1076 - const apps = {{}}; 1077 - collections.forEach(collection => {{ 1078 - const parts = collection.split('.'); 1079 - if (parts.length >= 2) {{ 1080 - const namespace = `${{parts[0]}}.${{parts[1]}}`; 1081 - if (!apps[namespace]) apps[namespace] = []; 1082 - apps[namespace].push(collection); 1083 - }} 1084 - }}); 1085 - 1086 - const field = document.getElementById('field'); 1087 - field.innerHTML = ''; 1088 - field.classList.remove('loading'); 1089 - 1090 - const appNames = Object.keys(apps).sort(); 1091 - const radius = 240; 1092 - const centerX = window.innerWidth / 2; 1093 - const centerY = window.innerHeight / 2; 1094 - 1095 - appNames.forEach((namespace, i) => {{ 1096 - const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 1097 - const x = centerX + radius * Math.cos(angle) - 25; 1098 - const y = centerY + radius * Math.sin(angle) - 30; 1099 - 1100 - const div = document.createElement('div'); 1101 - div.className = 'app-view'; 1102 - div.style.left = `${{x}}px`; 1103 - div.style.top = `${{y}}px`; 1104 - 1105 - const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 1106 - 1107 - div.innerHTML = ` 1108 - <div class="app-circle" data-namespace="${{namespace}}">${{firstLetter}}</div> 1109 - <div class="app-name">${{namespace}}</div> 1110 - `; 1111 - 1112 - // Try to fetch and display avatar 1113 - fetchAppAvatar(namespace).then(avatarUrl => {{ 1114 - if (avatarUrl) {{ 1115 - const circle = div.querySelector('.app-circle'); 1116 - circle.innerHTML = `<img src="${{avatarUrl}}" class="app-logo" alt="${{namespace}}" />`; 1117 - }} 1118 - }}); 1119 - 1120 - div.addEventListener('click', () => {{ 1121 - const detail = document.getElementById('detail'); 1122 - const collections = apps[namespace]; 1123 - 1124 - let html = ` 1125 - <button class="detail-close" id="detailClose">×</button> 1126 - <h3>${{namespace}}</h3> 1127 - <div class="subtitle">records stored in your pds:</div> 1128 - `; 1129 - 1130 - if (collections && collections.length > 0) {{ 1131 - // Group collections by sub-namespace (third segment) 1132 - const grouped = {{}}; 1133 - collections.forEach(lexicon => {{ 1134 - const parts = lexicon.split('.'); 1135 - const subNamespace = parts.slice(2).join('.'); 1136 - const firstPart = parts[2] || lexicon; 1137 - 1138 - if (!grouped[firstPart]) grouped[firstPart] = []; 1139 - grouped[firstPart].push({{ lexicon, subNamespace }}); 1140 - }}); 1141 - 1142 - // Sort and display grouped items 1143 - Object.keys(grouped).sort().forEach(group => {{ 1144 - const items = grouped[group]; 1145 - 1146 - if (items.length === 1 && items[0].subNamespace === group) {{ 1147 - // Single item with no further nesting 1148 - html += ` 1149 - <div class="tree-item" data-lexicon="${{items[0].lexicon}}"> 1150 - <div class="tree-item-header"> 1151 - <span>${{group}}</span> 1152 - <span class="tree-item-count">loading...</span> 1153 - </div> 1154 - </div> 1155 - `; 1156 - }} else {{ 1157 - // Group header 1158 - html += `<div style="margin-bottom: 0.75rem;">`; 1159 - html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${{group}}</div>`; 1160 - 1161 - // Items in group 1162 - items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {{ 1163 - const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 1164 - html += ` 1165 - <div class="tree-item" data-lexicon="${{item.lexicon}}" style="margin-left: 0.75rem;"> 1166 - <div class="tree-item-header"> 1167 - <span>${{displayName}}</span> 1168 - <span class="tree-item-count">loading...</span> 1169 - </div> 1170 - </div> 1171 - `; 1172 - }}); 1173 - html += `</div>`; 1174 - }} 1175 - }}); 1176 - }} else {{ 1177 - html += `<div class="tree-item">no collections found</div>`; 1178 - }} 1179 - 1180 - detail.innerHTML = html; 1181 - detail.classList.add('visible'); 1182 - 1183 - // Add close button handler 1184 - document.getElementById('detailClose').addEventListener('click', (e) => {{ 1185 - e.stopPropagation(); 1186 - detail.classList.remove('visible'); 1187 - }}); 1188 - 1189 - // Fetch record counts for each collection 1190 - if (collections && collections.length > 0) {{ 1191 - collections.forEach(lexicon => {{ 1192 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`) 1193 - .then(r => r.json()) 1194 - .then(data => {{ 1195 - const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`); 1196 - if (item) {{ 1197 - const countSpan = item.querySelector('.tree-item-count'); 1198 - // The cursor field indicates there are more records 1199 - countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 1200 - }} 1201 - }}) 1202 - .catch(e => {{ 1203 - console.error('Error fetching count for', lexicon, e); 1204 - const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`); 1205 - if (item) {{ 1206 - const countSpan = item.querySelector('.tree-item-count'); 1207 - countSpan.textContent = 'error'; 1208 - }} 1209 - }}); 1210 - }}); 1211 - }} 1212 - 1213 - // Add click handlers to tree items to fetch actual records 1214 - detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{ 1215 - item.addEventListener('click', (e) => {{ 1216 - e.stopPropagation(); 1217 - const lexicon = item.dataset.lexicon; 1218 - const existingRecords = item.querySelector('.record-list'); 1219 - 1220 - if (existingRecords) {{ 1221 - existingRecords.remove(); 1222 - return; 1223 - }} 1224 - 1225 - const recordListDiv = document.createElement('div'); 1226 - recordListDiv.className = 'record-list'; 1227 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 1228 - item.appendChild(recordListDiv); 1229 - 1230 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`) 1231 - .then(r => r.json()) 1232 - .then(data => {{ 1233 - if (data.records && data.records.length > 0) {{ 1234 - let recordsHtml = ''; 1235 - data.records.forEach((record, idx) => {{ 1236 - const json = JSON.stringify(record.value, null, 2); 1237 - const recordId = `record-${{Date.now()}}-${{idx}}`; 1238 - recordsHtml += ` 1239 - <div class="record"> 1240 - <div class="record-header"> 1241 - <span class="record-label">record</span> 1242 - <button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button> 1243 - </div> 1244 - <div class="record-content"> 1245 - <pre>${{json}}</pre> 1246 - </div> 1247 - </div> 1248 - `; 1249 - }}); 1250 - 1251 - if (data.cursor && data.records.length === 5) {{ 1252 - recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`; 1253 - }} 1254 - 1255 - recordListDiv.innerHTML = recordsHtml; 1256 - 1257 - // Use event delegation for copy and load more buttons 1258 - recordListDiv.addEventListener('click', (e) => {{ 1259 - // Handle copy button 1260 - if (e.target.classList.contains('copy-btn')) {{ 1261 - e.stopPropagation(); 1262 - const copyBtn = e.target; 1263 - const content = decodeURIComponent(copyBtn.dataset.content); 1264 - 1265 - navigator.clipboard.writeText(content).then(() => {{ 1266 - const originalText = copyBtn.textContent; 1267 - copyBtn.textContent = 'copied!'; 1268 - copyBtn.classList.add('copied'); 1269 - setTimeout(() => {{ 1270 - copyBtn.textContent = originalText; 1271 - copyBtn.classList.remove('copied'); 1272 - }}, 1500); 1273 - }}).catch(err => {{ 1274 - console.error('Failed to copy:', err); 1275 - copyBtn.textContent = 'error'; 1276 - setTimeout(() => {{ 1277 - copyBtn.textContent = 'copy'; 1278 - }}, 1500); 1279 - }}); 1280 - }} 1281 - 1282 - // Handle load more button 1283 - if (e.target.classList.contains('load-more')) {{ 1284 - e.stopPropagation(); 1285 - const loadMoreBtn = e.target; 1286 - const cursor = loadMoreBtn.dataset.cursor; 1287 - const lexicon = loadMoreBtn.dataset.lexicon; 1288 - 1289 - loadMoreBtn.textContent = 'loading...'; 1290 - 1291 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`) 1292 - .then(r => r.json()) 1293 - .then(moreData => {{ 1294 - let moreHtml = ''; 1295 - moreData.records.forEach((record, idx) => {{ 1296 - const json = JSON.stringify(record.value, null, 2); 1297 - const recordId = `record-more-${{Date.now()}}-${{idx}}`; 1298 - moreHtml += ` 1299 - <div class="record"> 1300 - <div class="record-header"> 1301 - <span class="record-label">record</span> 1302 - <button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button> 1303 - </div> 1304 - <div class="record-content"> 1305 - <pre>${{json}}</pre> 1306 - </div> 1307 - </div> 1308 - `; 1309 - }}); 1310 - 1311 - loadMoreBtn.remove(); 1312 - recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 1313 - 1314 - if (moreData.cursor && moreData.records.length === 5) {{ 1315 - recordListDiv.insertAdjacentHTML('beforeend', 1316 - `<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>` 1317 - ); 1318 - }} 1319 - }}); 1320 - }} 1321 - }}); 1322 - }} else {{ 1323 - recordListDiv.innerHTML = '<div class="record">no records found</div>'; 1324 - }} 1325 - }}) 1326 - .catch(e => {{ 1327 - console.error('Error fetching records:', e); 1328 - recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 1329 - }}); 1330 - }}); 1331 - }}); 1332 - }}); 1333 - 1334 - field.appendChild(div); 1335 - }}); 1336 - 1337 - // Close detail panel when clicking canvas 1338 - const canvas = document.querySelector('.canvas'); 1339 - canvas.addEventListener('click', (e) => {{ 1340 - if (e.target === canvas) {{ 1341 - document.getElementById('detail').classList.remove('visible'); 1342 - }} 1343 - }}); 1344 - }}) 1345 - .catch(e => {{ 1346 - document.getElementById('field').innerHTML = 'error loading records'; 1347 - console.error(e); 1348 - }}); 885 + window.DID = '{}'; 1349 886 </script> 887 + <script src="/static/app.js"></script> 888 + <script src="/static/onboarding.js"></script> 1350 889 </body> 1351 890 </html> 1352 891 "#, did)
+417
static/app.js
··· 1 + // DID is set as window.DID by the template 2 + const did = window.DID; 3 + localStorage.setItem('atme_did', did); 4 + 5 + let globalPds = null; 6 + let globalHandle = null; 7 + 8 + // Try to fetch app avatar from their bsky profile 9 + async function fetchAppAvatar(namespace) { 10 + try { 11 + // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 12 + const reversed = namespace.split('.').reverse().join('.'); 13 + // Try reversed domain, then reversed.bsky.social 14 + const handles = [reversed, `${reversed}.bsky.social`]; 15 + 16 + for (const handle of handles) { 17 + try { 18 + const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 19 + if (!didRes.ok) continue; 20 + 21 + const { did } = await didRes.json(); 22 + const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 23 + if (!profileRes.ok) continue; 24 + 25 + const profile = await profileRes.json(); 26 + if (profile.avatar) { 27 + return profile.avatar; 28 + } 29 + } catch (e) { 30 + // Silently continue to next handle 31 + continue; 32 + } 33 + } 34 + } catch (e) { 35 + // Expected for namespaces without Bluesky accounts 36 + } 37 + return null; 38 + } 39 + 40 + // Logout handler 41 + document.getElementById('logoutBtn').addEventListener('click', (e) => { 42 + e.preventDefault(); 43 + localStorage.removeItem('atme_did'); 44 + window.location.href = '/logout'; 45 + }); 46 + 47 + // Info modal handlers 48 + document.getElementById('infoBtn').addEventListener('click', () => { 49 + document.getElementById('infoModal').classList.add('visible'); 50 + document.getElementById('overlay').classList.add('visible'); 51 + }); 52 + 53 + document.getElementById('closeInfo').addEventListener('click', () => { 54 + document.getElementById('infoModal').classList.remove('visible'); 55 + document.getElementById('overlay').classList.remove('visible'); 56 + }); 57 + 58 + document.getElementById('overlay').addEventListener('click', () => { 59 + document.getElementById('infoModal').classList.remove('visible'); 60 + document.getElementById('overlay').classList.remove('visible'); 61 + const detail = document.getElementById('detail'); 62 + detail.classList.remove('visible'); 63 + }); 64 + 65 + // First resolve DID to get PDS endpoint and handle 66 + fetch('https://plc.directory/' + did) 67 + .then(r => r.json()) 68 + .then(didDoc => { 69 + const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 70 + const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did; 71 + 72 + globalPds = pds; 73 + globalHandle = handle; 74 + 75 + // Update identity display with handle 76 + document.getElementById('handle').textContent = handle; 77 + 78 + // Try to fetch and display user's avatar 79 + fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`) 80 + .then(r => r.json()) 81 + .then(profile => { 82 + if (profile.avatar) { 83 + const identity = document.querySelector('.identity'); 84 + const avatarImg = document.createElement('img'); 85 + avatarImg.src = profile.avatar; 86 + avatarImg.className = 'identity-avatar'; 87 + avatarImg.alt = handle; 88 + // Insert avatar before the @ label 89 + identity.insertBefore(avatarImg, identity.firstChild); 90 + } 91 + }) 92 + .catch(() => { 93 + // User may not have an avatar set 94 + }); 95 + 96 + // Add identity click handler to show PDS info 97 + document.querySelector('.identity').addEventListener('click', () => { 98 + const detail = document.getElementById('detail'); 99 + const pdsHost = pds.replace('https://', '').replace('http://', ''); 100 + detail.innerHTML = ` 101 + <button class="detail-close" id="detailClose">×</button> 102 + <h3>your identity</h3> 103 + <div class="subtitle">decentralized identifier & storage</div> 104 + <div class="tree-item"> 105 + <div class="tree-item-header"> 106 + <span style="color: var(--text-light);">did</span> 107 + <span style="font-size: 0.6rem; color: var(--text);">${did}</span> 108 + </div> 109 + </div> 110 + <div class="tree-item"> 111 + <div class="tree-item-header"> 112 + <span style="color: var(--text-light);">handle</span> 113 + <span style="font-size: 0.6rem; color: var(--text);">@${handle}</span> 114 + </div> 115 + </div> 116 + <div class="tree-item"> 117 + <div class="tree-item-header"> 118 + <span style="color: var(--text-light);">personal data server</span> 119 + <span style="font-size: 0.6rem; color: var(--text);">${pds}</span> 120 + </div> 121 + </div> 122 + <div style="margin-top: 1rem; padding: 0.6rem; background: var(--bg); border-radius: 4px; font-size: 0.65rem; line-height: 1.5; color: var(--text-lighter);"> 123 + your data lives at <strong style="color: var(--text);">${pdsHost}</strong>. apps like bluesky write to and read from this server. you control @<strong style="color: var(--text);">${handle}</strong> and can move it to a different server anytime. 124 + </div> 125 + `; 126 + detail.classList.add('visible'); 127 + 128 + // Add close button handler 129 + document.getElementById('detailClose').addEventListener('click', (e) => { 130 + e.stopPropagation(); 131 + detail.classList.remove('visible'); 132 + }); 133 + }); 134 + 135 + // Get all collections from PDS 136 + return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 137 + }) 138 + .then(r => r.json()) 139 + .then(repo => { 140 + const collections = repo.collections || []; 141 + 142 + // Group by app namespace (first two parts of lexicon) 143 + const apps = {}; 144 + collections.forEach(collection => { 145 + const parts = collection.split('.'); 146 + if (parts.length >= 2) { 147 + const namespace = `${parts[0]}.${parts[1]}`; 148 + if (!apps[namespace]) apps[namespace] = []; 149 + apps[namespace].push(collection); 150 + } 151 + }); 152 + 153 + const field = document.getElementById('field'); 154 + field.innerHTML = ''; 155 + field.classList.remove('loading'); 156 + 157 + const appNames = Object.keys(apps).sort(); 158 + // Responsive radius: use viewport-relative sizing with min/max bounds 159 + const vmin = Math.min(window.innerWidth, window.innerHeight); 160 + const radius = Math.max(vmin * 0.35, 150); // 35% of smallest dimension, min 150px 161 + const centerX = window.innerWidth / 2; 162 + const centerY = window.innerHeight / 2; 163 + 164 + appNames.forEach((namespace, i) => { 165 + const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 166 + const x = centerX + radius * Math.cos(angle) - 30; 167 + const y = centerY + radius * Math.sin(angle) - 30; 168 + 169 + const div = document.createElement('div'); 170 + div.className = 'app-view'; 171 + div.style.left = `${x}px`; 172 + div.style.top = `${y}px`; 173 + 174 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 175 + 176 + div.innerHTML = ` 177 + <div class="app-circle" data-namespace="${namespace}">${firstLetter}</div> 178 + <div class="app-name">${namespace}</div> 179 + `; 180 + 181 + // Try to fetch and display avatar 182 + fetchAppAvatar(namespace).then(avatarUrl => { 183 + if (avatarUrl) { 184 + const circle = div.querySelector('.app-circle'); 185 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 186 + } 187 + }); 188 + 189 + div.addEventListener('click', () => { 190 + const detail = document.getElementById('detail'); 191 + const collections = apps[namespace]; 192 + 193 + let html = ` 194 + <button class="detail-close" id="detailClose">×</button> 195 + <h3>${namespace}</h3> 196 + <div class="subtitle">records stored in your pds:</div> 197 + `; 198 + 199 + if (collections && collections.length > 0) { 200 + // Group collections by sub-namespace (third segment) 201 + const grouped = {}; 202 + collections.forEach(lexicon => { 203 + const parts = lexicon.split('.'); 204 + const subNamespace = parts.slice(2).join('.'); 205 + const firstPart = parts[2] || lexicon; 206 + 207 + if (!grouped[firstPart]) grouped[firstPart] = []; 208 + grouped[firstPart].push({ lexicon, subNamespace }); 209 + }); 210 + 211 + // Sort and display grouped items 212 + Object.keys(grouped).sort().forEach(group => { 213 + const items = grouped[group]; 214 + 215 + if (items.length === 1 && items[0].subNamespace === group) { 216 + // Single item with no further nesting 217 + html += ` 218 + <div class="tree-item" data-lexicon="${items[0].lexicon}"> 219 + <div class="tree-item-header"> 220 + <span>${group}</span> 221 + <span class="tree-item-count">loading...</span> 222 + </div> 223 + </div> 224 + `; 225 + } else { 226 + // Group header 227 + html += `<div style="margin-bottom: 0.75rem;">`; 228 + html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`; 229 + 230 + // Items in group 231 + items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => { 232 + const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 233 + html += ` 234 + <div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;"> 235 + <div class="tree-item-header"> 236 + <span>${displayName}</span> 237 + <span class="tree-item-count">loading...</span> 238 + </div> 239 + </div> 240 + `; 241 + }); 242 + html += `</div>`; 243 + } 244 + }); 245 + } else { 246 + html += `<div class="tree-item">no collections found</div>`; 247 + } 248 + 249 + detail.innerHTML = html; 250 + detail.classList.add('visible'); 251 + 252 + // Add close button handler 253 + document.getElementById('detailClose').addEventListener('click', (e) => { 254 + e.stopPropagation(); 255 + detail.classList.remove('visible'); 256 + }); 257 + 258 + // Fetch record counts for each collection 259 + if (collections && collections.length > 0) { 260 + collections.forEach(lexicon => { 261 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`) 262 + .then(r => r.json()) 263 + .then(data => { 264 + const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 265 + if (item) { 266 + const countSpan = item.querySelector('.tree-item-count'); 267 + // The cursor field indicates there are more records 268 + countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 269 + } 270 + }) 271 + .catch(e => { 272 + console.error('Error fetching count for', lexicon, e); 273 + const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 274 + if (item) { 275 + const countSpan = item.querySelector('.tree-item-count'); 276 + countSpan.textContent = 'error'; 277 + } 278 + }); 279 + }); 280 + } 281 + 282 + // Add click handlers to tree items to fetch actual records 283 + detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => { 284 + item.addEventListener('click', (e) => { 285 + e.stopPropagation(); 286 + const lexicon = item.dataset.lexicon; 287 + const existingRecords = item.querySelector('.record-list'); 288 + 289 + if (existingRecords) { 290 + existingRecords.remove(); 291 + return; 292 + } 293 + 294 + const recordListDiv = document.createElement('div'); 295 + recordListDiv.className = 'record-list'; 296 + recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 297 + item.appendChild(recordListDiv); 298 + 299 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`) 300 + .then(r => r.json()) 301 + .then(data => { 302 + if (data.records && data.records.length > 0) { 303 + let recordsHtml = ''; 304 + data.records.forEach((record, idx) => { 305 + const json = JSON.stringify(record.value, null, 2); 306 + const recordId = `record-${Date.now()}-${idx}`; 307 + recordsHtml += ` 308 + <div class="record"> 309 + <div class="record-header"> 310 + <span class="record-label">record</span> 311 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 312 + </div> 313 + <div class="record-content"> 314 + <pre>${json}</pre> 315 + </div> 316 + </div> 317 + `; 318 + }); 319 + 320 + if (data.cursor && data.records.length === 5) { 321 + recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 322 + } 323 + 324 + recordListDiv.innerHTML = recordsHtml; 325 + 326 + // Use event delegation for copy and load more buttons 327 + recordListDiv.addEventListener('click', (e) => { 328 + // Handle copy button 329 + if (e.target.classList.contains('copy-btn')) { 330 + e.stopPropagation(); 331 + const copyBtn = e.target; 332 + const content = decodeURIComponent(copyBtn.dataset.content); 333 + 334 + navigator.clipboard.writeText(content).then(() => { 335 + const originalText = copyBtn.textContent; 336 + copyBtn.textContent = 'copied!'; 337 + copyBtn.classList.add('copied'); 338 + setTimeout(() => { 339 + copyBtn.textContent = originalText; 340 + copyBtn.classList.remove('copied'); 341 + }, 1500); 342 + }).catch(err => { 343 + console.error('Failed to copy:', err); 344 + copyBtn.textContent = 'error'; 345 + setTimeout(() => { 346 + copyBtn.textContent = 'copy'; 347 + }, 1500); 348 + }); 349 + } 350 + 351 + // Handle load more button 352 + if (e.target.classList.contains('load-more')) { 353 + e.stopPropagation(); 354 + const loadMoreBtn = e.target; 355 + const cursor = loadMoreBtn.dataset.cursor; 356 + const lexicon = loadMoreBtn.dataset.lexicon; 357 + 358 + loadMoreBtn.textContent = 'loading...'; 359 + 360 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`) 361 + .then(r => r.json()) 362 + .then(moreData => { 363 + let moreHtml = ''; 364 + moreData.records.forEach((record, idx) => { 365 + const json = JSON.stringify(record.value, null, 2); 366 + const recordId = `record-more-${Date.now()}-${idx}`; 367 + moreHtml += ` 368 + <div class="record"> 369 + <div class="record-header"> 370 + <span class="record-label">record</span> 371 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 372 + </div> 373 + <div class="record-content"> 374 + <pre>${json}</pre> 375 + </div> 376 + </div> 377 + `; 378 + }); 379 + 380 + loadMoreBtn.remove(); 381 + recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 382 + 383 + if (moreData.cursor && moreData.records.length === 5) { 384 + recordListDiv.insertAdjacentHTML('beforeend', 385 + `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 386 + ); 387 + } 388 + }); 389 + } 390 + }); 391 + } else { 392 + recordListDiv.innerHTML = '<div class="record">no records found</div>'; 393 + } 394 + }) 395 + .catch(e => { 396 + console.error('Error fetching records:', e); 397 + recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 398 + }); 399 + }); 400 + }); 401 + }); 402 + 403 + field.appendChild(div); 404 + }); 405 + 406 + // Close detail panel when clicking canvas 407 + const canvas = document.querySelector('.canvas'); 408 + canvas.addEventListener('click', (e) => { 409 + if (e.target === canvas) { 410 + document.getElementById('detail').classList.remove('visible'); 411 + } 412 + }); 413 + }) 414 + .catch(e => { 415 + document.getElementById('field').innerHTML = 'error loading records'; 416 + console.error(e); 417 + });
+157
static/login.js
··· 1 + // Check for saved session 2 + const savedDid = localStorage.getItem('atme_did'); 3 + if (savedDid) { 4 + document.getElementById('loginForm').classList.add('hidden'); 5 + document.getElementById('restoring').classList.remove('hidden'); 6 + 7 + fetch('/api/restore-session', { 8 + method: 'POST', 9 + headers: { 'Content-Type': 'application/json' }, 10 + body: JSON.stringify({ did: savedDid }) 11 + }).then(r => { 12 + if (r.ok) { 13 + window.location.href = '/'; 14 + } else { 15 + localStorage.removeItem('atme_did'); 16 + document.getElementById('loginForm').classList.remove('hidden'); 17 + document.getElementById('restoring').classList.add('hidden'); 18 + } 19 + }).catch(() => { 20 + localStorage.removeItem('atme_did'); 21 + document.getElementById('loginForm').classList.remove('hidden'); 22 + document.getElementById('restoring').classList.add('hidden'); 23 + }); 24 + } 25 + 26 + // Fetch and cache atmosphere data 27 + async function fetchAtmosphere() { 28 + const CACHE_KEY = 'atme_atmosphere'; 29 + const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 30 + 31 + const cached = localStorage.getItem(CACHE_KEY); 32 + if (cached) { 33 + const { data, timestamp } = JSON.parse(cached); 34 + if (Date.now() - timestamp < CACHE_DURATION) { 35 + return data; 36 + } 37 + } 38 + 39 + try { 40 + const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50'); 41 + const json = await response.json(); 42 + 43 + // Group by namespace (first two segments) 44 + const namespaces = {}; 45 + json.collections.forEach(col => { 46 + const parts = col.nsid.split('.'); 47 + if (parts.length >= 2) { 48 + const ns = `${parts[0]}.${parts[1]}`; 49 + if (!namespaces[ns]) { 50 + namespaces[ns] = { 51 + namespace: ns, 52 + dids_total: 0, 53 + records_total: 0, 54 + collections: [] 55 + }; 56 + } 57 + namespaces[ns].dids_total += col.dids_estimate; 58 + namespaces[ns].records_total += col.creates; 59 + namespaces[ns].collections.push(col.nsid); 60 + } 61 + }); 62 + 63 + const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30); 64 + 65 + localStorage.setItem(CACHE_KEY, JSON.stringify({ 66 + data, 67 + timestamp: Date.now() 68 + })); 69 + 70 + return data; 71 + } catch (e) { 72 + console.error('Failed to fetch atmosphere data:', e); 73 + return []; 74 + } 75 + } 76 + 77 + // Try to fetch app avatar 78 + async function fetchAppAvatar(namespace) { 79 + const reversed = namespace.split('.').reverse().join('.'); 80 + const handles = [reversed, `${reversed}.bsky.social`]; 81 + 82 + for (const handle of handles) { 83 + try { 84 + const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 85 + if (!didRes.ok) continue; 86 + 87 + const { did } = await didRes.json(); 88 + const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`); 89 + if (!profileRes.ok) continue; 90 + 91 + const profile = await profileRes.json(); 92 + if (profile.avatar) return profile.avatar; 93 + } catch (e) { 94 + // Silently continue to next handle 95 + continue; 96 + } 97 + } 98 + return null; 99 + } 100 + 101 + // Render atmosphere 102 + async function renderAtmosphere() { 103 + const data = await fetchAtmosphere(); 104 + if (!data.length) return; 105 + 106 + const atmosphere = document.getElementById('atmosphere'); 107 + const maxSize = Math.max(...data.map(d => d.dids_total)); 108 + 109 + data.forEach((app, i) => { 110 + const orb = document.createElement('div'); 111 + orb.className = 'app-orb'; 112 + 113 + // Size based on user count (20-80px) 114 + const size = 20 + (app.dids_total / maxSize) * 60; 115 + 116 + // Position in 3D space 117 + const angle = (i / data.length) * Math.PI * 2; 118 + const radius = 250 + (i % 3) * 100; 119 + const y = (i % 5) * 80 - 160; 120 + const x = Math.cos(angle) * radius; 121 + const z = Math.sin(angle) * radius; 122 + 123 + orb.style.width = `${size}px`; 124 + orb.style.height = `${size}px`; 125 + orb.style.left = `calc(50% + ${x}px)`; 126 + orb.style.top = `calc(50% + ${y}px)`; 127 + orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`; 128 + orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`; 129 + orb.style.border = '1px solid rgba(255,255,255,0.1)'; 130 + orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)'; 131 + 132 + // Fallback letter 133 + const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase(); 134 + orb.innerHTML = `<div class="fallback">${letter}</div>`; 135 + 136 + // Tooltip 137 + const tooltip = document.createElement('div'); 138 + tooltip.className = 'app-tooltip'; 139 + const users = app.dids_total >= 1000000 140 + ? `${(app.dids_total / 1000000).toFixed(1)}M users` 141 + : `${(app.dids_total / 1000).toFixed(0)}K users`; 142 + tooltip.textContent = `${app.namespace} • ${users}`; 143 + orb.appendChild(tooltip); 144 + 145 + atmosphere.appendChild(orb); 146 + 147 + // Fetch and apply avatar 148 + fetchAppAvatar(app.namespace).then(avatarUrl => { 149 + if (avatarUrl) { 150 + orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`; 151 + orb.appendChild(tooltip); 152 + } 153 + }); 154 + }); 155 + } 156 + 157 + renderAtmosphere();
+191
static/onboarding.js
··· 1 + // Onboarding overlay for first-time users 2 + const ONBOARDING_KEY = 'atme_onboarding_seen'; 3 + 4 + const steps = [ 5 + { 6 + target: '.identity', 7 + title: 'this is you', 8 + description: 'your global identity and handle. your data is hosted at your personal data server (pds).', 9 + position: 'bottom' 10 + }, 11 + { 12 + target: '.canvas', 13 + title: 'third-party applications', 14 + description: 'these apps use your global identity to write public records to your pds. they can also read records you\'ve created.', 15 + position: 'center' 16 + }, 17 + { 18 + target: '.app-view', 19 + title: 'explore your records', 20 + description: 'click any app to see what records it has written to your pds.', 21 + position: 'bottom' 22 + } 23 + ]; 24 + 25 + let currentStep = 0; 26 + 27 + function showOnboarding() { 28 + const overlay = document.getElementById('onboardingOverlay'); 29 + if (!overlay) return; 30 + 31 + overlay.style.display = 'block'; 32 + setTimeout(() => { 33 + overlay.style.opacity = '1'; 34 + showStep(0); 35 + }, 50); 36 + } 37 + 38 + function hideOnboarding() { 39 + const overlay = document.getElementById('onboardingOverlay'); 40 + const spotlight = document.getElementById('onboardingSpotlight'); 41 + const content = document.getElementById('onboardingContent'); 42 + 43 + if (overlay) { 44 + overlay.style.opacity = '0'; 45 + setTimeout(() => { 46 + overlay.style.display = 'none'; 47 + }, 300); 48 + } 49 + 50 + if (spotlight) spotlight.classList.remove('active'); 51 + if (content) content.classList.remove('active'); 52 + 53 + localStorage.setItem(ONBOARDING_KEY, 'true'); 54 + } 55 + 56 + function showStep(stepIndex) { 57 + if (stepIndex >= steps.length) { 58 + hideOnboarding(); 59 + return; 60 + } 61 + 62 + currentStep = stepIndex; 63 + const step = steps[stepIndex]; 64 + const target = document.querySelector(step.target); 65 + 66 + if (!target) { 67 + console.warn('Onboarding target not found:', step.target); 68 + showStep(stepIndex + 1); 69 + return; 70 + } 71 + 72 + const spotlight = document.getElementById('onboardingSpotlight'); 73 + const content = document.getElementById('onboardingContent'); 74 + 75 + // Position spotlight on target 76 + const rect = target.getBoundingClientRect(); 77 + const padding = step.target === '.canvas' ? 100 : 20; 78 + 79 + spotlight.style.left = `${rect.left - padding}px`; 80 + spotlight.style.top = `${rect.top - padding}px`; 81 + spotlight.style.width = `${rect.width + padding * 2}px`; 82 + spotlight.style.height = `${rect.height + padding * 2}px`; 83 + spotlight.classList.add('active'); 84 + 85 + // Position content 86 + content.innerHTML = ` 87 + <h3>${step.title}</h3> 88 + <p>${step.description}</p> 89 + <div class="onboarding-actions"> 90 + <button id="skipOnboarding" class="onboarding-skip">skip</button> 91 + <button id="nextOnboarding" class="onboarding-next"> 92 + ${stepIndex === steps.length - 1 ? 'got it' : 'next'} 93 + </button> 94 + </div> 95 + <div class="onboarding-progress"> 96 + ${steps.map((_, i) => `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>`).join('')} 97 + </div> 98 + `; 99 + 100 + // Position content relative to spotlight 101 + let contentTop, contentLeft; 102 + const contentMaxWidth = Math.min(400, window.innerWidth * 0.9); // responsive max-width 103 + const contentHeight = 250; // approximate height 104 + const margin = Math.max(20, window.innerWidth * 0.05); // responsive margin 105 + 106 + if (step.position === 'bottom') { 107 + contentTop = rect.bottom + padding + margin; 108 + contentLeft = rect.left + rect.width / 2; 109 + 110 + // Check if it would go off bottom 111 + if (contentTop + contentHeight > window.innerHeight) { 112 + contentTop = rect.top - padding - contentHeight - margin; 113 + } 114 + } else if (step.position === 'center') { 115 + contentTop = window.innerHeight / 2 - contentHeight / 2; 116 + contentLeft = window.innerWidth / 2; 117 + } else { 118 + contentTop = rect.top - padding - contentHeight - margin; 119 + contentLeft = rect.left + rect.width / 2; 120 + 121 + // Check if it would go off top 122 + if (contentTop < margin) { 123 + contentTop = rect.bottom + padding + margin; 124 + } 125 + } 126 + 127 + // Ensure content stays on screen horizontally 128 + const halfWidth = contentMaxWidth / 2; 129 + if (contentLeft - halfWidth < margin) { 130 + contentLeft = halfWidth + margin; 131 + } else if (contentLeft + halfWidth > window.innerWidth - margin) { 132 + contentLeft = window.innerWidth - halfWidth - margin; 133 + } 134 + 135 + // Ensure content stays on screen vertically 136 + if (contentTop < margin) { 137 + contentTop = margin; 138 + } else if (contentTop + contentHeight > window.innerHeight - margin) { 139 + contentTop = window.innerHeight - contentHeight - margin; 140 + } 141 + 142 + content.style.top = `${contentTop}px`; 143 + content.style.left = `${contentLeft}px`; 144 + content.style.transform = 'translate(-50%, 0)'; 145 + content.classList.add('active'); 146 + 147 + // Add event listeners 148 + document.getElementById('skipOnboarding').addEventListener('click', hideOnboarding); 149 + document.getElementById('nextOnboarding').addEventListener('click', () => { 150 + showStep(stepIndex + 1); 151 + }); 152 + } 153 + 154 + // Initialize onboarding 155 + function initOnboarding() { 156 + const seen = localStorage.getItem(ONBOARDING_KEY); 157 + 158 + if (!seen) { 159 + // Wait for app circles to render 160 + setTimeout(() => { 161 + showOnboarding(); 162 + }, 1000); 163 + } 164 + } 165 + 166 + // ESC key handler 167 + document.addEventListener('keydown', (e) => { 168 + if (e.key === 'Escape') { 169 + const overlay = document.getElementById('onboardingOverlay'); 170 + if (overlay && overlay.style.display === 'block') { 171 + hideOnboarding(); 172 + } 173 + } 174 + }); 175 + 176 + // Help button handler to restart onboarding 177 + window.restartOnboarding = function() { 178 + localStorage.removeItem(ONBOARDING_KEY); 179 + document.getElementById('infoModal').classList.remove('visible'); 180 + document.getElementById('overlay').classList.remove('visible'); 181 + setTimeout(() => { 182 + showOnboarding(); 183 + }, 300); 184 + }; 185 + 186 + // Start onboarding after page loads 187 + if (document.readyState === 'loading') { 188 + document.addEventListener('DOMContentLoaded', initOnboarding); 189 + } else { 190 + initOnboarding(); 191 + }