From 5ab905605227def6871e0f59e35ef324c168dbc2 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 7 Oct 2025 11:05:29 -0500 Subject: [PATCH] feat: add onboarding overlay and refactor js to static files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 52 +++ Cargo.toml | 1 + src/main.rs | 2 + src/templates.rs | 773 +++++++++---------------------------------- static/app.js | 417 +++++++++++++++++++++++ static/login.js | 157 +++++++++ static/onboarding.js | 191 +++++++++++ 7 files changed, 976 insertions(+), 617 deletions(-) create mode 100644 static/app.js create mode 100644 static/login.js create mode 100644 static/onboarding.js diff --git a/Cargo.lock b/Cargo.lock index 1d3ad70..79c892c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,29 @@ dependencies = [ "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" @@ -386,6 +409,7 @@ dependencies = [ name = "at-me" version = "0.1.0" dependencies = [ + "actix-files", "actix-session", "actix-web", "atrium-api", @@ -1415,6 +1439,12 @@ dependencies = [ "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" @@ -1896,6 +1926,16 @@ version = "0.3.17" 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" @@ -2944,6 +2984,12 @@ version = "1.19.0" 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" @@ -3007,6 +3053,12 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index a7a2be7..0f5c20c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [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" diff --git a/src/main.rs b/src/main.rs index f2f86f0..2329e04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ 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; @@ -36,6 +37,7 @@ async fn main() -> std::io::Result<()> { .service(routes::logout) .service(routes::restore_session) .service(routes::favicon) + .service(Files::new("/static", "./static")) }) .bind(("0.0.0.0", 8080))? .run() diff --git a/src/templates.rs b/src/templates.rs index 705b6d6..16bc685 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -194,164 +194,7 @@ pub fn login_page() -> &'static str { by @zzstoatzz.io - + "# @@ -413,13 +256,13 @@ pub fn app_page(did: &str) -> String { .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; @@ -433,27 +276,18 @@ pub fn app_page(did: &str) -> String { 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; @@ -467,16 +301,6 @@ pub fn app_page(did: &str) -> String { 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%; @@ -570,13 +394,14 @@ pub fn app_page(did: &str) -> String { 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; @@ -589,52 +414,45 @@ pub fn app_page(did: &str) -> String { 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; @@ -650,13 +468,14 @@ pub fn app_page(did: &str) -> String { 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 {{ @@ -671,10 +490,10 @@ pub fn app_page(did: &str) -> String { }} .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 {{ @@ -902,30 +721,136 @@ pub fn app_page(did: &str) -> String { .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); + }} -
i
+
?
logout
@@ -935,6 +860,12 @@ pub fn app_page(did: &str) -> String {

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.

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.

+ + + +
+
+
@@ -951,402 +882,10 @@ pub fn app_page(did: &str) -> String { view source
+ + "#, did) diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..68745cf --- /dev/null +++ b/static/app.js @@ -0,0 +1,417 @@ +// 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 = ` + +

your identity

+
decentralized identifier & storage
+
+
+ did + ${did} +
+
+
+
+ handle + @${handle} +
+
+
+
+ personal data server + ${pds} +
+
+
+ your data lives at ${pdsHost}. apps like bluesky write to and read from this server. you control @${handle} and can move it to a different server anytime. +
+ `; + 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 = ` +
${firstLetter}
+
${namespace}
+ `; + + // Try to fetch and display avatar + fetchAppAvatar(namespace).then(avatarUrl => { + if (avatarUrl) { + const circle = div.querySelector('.app-circle'); + circle.innerHTML = ``; + } + }); + + div.addEventListener('click', () => { + const detail = document.getElementById('detail'); + const collections = apps[namespace]; + + let html = ` + +

${namespace}

+
records stored in your pds:
+ `; + + 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 += ` +
+
+ ${group} + loading... +
+
+ `; + } else { + // Group header + html += `
`; + html += `
${group}
`; + + // 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 += ` +
+
+ ${displayName} + loading... +
+
+ `; + }); + html += `
`; + } + }); + } else { + html += `
no collections found
`; + } + + 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 = '
loading records...
'; + 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 += ` +
+
+ record + +
+
+
${json}
+
+
+ `; + }); + + if (data.cursor && data.records.length === 5) { + recordsHtml += ``; + } + + 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 += ` +
+
+ record + +
+
+
${json}
+
+
+ `; + }); + + loadMoreBtn.remove(); + recordListDiv.insertAdjacentHTML('beforeend', moreHtml); + + if (moreData.cursor && moreData.records.length === 5) { + recordListDiv.insertAdjacentHTML('beforeend', + `` + ); + } + }); + } + }); + } else { + recordListDiv.innerHTML = '
no records found
'; + } + }) + .catch(e => { + console.error('Error fetching records:', e); + recordListDiv.innerHTML = '
error loading records
'; + }); + }); + }); + }); + + 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); + }); diff --git a/static/login.js b/static/login.js new file mode 100644 index 0000000..925d808 --- /dev/null +++ b/static/login.js @@ -0,0 +1,157 @@ +// 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 = `
${letter}
`; + + // 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 = `${app.namespace}`; + orb.appendChild(tooltip); + } + }); + }); +} + +renderAtmosphere(); diff --git a/static/onboarding.js b/static/onboarding.js new file mode 100644 index 0000000..072b691 --- /dev/null +++ b/static/onboarding.js @@ -0,0 +1,191 @@ +// 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 = ` +

${step.title}

+

${step.description}

+
+ + +
+
+ ${steps.map((_, i) => ``).join('')} +
+ `; + + // 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(); +} -- 2.43.0