add onboarding #6

merged
opened by zzstoatzz.io targeting main from onboarding-overlay

first time onboarding

+52
Cargo.lock
···
"tracing",
]
+
[[package]]
+
name = "actix-files"
+
version = "0.6.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576"
+
dependencies = [
+
"actix-http",
+
"actix-service",
+
"actix-utils",
+
"actix-web",
+
"bitflags",
+
"bytes",
+
"derive_more 2.0.1",
+
"futures-core",
+
"http-range",
+
"log",
+
"mime",
+
"mime_guess",
+
"percent-encoding",
+
"pin-project-lite",
+
"v_htmlescape",
+
]
+
[[package]]
name = "actix-http"
version = "3.11.2"
···
name = "at-me"
version = "0.1.0"
dependencies = [
+
"actix-files",
"actix-session",
"actix-web",
"atrium-api",
···
"pin-project-lite",
+
[[package]]
+
name = "http-range"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
[[package]]
name = "httparse"
version = "1.10.1"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
+
name = "mime_guess"
+
version = "2.0.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+
dependencies = [
+
"mime",
+
"unicase",
+
]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
[[package]]
+
name = "unicase"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
[[package]]
name = "unicode-ident"
version = "1.0.19"
···
"wasm-bindgen",
+
[[package]]
+
name = "v_htmlescape"
+
version = "0.15.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
+1
Cargo.toml
···
[dependencies]
actix-web = "4.10"
+
actix-files = "0.6"
actix-session = { version = "0.10", features = ["cookie-session"] }
atrium-api = "0.25"
atrium-common = "0.1"
+2
src/main.rs
···
use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore};
use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web};
+
use actix_files::Files;
mod oauth;
mod routes;
···
.service(routes::logout)
.service(routes::restore_session)
.service(routes::favicon)
+
.service(Files::new("/static", "./static"))
})
.bind(("0.0.0.0", 8080))?
.run()
+156 -617
src/templates.rs
···
by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a>
</div>
-
<script>
-
// Check for saved session
-
const savedDid = localStorage.getItem('atme_did');
-
if (savedDid) {
-
document.getElementById('loginForm').classList.add('hidden');
-
document.getElementById('restoring').classList.remove('hidden');
-
-
fetch('/api/restore-session', {
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ did: savedDid })
-
}).then(r => {
-
if (r.ok) {
-
window.location.href = '/';
-
} else {
-
localStorage.removeItem('atme_did');
-
document.getElementById('loginForm').classList.remove('hidden');
-
document.getElementById('restoring').classList.add('hidden');
-
}
-
}).catch(() => {
-
localStorage.removeItem('atme_did');
-
document.getElementById('loginForm').classList.remove('hidden');
-
document.getElementById('restoring').classList.add('hidden');
-
});
-
}
-
-
// Fetch and cache atmosphere data
-
async function fetchAtmosphere() {
-
const CACHE_KEY = 'atme_atmosphere';
-
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
-
-
const cached = localStorage.getItem(CACHE_KEY);
-
if (cached) {
-
const { data, timestamp } = JSON.parse(cached);
-
if (Date.now() - timestamp < CACHE_DURATION) {
-
return data;
-
}
-
}
-
-
try {
-
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
-
const json = await response.json();
-
-
// Group by namespace (first two segments)
-
const namespaces = {};
-
json.collections.forEach(col => {
-
const parts = col.nsid.split('.');
-
if (parts.length >= 2) {
-
const ns = `${parts[0]}.${parts[1]}`;
-
if (!namespaces[ns]) {
-
namespaces[ns] = {
-
namespace: ns,
-
dids_total: 0,
-
records_total: 0,
-
collections: []
-
};
-
}
-
namespaces[ns].dids_total += col.dids_estimate;
-
namespaces[ns].records_total += col.creates;
-
namespaces[ns].collections.push(col.nsid);
-
}
-
});
-
-
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
-
-
localStorage.setItem(CACHE_KEY, JSON.stringify({
-
data,
-
timestamp: Date.now()
-
}));
-
-
return data;
-
} catch (e) {
-
console.error('Failed to fetch atmosphere data:', e);
-
return [];
-
}
-
}
-
-
// Try to fetch app avatar
-
async function fetchAppAvatar(namespace) {
-
const reversed = namespace.split('.').reverse().join('.');
-
const handles = [reversed, `${reversed}.bsky.social`];
-
-
for (const handle of handles) {
-
try {
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
-
if (!didRes.ok) continue;
-
-
const { did } = await didRes.json();
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
-
if (!profileRes.ok) continue;
-
-
const profile = await profileRes.json();
-
if (profile.avatar) return profile.avatar;
-
} catch (e) {
-
continue;
-
}
-
}
-
return null;
-
}
-
-
// Render atmosphere
-
async function renderAtmosphere() {
-
const data = await fetchAtmosphere();
-
if (!data.length) return;
-
-
const atmosphere = document.getElementById('atmosphere');
-
const maxSize = Math.max(...data.map(d => d.dids_total));
-
-
data.forEach((app, i) => {
-
const orb = document.createElement('div');
-
orb.className = 'app-orb';
-
-
// Size based on user count (20-80px)
-
const size = 20 + (app.dids_total / maxSize) * 60;
-
-
// Position in 3D space
-
const angle = (i / data.length) * Math.PI * 2;
-
const radius = 250 + (i % 3) * 100;
-
const y = (i % 5) * 80 - 160;
-
const x = Math.cos(angle) * radius;
-
const z = Math.sin(angle) * radius;
-
-
orb.style.width = `${size}px`;
-
orb.style.height = `${size}px`;
-
orb.style.left = `calc(50% + ${x}px)`;
-
orb.style.top = `calc(50% + ${y}px)`;
-
orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`;
-
orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`;
-
orb.style.border = '1px solid rgba(255,255,255,0.1)';
-
orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)';
-
-
// Fallback letter
-
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
-
orb.innerHTML = `<div class="fallback">${letter}</div>`;
-
-
// Tooltip
-
const tooltip = document.createElement('div');
-
tooltip.className = 'app-tooltip';
-
const users = app.dids_total >= 1000000
-
? `${(app.dids_total / 1000000).toFixed(1)}M users`
-
: `${(app.dids_total / 1000).toFixed(0)}K users`;
-
tooltip.textContent = `${app.namespace} • ${users}`;
-
orb.appendChild(tooltip);
-
-
atmosphere.appendChild(orb);
-
-
// Fetch and apply avatar
-
fetchAppAvatar(app.namespace).then(avatarUrl => {
-
if (avatarUrl) {
-
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
-
orb.appendChild(tooltip);
-
}
-
});
-
});
-
}
-
-
renderAtmosphere();
-
</script>
+
<script src="/static/login.js"></script>
</body>
</html>
"#
···
.logout {{
position: fixed;
-
top: 1.5rem;
-
right: 1.5rem;
-
font-size: 0.7rem;
+
top: clamp(1rem, 2vmin, 1.5rem);
+
right: clamp(1rem, 2vmin, 1.5rem);
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
color: var(--text-light);
text-decoration: none;
border: 1px solid var(--border);
-
padding: 0.4rem 0.8rem;
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
transition: all 0.2s ease;
z-index: 100;
-webkit-tap-highlight-color: transparent;
···
border-color: var(--text-light);
}}
-
@media (max-width: 768px) {{
-
.logout {{
-
padding: 0.6rem 1rem;
-
font-size: 0.75rem;
-
top: 1rem;
-
right: 1rem;
-
}}
-
}}
-
.info {{
position: fixed;
-
top: 1.5rem;
-
left: 1.5rem;
-
width: 32px;
-
height: 32px;
+
top: clamp(1rem, 2vmin, 1.5rem);
+
left: clamp(1rem, 2vmin, 1.5rem);
+
width: clamp(32px, 6vmin, 40px);
+
height: clamp(32px, 6vmin, 40px);
border-radius: 50%;
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
-
font-size: 0.75rem;
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
color: var(--text-light);
cursor: pointer;
transition: all 0.2s ease;
···
border-color: var(--text-light);
}}
-
@media (max-width: 768px) {{
-
.info {{
-
width: 40px;
-
height: 40px;
-
font-size: 0.85rem;
-
top: 1rem;
-
left: 1rem;
-
}}
-
}}
-
.info-modal {{
position: fixed;
top: 50%;
···
background: var(--surface);
border: 2px solid var(--text-light);
border-radius: 50%;
-
width: 120px;
-
height: 120px;
+
width: clamp(100px, 20vmin, 140px);
+
height: clamp(100px, 20vmin, 140px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
-
gap: 0.3rem;
+
gap: clamp(0.2rem, 1vmin, 0.3rem);
+
padding: clamp(0.4rem, 1vmin, 0.6rem);
z-index: 10;
cursor: pointer;
transition: all 0.2s ease;
···
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
}}
-
@media (max-width: 768px) {{
-
.identity {{
-
width: 100px;
-
height: 100px;
-
}}
-
}}
-
.identity-label {{
-
font-size: 1.2rem;
+
font-size: clamp(1rem, 2vmin, 1.2rem);
color: var(--text);
font-weight: 600;
line-height: 1;
}}
.identity-value {{
-
font-size: 0.7rem;
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
color: var(--text-lighter);
text-align: center;
word-break: break-word;
-
max-width: 100px;
+
max-width: 90%;
font-weight: 400;
-
}}
-
-
@media (max-width: 768px) {{
-
.identity-label {{
-
font-size: 1.1rem;
-
}}
-
-
.identity-value {{
-
font-size: 0.65rem;
-
}}
+
line-height: 1.2;
}}
.identity-hint {{
-
font-size: 0.4rem;
+
font-size: clamp(0.35rem, 0.8vmin, 0.45rem);
color: var(--text-lighter);
margin-top: 0.2rem;
letter-spacing: 0.05em;
}}
+
.identity-avatar {{
+
width: clamp(30px, 6vmin, 45px);
+
height: clamp(30px, 6vmin, 45px);
+
border-radius: 50%;
+
object-fit: cover;
+
border: 2px solid var(--text-light);
+
margin-bottom: clamp(0.2rem, 1vmin, 0.3rem);
+
}}
+
.app-view {{
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
-
gap: 0.4rem;
+
gap: clamp(0.3rem, 1vmin, 0.5rem);
cursor: pointer;
transition: all 0.2s ease;
opacity: 0.7;
···
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 50%;
-
width: 60px;
-
height: 60px;
+
width: clamp(45px, 8vmin, 60px);
+
height: clamp(45px, 8vmin, 60px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
overflow: hidden;
+
font-size: clamp(1rem, 2vmin, 1.5rem);
}}
.app-logo {{
···
}}
.app-name {{
-
font-size: 0.65rem;
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
color: var(--text);
text-align: center;
-
max-width: 100px;
+
max-width: clamp(80px, 15vmin, 120px);
}}
.detail-panel {{
···
.footer {{
position: fixed;
-
bottom: 1rem;
+
bottom: clamp(0.75rem, 2vmin, 1rem);
left: 50%;
transform: translateX(-50%);
-
font-size: 0.65rem;
-
color: var(--text-light);
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
+
color: var(--text);
z-index: 100;
+
background: var(--surface);
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.75rem, 2vmin, 1rem);
+
border-radius: 4px;
+
border: 1px solid var(--border);
}}
.footer a {{
-
color: var(--text-light);
+
color: var(--text);
text-decoration: none;
border-bottom: 1px solid transparent;
-
transition: border-color 0.2s ease;
+
transition: all 0.2s ease;
}}
.footer a:hover {{
-
border-bottom-color: var(--text-light);
+
border-bottom-color: var(--text);
}}
.loading {{ color: var(--text-light); font-size: 0.75rem; }}
+
+
.onboarding-overlay {{
+
position: fixed;
+
inset: 0;
+
background: transparent;
+
z-index: 3000;
+
display: none;
+
opacity: 0;
+
transition: opacity 0.3s ease;
+
pointer-events: none;
+
}}
+
+
.onboarding-overlay.active {{
+
display: block;
+
opacity: 1;
+
}}
+
+
.onboarding-spotlight {{
+
position: absolute;
+
border: 2px solid rgba(255, 255, 255, 0.9);
+
border-radius: 50%;
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5);
+
pointer-events: none;
+
transition: all 0.5s ease;
+
}}
+
+
.onboarding-content {{
+
position: fixed;
+
background: var(--surface);
+
border: 2px solid var(--border);
+
padding: clamp(1rem, 3vmin, 2rem);
+
max-width: min(400px, 90vw);
+
z-index: 3001;
+
border-radius: 4px;
+
transition: all 0.3s ease;
+
pointer-events: auto;
+
}}
+
+
.onboarding-content h3 {{
+
font-size: clamp(0.9rem, 2vmin, 1.1rem);
+
margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem);
+
color: var(--text);
+
font-weight: 500;
+
}}
+
+
.onboarding-content p {{
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
+
color: var(--text-light);
+
line-height: 1.5;
+
margin-bottom: clamp(1rem, 2vmin, 1.25rem);
+
}}
+
+
.onboarding-actions {{
+
display: flex;
+
gap: clamp(0.5rem, 1.5vmin, 0.75rem);
+
justify-content: flex-end;
+
}}
+
+
.onboarding-actions button {{
+
font-family: inherit;
+
font-size: clamp(0.7rem, 1.5vmin, 0.8rem);
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
+
background: transparent;
+
border: 1px solid var(--border);
+
color: var(--text);
+
cursor: pointer;
+
transition: all 0.2s ease;
+
border-radius: 2px;
+
}}
+
+
.onboarding-actions button:hover {{
+
background: var(--surface-hover);
+
border-color: var(--text-light);
+
}}
+
+
.onboarding-actions button.primary {{
+
background: var(--surface-hover);
+
border-color: var(--text-light);
+
}}
+
+
.onboarding-progress {{
+
display: flex;
+
gap: clamp(0.4rem, 1vmin, 0.5rem);
+
justify-content: center;
+
margin-top: clamp(0.75rem, 2vmin, 1rem);
+
}}
+
+
.onboarding-progress span {{
+
width: clamp(6px, 1.5vmin, 8px);
+
height: clamp(6px, 1.5vmin, 8px);
+
border-radius: 50%;
+
background: var(--border);
+
transition: background 0.3s ease;
+
}}
+
+
.onboarding-progress span.active {{
+
background: var(--text);
+
}}
+
+
.onboarding-progress span.done {{
+
background: var(--text-light);
+
}}
</style>
</head>
<body>
-
<div class="info" id="infoBtn">i</div>
+
<div class="info" id="infoBtn">?</div>
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
<div class="overlay" id="overlay"></div>
···
<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>
<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>
<button id="closeInfo">got it</button>
+
<button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button>
+
</div>
+
+
<div class="onboarding-overlay" id="onboardingOverlay">
+
<div class="onboarding-spotlight" id="onboardingSpotlight"></div>
+
<div class="onboarding-content" id="onboardingContent"></div>
</div>
<div class="canvas">
···
<a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
</div>
<script>
-
const did = '{}';
-
localStorage.setItem('atme_did', did);
-
-
let globalPds = null;
-
let globalHandle = null;
-
-
// Try to fetch app avatar from their bsky profile
-
async function fetchAppAvatar(namespace) {{
-
try {{
-
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
-
const reversed = namespace.split('.').reverse().join('.');
-
// Try reversed domain, then reversed.bsky.social
-
const handles = [reversed, `${{reversed}}.bsky.social`];
-
-
for (const handle of handles) {{
-
try {{
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${{handle}}`);
-
if (!didRes.ok) continue;
-
-
const {{ did }} = await didRes.json();
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{did}}`);
-
if (!profileRes.ok) continue;
-
-
const profile = await profileRes.json();
-
if (profile.avatar) {{
-
return profile.avatar;
-
}}
-
}} catch (e) {{
-
continue;
-
}}
-
}}
-
}} catch (e) {{
-
console.log('Could not fetch avatar for', namespace);
-
}}
-
return null;
-
}}
-
-
// Logout handler
-
document.getElementById('logoutBtn').addEventListener('click', (e) => {{
-
e.preventDefault();
-
localStorage.removeItem('atme_did');
-
window.location.href = '/logout';
-
}});
-
-
// Info modal handlers
-
document.getElementById('infoBtn').addEventListener('click', () => {{
-
document.getElementById('infoModal').classList.add('visible');
-
document.getElementById('overlay').classList.add('visible');
-
}});
-
-
document.getElementById('closeInfo').addEventListener('click', () => {{
-
document.getElementById('infoModal').classList.remove('visible');
-
document.getElementById('overlay').classList.remove('visible');
-
}});
-
-
document.getElementById('overlay').addEventListener('click', () => {{
-
document.getElementById('infoModal').classList.remove('visible');
-
document.getElementById('overlay').classList.remove('visible');
-
const detail = document.getElementById('detail');
-
detail.classList.remove('visible');
-
}});
-
-
// First resolve DID to get PDS endpoint and handle
-
fetch('https://plc.directory/' + did)
-
.then(r => r.json())
-
.then(didDoc => {{
-
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
-
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
-
-
globalPds = pds;
-
globalHandle = handle;
-
-
// Update identity display with handle
-
document.getElementById('handle').textContent = handle;
-
-
// Add identity click handler to show PDS info
-
document.querySelector('.identity').addEventListener('click', () => {{
-
const detail = document.getElementById('detail');
-
const pdsHost = pds.replace('https://', '').replace('http://', '');
-
detail.innerHTML = `
-
<button class="detail-close" id="detailClose">×</button>
-
<h3>your identity</h3>
-
<div class="subtitle">decentralized identifier & storage</div>
-
<div class="tree-item">
-
<div class="tree-item-header">
-
<span style="color: var(--text-light);">did</span>
-
<span style="font-size: 0.6rem; color: var(--text);">${{did}}</span>
-
</div>
-
</div>
-
<div class="tree-item">
-
<div class="tree-item-header">
-
<span style="color: var(--text-light);">handle</span>
-
<span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span>
-
</div>
-
</div>
-
<div class="tree-item">
-
<div class="tree-item-header">
-
<span style="color: var(--text-light);">personal data server</span>
-
<span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span>
-
</div>
-
</div>
-
<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);">
-
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.
-
</div>
-
`;
-
detail.classList.add('visible');
-
-
// Add close button handler
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
-
e.stopPropagation();
-
detail.classList.remove('visible');
-
}});
-
}});
-
-
// Get all collections from PDS
-
return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`);
-
}})
-
.then(r => r.json())
-
.then(repo => {{
-
const collections = repo.collections || [];
-
-
// Group by app namespace (first two parts of lexicon)
-
const apps = {{}};
-
collections.forEach(collection => {{
-
const parts = collection.split('.');
-
if (parts.length >= 2) {{
-
const namespace = `${{parts[0]}}.${{parts[1]}}`;
-
if (!apps[namespace]) apps[namespace] = [];
-
apps[namespace].push(collection);
-
}}
-
}});
-
-
const field = document.getElementById('field');
-
field.innerHTML = '';
-
field.classList.remove('loading');
-
-
const appNames = Object.keys(apps).sort();
-
const radius = 240;
-
const centerX = window.innerWidth / 2;
-
const centerY = window.innerHeight / 2;
-
-
appNames.forEach((namespace, i) => {{
-
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
-
const x = centerX + radius * Math.cos(angle) - 25;
-
const y = centerY + radius * Math.sin(angle) - 30;
-
-
const div = document.createElement('div');
-
div.className = 'app-view';
-
div.style.left = `${{x}}px`;
-
div.style.top = `${{y}}px`;
-
-
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
-
-
div.innerHTML = `
-
<div class="app-circle" data-namespace="${{namespace}}">${{firstLetter}}</div>
-
<div class="app-name">${{namespace}}</div>
-
`;
-
-
// Try to fetch and display avatar
-
fetchAppAvatar(namespace).then(avatarUrl => {{
-
if (avatarUrl) {{
-
const circle = div.querySelector('.app-circle');
-
circle.innerHTML = `<img src="${{avatarUrl}}" class="app-logo" alt="${{namespace}}" />`;
-
}}
-
}});
-
-
div.addEventListener('click', () => {{
-
const detail = document.getElementById('detail');
-
const collections = apps[namespace];
-
-
let html = `
-
<button class="detail-close" id="detailClose">×</button>
-
<h3>${{namespace}}</h3>
-
<div class="subtitle">records stored in your pds:</div>
-
`;
-
-
if (collections && collections.length > 0) {{
-
// Group collections by sub-namespace (third segment)
-
const grouped = {{}};
-
collections.forEach(lexicon => {{
-
const parts = lexicon.split('.');
-
const subNamespace = parts.slice(2).join('.');
-
const firstPart = parts[2] || lexicon;
-
-
if (!grouped[firstPart]) grouped[firstPart] = [];
-
grouped[firstPart].push({{ lexicon, subNamespace }});
-
}});
-
-
// Sort and display grouped items
-
Object.keys(grouped).sort().forEach(group => {{
-
const items = grouped[group];
-
-
if (items.length === 1 && items[0].subNamespace === group) {{
-
// Single item with no further nesting
-
html += `
-
<div class="tree-item" data-lexicon="${{items[0].lexicon}}">
-
<div class="tree-item-header">
-
<span>${{group}}</span>
-
<span class="tree-item-count">loading...</span>
-
</div>
-
</div>
-
`;
-
}} else {{
-
// Group header
-
html += `<div style="margin-bottom: 0.75rem;">`;
-
html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${{group}}</div>`;
-
-
// Items in group
-
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {{
-
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
-
html += `
-
<div class="tree-item" data-lexicon="${{item.lexicon}}" style="margin-left: 0.75rem;">
-
<div class="tree-item-header">
-
<span>${{displayName}}</span>
-
<span class="tree-item-count">loading...</span>
-
</div>
-
</div>
-
`;
-
}});
-
html += `</div>`;
-
}}
-
}});
-
}} else {{
-
html += `<div class="tree-item">no collections found</div>`;
-
}}
-
-
detail.innerHTML = html;
-
detail.classList.add('visible');
-
-
// Add close button handler
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
-
e.stopPropagation();
-
detail.classList.remove('visible');
-
}});
-
-
// Fetch record counts for each collection
-
if (collections && collections.length > 0) {{
-
collections.forEach(lexicon => {{
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`)
-
.then(r => r.json())
-
.then(data => {{
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
-
if (item) {{
-
const countSpan = item.querySelector('.tree-item-count');
-
// The cursor field indicates there are more records
-
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
-
}}
-
}})
-
.catch(e => {{
-
console.error('Error fetching count for', lexicon, e);
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
-
if (item) {{
-
const countSpan = item.querySelector('.tree-item-count');
-
countSpan.textContent = 'error';
-
}}
-
}});
-
}});
-
}}
-
-
// Add click handlers to tree items to fetch actual records
-
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{
-
item.addEventListener('click', (e) => {{
-
e.stopPropagation();
-
const lexicon = item.dataset.lexicon;
-
const existingRecords = item.querySelector('.record-list');
-
-
if (existingRecords) {{
-
existingRecords.remove();
-
return;
-
}}
-
-
const recordListDiv = document.createElement('div');
-
recordListDiv.className = 'record-list';
-
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
-
item.appendChild(recordListDiv);
-
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`)
-
.then(r => r.json())
-
.then(data => {{
-
if (data.records && data.records.length > 0) {{
-
let recordsHtml = '';
-
data.records.forEach((record, idx) => {{
-
const json = JSON.stringify(record.value, null, 2);
-
const recordId = `record-${{Date.now()}}-${{idx}}`;
-
recordsHtml += `
-
<div class="record">
-
<div class="record-header">
-
<span class="record-label">record</span>
-
<button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button>
-
</div>
-
<div class="record-content">
-
<pre>${{json}}</pre>
-
</div>
-
</div>
-
`;
-
}});
-
-
if (data.cursor && data.records.length === 5) {{
-
recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`;
-
}}
-
-
recordListDiv.innerHTML = recordsHtml;
-
-
// Use event delegation for copy and load more buttons
-
recordListDiv.addEventListener('click', (e) => {{
-
// Handle copy button
-
if (e.target.classList.contains('copy-btn')) {{
-
e.stopPropagation();
-
const copyBtn = e.target;
-
const content = decodeURIComponent(copyBtn.dataset.content);
-
-
navigator.clipboard.writeText(content).then(() => {{
-
const originalText = copyBtn.textContent;
-
copyBtn.textContent = 'copied!';
-
copyBtn.classList.add('copied');
-
setTimeout(() => {{
-
copyBtn.textContent = originalText;
-
copyBtn.classList.remove('copied');
-
}}, 1500);
-
}}).catch(err => {{
-
console.error('Failed to copy:', err);
-
copyBtn.textContent = 'error';
-
setTimeout(() => {{
-
copyBtn.textContent = 'copy';
-
}}, 1500);
-
}});
-
}}
-
-
// Handle load more button
-
if (e.target.classList.contains('load-more')) {{
-
e.stopPropagation();
-
const loadMoreBtn = e.target;
-
const cursor = loadMoreBtn.dataset.cursor;
-
const lexicon = loadMoreBtn.dataset.lexicon;
-
-
loadMoreBtn.textContent = 'loading...';
-
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`)
-
.then(r => r.json())
-
.then(moreData => {{
-
let moreHtml = '';
-
moreData.records.forEach((record, idx) => {{
-
const json = JSON.stringify(record.value, null, 2);
-
const recordId = `record-more-${{Date.now()}}-${{idx}}`;
-
moreHtml += `
-
<div class="record">
-
<div class="record-header">
-
<span class="record-label">record</span>
-
<button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button>
-
</div>
-
<div class="record-content">
-
<pre>${{json}}</pre>
-
</div>
-
</div>
-
`;
-
}});
-
-
loadMoreBtn.remove();
-
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
-
-
if (moreData.cursor && moreData.records.length === 5) {{
-
recordListDiv.insertAdjacentHTML('beforeend',
-
`<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>`
-
);
-
}}
-
}});
-
}}
-
}});
-
}} else {{
-
recordListDiv.innerHTML = '<div class="record">no records found</div>';
-
}}
-
}})
-
.catch(e => {{
-
console.error('Error fetching records:', e);
-
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
-
}});
-
}});
-
}});
-
}});
-
-
field.appendChild(div);
-
}});
-
-
// Close detail panel when clicking canvas
-
const canvas = document.querySelector('.canvas');
-
canvas.addEventListener('click', (e) => {{
-
if (e.target === canvas) {{
-
document.getElementById('detail').classList.remove('visible');
-
}}
-
}});
-
}})
-
.catch(e => {{
-
document.getElementById('field').innerHTML = 'error loading records';
-
console.error(e);
-
}});
+
window.DID = '{}';
</script>
+
<script src="/static/app.js"></script>
+
<script src="/static/onboarding.js"></script>
</body>
</html>
"#, did)
+417
static/app.js
···
+
// DID is set as window.DID by the template
+
const did = window.DID;
+
localStorage.setItem('atme_did', did);
+
+
let globalPds = null;
+
let globalHandle = null;
+
+
// Try to fetch app avatar from their bsky profile
+
async function fetchAppAvatar(namespace) {
+
try {
+
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
+
const reversed = namespace.split('.').reverse().join('.');
+
// Try reversed domain, then reversed.bsky.social
+
const handles = [reversed, `${reversed}.bsky.social`];
+
+
for (const handle of handles) {
+
try {
+
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
+
if (!didRes.ok) continue;
+
+
const { did } = await didRes.json();
+
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
+
if (!profileRes.ok) continue;
+
+
const profile = await profileRes.json();
+
if (profile.avatar) {
+
return profile.avatar;
+
}
+
} catch (e) {
+
// Silently continue to next handle
+
continue;
+
}
+
}
+
} catch (e) {
+
// Expected for namespaces without Bluesky accounts
+
}
+
return null;
+
}
+
+
// Logout handler
+
document.getElementById('logoutBtn').addEventListener('click', (e) => {
+
e.preventDefault();
+
localStorage.removeItem('atme_did');
+
window.location.href = '/logout';
+
});
+
+
// Info modal handlers
+
document.getElementById('infoBtn').addEventListener('click', () => {
+
document.getElementById('infoModal').classList.add('visible');
+
document.getElementById('overlay').classList.add('visible');
+
});
+
+
document.getElementById('closeInfo').addEventListener('click', () => {
+
document.getElementById('infoModal').classList.remove('visible');
+
document.getElementById('overlay').classList.remove('visible');
+
});
+
+
document.getElementById('overlay').addEventListener('click', () => {
+
document.getElementById('infoModal').classList.remove('visible');
+
document.getElementById('overlay').classList.remove('visible');
+
const detail = document.getElementById('detail');
+
detail.classList.remove('visible');
+
});
+
+
// First resolve DID to get PDS endpoint and handle
+
fetch('https://plc.directory/' + did)
+
.then(r => r.json())
+
.then(didDoc => {
+
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
+
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
+
+
globalPds = pds;
+
globalHandle = handle;
+
+
// Update identity display with handle
+
document.getElementById('handle').textContent = handle;
+
+
// Try to fetch and display user's avatar
+
fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
+
.then(r => r.json())
+
.then(profile => {
+
if (profile.avatar) {
+
const identity = document.querySelector('.identity');
+
const avatarImg = document.createElement('img');
+
avatarImg.src = profile.avatar;
+
avatarImg.className = 'identity-avatar';
+
avatarImg.alt = handle;
+
// Insert avatar before the @ label
+
identity.insertBefore(avatarImg, identity.firstChild);
+
}
+
})
+
.catch(() => {
+
// User may not have an avatar set
+
});
+
+
// Add identity click handler to show PDS info
+
document.querySelector('.identity').addEventListener('click', () => {
+
const detail = document.getElementById('detail');
+
const pdsHost = pds.replace('https://', '').replace('http://', '');
+
detail.innerHTML = `
+
<button class="detail-close" id="detailClose">×</button>
+
<h3>your identity</h3>
+
<div class="subtitle">decentralized identifier & storage</div>
+
<div class="tree-item">
+
<div class="tree-item-header">
+
<span style="color: var(--text-light);">did</span>
+
<span style="font-size: 0.6rem; color: var(--text);">${did}</span>
+
</div>
+
</div>
+
<div class="tree-item">
+
<div class="tree-item-header">
+
<span style="color: var(--text-light);">handle</span>
+
<span style="font-size: 0.6rem; color: var(--text);">@${handle}</span>
+
</div>
+
</div>
+
<div class="tree-item">
+
<div class="tree-item-header">
+
<span style="color: var(--text-light);">personal data server</span>
+
<span style="font-size: 0.6rem; color: var(--text);">${pds}</span>
+
</div>
+
</div>
+
<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);">
+
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.
+
</div>
+
`;
+
detail.classList.add('visible');
+
+
// Add close button handler
+
document.getElementById('detailClose').addEventListener('click', (e) => {
+
e.stopPropagation();
+
detail.classList.remove('visible');
+
});
+
});
+
+
// Get all collections from PDS
+
return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`);
+
})
+
.then(r => r.json())
+
.then(repo => {
+
const collections = repo.collections || [];
+
+
// Group by app namespace (first two parts of lexicon)
+
const apps = {};
+
collections.forEach(collection => {
+
const parts = collection.split('.');
+
if (parts.length >= 2) {
+
const namespace = `${parts[0]}.${parts[1]}`;
+
if (!apps[namespace]) apps[namespace] = [];
+
apps[namespace].push(collection);
+
}
+
});
+
+
const field = document.getElementById('field');
+
field.innerHTML = '';
+
field.classList.remove('loading');
+
+
const appNames = Object.keys(apps).sort();
+
// Responsive radius: use viewport-relative sizing with min/max bounds
+
const vmin = Math.min(window.innerWidth, window.innerHeight);
+
const radius = Math.max(vmin * 0.35, 150); // 35% of smallest dimension, min 150px
+
const centerX = window.innerWidth / 2;
+
const centerY = window.innerHeight / 2;
+
+
appNames.forEach((namespace, i) => {
+
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
+
const x = centerX + radius * Math.cos(angle) - 30;
+
const y = centerY + radius * Math.sin(angle) - 30;
+
+
const div = document.createElement('div');
+
div.className = 'app-view';
+
div.style.left = `${x}px`;
+
div.style.top = `${y}px`;
+
+
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
+
+
div.innerHTML = `
+
<div class="app-circle" data-namespace="${namespace}">${firstLetter}</div>
+
<div class="app-name">${namespace}</div>
+
`;
+
+
// Try to fetch and display avatar
+
fetchAppAvatar(namespace).then(avatarUrl => {
+
if (avatarUrl) {
+
const circle = div.querySelector('.app-circle');
+
circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`;
+
}
+
});
+
+
div.addEventListener('click', () => {
+
const detail = document.getElementById('detail');
+
const collections = apps[namespace];
+
+
let html = `
+
<button class="detail-close" id="detailClose">×</button>
+
<h3>${namespace}</h3>
+
<div class="subtitle">records stored in your pds:</div>
+
`;
+
+
if (collections && collections.length > 0) {
+
// Group collections by sub-namespace (third segment)
+
const grouped = {};
+
collections.forEach(lexicon => {
+
const parts = lexicon.split('.');
+
const subNamespace = parts.slice(2).join('.');
+
const firstPart = parts[2] || lexicon;
+
+
if (!grouped[firstPart]) grouped[firstPart] = [];
+
grouped[firstPart].push({ lexicon, subNamespace });
+
});
+
+
// Sort and display grouped items
+
Object.keys(grouped).sort().forEach(group => {
+
const items = grouped[group];
+
+
if (items.length === 1 && items[0].subNamespace === group) {
+
// Single item with no further nesting
+
html += `
+
<div class="tree-item" data-lexicon="${items[0].lexicon}">
+
<div class="tree-item-header">
+
<span>${group}</span>
+
<span class="tree-item-count">loading...</span>
+
</div>
+
</div>
+
`;
+
} else {
+
// Group header
+
html += `<div style="margin-bottom: 0.75rem;">`;
+
html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`;
+
+
// Items in group
+
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {
+
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
+
html += `
+
<div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;">
+
<div class="tree-item-header">
+
<span>${displayName}</span>
+
<span class="tree-item-count">loading...</span>
+
</div>
+
</div>
+
`;
+
});
+
html += `</div>`;
+
}
+
});
+
} else {
+
html += `<div class="tree-item">no collections found</div>`;
+
}
+
+
detail.innerHTML = html;
+
detail.classList.add('visible');
+
+
// Add close button handler
+
document.getElementById('detailClose').addEventListener('click', (e) => {
+
e.stopPropagation();
+
detail.classList.remove('visible');
+
});
+
+
// Fetch record counts for each collection
+
if (collections && collections.length > 0) {
+
collections.forEach(lexicon => {
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`)
+
.then(r => r.json())
+
.then(data => {
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
+
if (item) {
+
const countSpan = item.querySelector('.tree-item-count');
+
// The cursor field indicates there are more records
+
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
+
}
+
})
+
.catch(e => {
+
console.error('Error fetching count for', lexicon, e);
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
+
if (item) {
+
const countSpan = item.querySelector('.tree-item-count');
+
countSpan.textContent = 'error';
+
}
+
});
+
});
+
}
+
+
// Add click handlers to tree items to fetch actual records
+
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {
+
item.addEventListener('click', (e) => {
+
e.stopPropagation();
+
const lexicon = item.dataset.lexicon;
+
const existingRecords = item.querySelector('.record-list');
+
+
if (existingRecords) {
+
existingRecords.remove();
+
return;
+
}
+
+
const recordListDiv = document.createElement('div');
+
recordListDiv.className = 'record-list';
+
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
+
item.appendChild(recordListDiv);
+
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`)
+
.then(r => r.json())
+
.then(data => {
+
if (data.records && data.records.length > 0) {
+
let recordsHtml = '';
+
data.records.forEach((record, idx) => {
+
const json = JSON.stringify(record.value, null, 2);
+
const recordId = `record-${Date.now()}-${idx}`;
+
recordsHtml += `
+
<div class="record">
+
<div class="record-header">
+
<span class="record-label">record</span>
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
+
</div>
+
<div class="record-content">
+
<pre>${json}</pre>
+
</div>
+
</div>
+
`;
+
});
+
+
if (data.cursor && data.records.length === 5) {
+
recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`;
+
}
+
+
recordListDiv.innerHTML = recordsHtml;
+
+
// Use event delegation for copy and load more buttons
+
recordListDiv.addEventListener('click', (e) => {
+
// Handle copy button
+
if (e.target.classList.contains('copy-btn')) {
+
e.stopPropagation();
+
const copyBtn = e.target;
+
const content = decodeURIComponent(copyBtn.dataset.content);
+
+
navigator.clipboard.writeText(content).then(() => {
+
const originalText = copyBtn.textContent;
+
copyBtn.textContent = 'copied!';
+
copyBtn.classList.add('copied');
+
setTimeout(() => {
+
copyBtn.textContent = originalText;
+
copyBtn.classList.remove('copied');
+
}, 1500);
+
}).catch(err => {
+
console.error('Failed to copy:', err);
+
copyBtn.textContent = 'error';
+
setTimeout(() => {
+
copyBtn.textContent = 'copy';
+
}, 1500);
+
});
+
}
+
+
// Handle load more button
+
if (e.target.classList.contains('load-more')) {
+
e.stopPropagation();
+
const loadMoreBtn = e.target;
+
const cursor = loadMoreBtn.dataset.cursor;
+
const lexicon = loadMoreBtn.dataset.lexicon;
+
+
loadMoreBtn.textContent = 'loading...';
+
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`)
+
.then(r => r.json())
+
.then(moreData => {
+
let moreHtml = '';
+
moreData.records.forEach((record, idx) => {
+
const json = JSON.stringify(record.value, null, 2);
+
const recordId = `record-more-${Date.now()}-${idx}`;
+
moreHtml += `
+
<div class="record">
+
<div class="record-header">
+
<span class="record-label">record</span>
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
+
</div>
+
<div class="record-content">
+
<pre>${json}</pre>
+
</div>
+
</div>
+
`;
+
});
+
+
loadMoreBtn.remove();
+
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
+
+
if (moreData.cursor && moreData.records.length === 5) {
+
recordListDiv.insertAdjacentHTML('beforeend',
+
`<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>`
+
);
+
}
+
});
+
}
+
});
+
} else {
+
recordListDiv.innerHTML = '<div class="record">no records found</div>';
+
}
+
})
+
.catch(e => {
+
console.error('Error fetching records:', e);
+
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
+
});
+
});
+
});
+
});
+
+
field.appendChild(div);
+
});
+
+
// Close detail panel when clicking canvas
+
const canvas = document.querySelector('.canvas');
+
canvas.addEventListener('click', (e) => {
+
if (e.target === canvas) {
+
document.getElementById('detail').classList.remove('visible');
+
}
+
});
+
})
+
.catch(e => {
+
document.getElementById('field').innerHTML = 'error loading records';
+
console.error(e);
+
});
+157
static/login.js
···
+
// Check for saved session
+
const savedDid = localStorage.getItem('atme_did');
+
if (savedDid) {
+
document.getElementById('loginForm').classList.add('hidden');
+
document.getElementById('restoring').classList.remove('hidden');
+
+
fetch('/api/restore-session', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ did: savedDid })
+
}).then(r => {
+
if (r.ok) {
+
window.location.href = '/';
+
} else {
+
localStorage.removeItem('atme_did');
+
document.getElementById('loginForm').classList.remove('hidden');
+
document.getElementById('restoring').classList.add('hidden');
+
}
+
}).catch(() => {
+
localStorage.removeItem('atme_did');
+
document.getElementById('loginForm').classList.remove('hidden');
+
document.getElementById('restoring').classList.add('hidden');
+
});
+
}
+
+
// Fetch and cache atmosphere data
+
async function fetchAtmosphere() {
+
const CACHE_KEY = 'atme_atmosphere';
+
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
+
+
const cached = localStorage.getItem(CACHE_KEY);
+
if (cached) {
+
const { data, timestamp } = JSON.parse(cached);
+
if (Date.now() - timestamp < CACHE_DURATION) {
+
return data;
+
}
+
}
+
+
try {
+
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
+
const json = await response.json();
+
+
// Group by namespace (first two segments)
+
const namespaces = {};
+
json.collections.forEach(col => {
+
const parts = col.nsid.split('.');
+
if (parts.length >= 2) {
+
const ns = `${parts[0]}.${parts[1]}`;
+
if (!namespaces[ns]) {
+
namespaces[ns] = {
+
namespace: ns,
+
dids_total: 0,
+
records_total: 0,
+
collections: []
+
};
+
}
+
namespaces[ns].dids_total += col.dids_estimate;
+
namespaces[ns].records_total += col.creates;
+
namespaces[ns].collections.push(col.nsid);
+
}
+
});
+
+
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
+
+
localStorage.setItem(CACHE_KEY, JSON.stringify({
+
data,
+
timestamp: Date.now()
+
}));
+
+
return data;
+
} catch (e) {
+
console.error('Failed to fetch atmosphere data:', e);
+
return [];
+
}
+
}
+
+
// Try to fetch app avatar
+
async function fetchAppAvatar(namespace) {
+
const reversed = namespace.split('.').reverse().join('.');
+
const handles = [reversed, `${reversed}.bsky.social`];
+
+
for (const handle of handles) {
+
try {
+
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
+
if (!didRes.ok) continue;
+
+
const { did } = await didRes.json();
+
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
+
if (!profileRes.ok) continue;
+
+
const profile = await profileRes.json();
+
if (profile.avatar) return profile.avatar;
+
} catch (e) {
+
// Silently continue to next handle
+
continue;
+
}
+
}
+
return null;
+
}
+
+
// Render atmosphere
+
async function renderAtmosphere() {
+
const data = await fetchAtmosphere();
+
if (!data.length) return;
+
+
const atmosphere = document.getElementById('atmosphere');
+
const maxSize = Math.max(...data.map(d => d.dids_total));
+
+
data.forEach((app, i) => {
+
const orb = document.createElement('div');
+
orb.className = 'app-orb';
+
+
// Size based on user count (20-80px)
+
const size = 20 + (app.dids_total / maxSize) * 60;
+
+
// Position in 3D space
+
const angle = (i / data.length) * Math.PI * 2;
+
const radius = 250 + (i % 3) * 100;
+
const y = (i % 5) * 80 - 160;
+
const x = Math.cos(angle) * radius;
+
const z = Math.sin(angle) * radius;
+
+
orb.style.width = `${size}px`;
+
orb.style.height = `${size}px`;
+
orb.style.left = `calc(50% + ${x}px)`;
+
orb.style.top = `calc(50% + ${y}px)`;
+
orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`;
+
orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`;
+
orb.style.border = '1px solid rgba(255,255,255,0.1)';
+
orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)';
+
+
// Fallback letter
+
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
+
orb.innerHTML = `<div class="fallback">${letter}</div>`;
+
+
// Tooltip
+
const tooltip = document.createElement('div');
+
tooltip.className = 'app-tooltip';
+
const users = app.dids_total >= 1000000
+
? `${(app.dids_total / 1000000).toFixed(1)}M users`
+
: `${(app.dids_total / 1000).toFixed(0)}K users`;
+
tooltip.textContent = `${app.namespace} • ${users}`;
+
orb.appendChild(tooltip);
+
+
atmosphere.appendChild(orb);
+
+
// Fetch and apply avatar
+
fetchAppAvatar(app.namespace).then(avatarUrl => {
+
if (avatarUrl) {
+
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
+
orb.appendChild(tooltip);
+
}
+
});
+
});
+
}
+
+
renderAtmosphere();
+191
static/onboarding.js
···
+
// Onboarding overlay for first-time users
+
const ONBOARDING_KEY = 'atme_onboarding_seen';
+
+
const steps = [
+
{
+
target: '.identity',
+
title: 'this is you',
+
description: 'your global identity and handle. your data is hosted at your personal data server (pds).',
+
position: 'bottom'
+
},
+
{
+
target: '.canvas',
+
title: 'third-party applications',
+
description: 'these apps use your global identity to write public records to your pds. they can also read records you\'ve created.',
+
position: 'center'
+
},
+
{
+
target: '.app-view',
+
title: 'explore your records',
+
description: 'click any app to see what records it has written to your pds.',
+
position: 'bottom'
+
}
+
];
+
+
let currentStep = 0;
+
+
function showOnboarding() {
+
const overlay = document.getElementById('onboardingOverlay');
+
if (!overlay) return;
+
+
overlay.style.display = 'block';
+
setTimeout(() => {
+
overlay.style.opacity = '1';
+
showStep(0);
+
}, 50);
+
}
+
+
function hideOnboarding() {
+
const overlay = document.getElementById('onboardingOverlay');
+
const spotlight = document.getElementById('onboardingSpotlight');
+
const content = document.getElementById('onboardingContent');
+
+
if (overlay) {
+
overlay.style.opacity = '0';
+
setTimeout(() => {
+
overlay.style.display = 'none';
+
}, 300);
+
}
+
+
if (spotlight) spotlight.classList.remove('active');
+
if (content) content.classList.remove('active');
+
+
localStorage.setItem(ONBOARDING_KEY, 'true');
+
}
+
+
function showStep(stepIndex) {
+
if (stepIndex >= steps.length) {
+
hideOnboarding();
+
return;
+
}
+
+
currentStep = stepIndex;
+
const step = steps[stepIndex];
+
const target = document.querySelector(step.target);
+
+
if (!target) {
+
console.warn('Onboarding target not found:', step.target);
+
showStep(stepIndex + 1);
+
return;
+
}
+
+
const spotlight = document.getElementById('onboardingSpotlight');
+
const content = document.getElementById('onboardingContent');
+
+
// Position spotlight on target
+
const rect = target.getBoundingClientRect();
+
const padding = step.target === '.canvas' ? 100 : 20;
+
+
spotlight.style.left = `${rect.left - padding}px`;
+
spotlight.style.top = `${rect.top - padding}px`;
+
spotlight.style.width = `${rect.width + padding * 2}px`;
+
spotlight.style.height = `${rect.height + padding * 2}px`;
+
spotlight.classList.add('active');
+
+
// Position content
+
content.innerHTML = `
+
<h3>${step.title}</h3>
+
<p>${step.description}</p>
+
<div class="onboarding-actions">
+
<button id="skipOnboarding" class="onboarding-skip">skip</button>
+
<button id="nextOnboarding" class="onboarding-next">
+
${stepIndex === steps.length - 1 ? 'got it' : 'next'}
+
</button>
+
</div>
+
<div class="onboarding-progress">
+
${steps.map((_, i) => `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>`).join('')}
+
</div>
+
`;
+
+
// Position content relative to spotlight
+
let contentTop, contentLeft;
+
const contentMaxWidth = Math.min(400, window.innerWidth * 0.9); // responsive max-width
+
const contentHeight = 250; // approximate height
+
const margin = Math.max(20, window.innerWidth * 0.05); // responsive margin
+
+
if (step.position === 'bottom') {
+
contentTop = rect.bottom + padding + margin;
+
contentLeft = rect.left + rect.width / 2;
+
+
// Check if it would go off bottom
+
if (contentTop + contentHeight > window.innerHeight) {
+
contentTop = rect.top - padding - contentHeight - margin;
+
}
+
} else if (step.position === 'center') {
+
contentTop = window.innerHeight / 2 - contentHeight / 2;
+
contentLeft = window.innerWidth / 2;
+
} else {
+
contentTop = rect.top - padding - contentHeight - margin;
+
contentLeft = rect.left + rect.width / 2;
+
+
// Check if it would go off top
+
if (contentTop < margin) {
+
contentTop = rect.bottom + padding + margin;
+
}
+
}
+
+
// Ensure content stays on screen horizontally
+
const halfWidth = contentMaxWidth / 2;
+
if (contentLeft - halfWidth < margin) {
+
contentLeft = halfWidth + margin;
+
} else if (contentLeft + halfWidth > window.innerWidth - margin) {
+
contentLeft = window.innerWidth - halfWidth - margin;
+
}
+
+
// Ensure content stays on screen vertically
+
if (contentTop < margin) {
+
contentTop = margin;
+
} else if (contentTop + contentHeight > window.innerHeight - margin) {
+
contentTop = window.innerHeight - contentHeight - margin;
+
}
+
+
content.style.top = `${contentTop}px`;
+
content.style.left = `${contentLeft}px`;
+
content.style.transform = 'translate(-50%, 0)';
+
content.classList.add('active');
+
+
// Add event listeners
+
document.getElementById('skipOnboarding').addEventListener('click', hideOnboarding);
+
document.getElementById('nextOnboarding').addEventListener('click', () => {
+
showStep(stepIndex + 1);
+
});
+
}
+
+
// Initialize onboarding
+
function initOnboarding() {
+
const seen = localStorage.getItem(ONBOARDING_KEY);
+
+
if (!seen) {
+
// Wait for app circles to render
+
setTimeout(() => {
+
showOnboarding();
+
}, 1000);
+
}
+
}
+
+
// ESC key handler
+
document.addEventListener('keydown', (e) => {
+
if (e.key === 'Escape') {
+
const overlay = document.getElementById('onboardingOverlay');
+
if (overlay && overlay.style.display === 'block') {
+
hideOnboarding();
+
}
+
}
+
});
+
+
// Help button handler to restart onboarding
+
window.restartOnboarding = function() {
+
localStorage.removeItem(ONBOARDING_KEY);
+
document.getElementById('infoModal').classList.remove('visible');
+
document.getElementById('overlay').classList.remove('visible');
+
setTimeout(() => {
+
showOnboarding();
+
}, 300);
+
};
+
+
// Start onboarding after page loads
+
if (document.readyState === 'loading') {
+
document.addEventListener('DOMContentLoaded', initOnboarding);
+
} else {
+
initOnboarding();
+
}