feat: add demo mode and educational content to login page #12

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

🤖 Generated with Claude Code

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

Changed files
+284 -5
src
static
+2
src/main.rs
···
.app_data(web::Data::new(firehose_manager.clone()))
.service(routes::index)
.service(routes::login)
+
.service(routes::demo)
+
.service(routes::demo_exit)
.service(routes::callback)
.service(routes::client_metadata)
.service(routes::logout)
+47 -3
src/routes.rs
···
let did: Option<String> = session.get("did").unwrap_or(None);
match did {
-
Some(did) => HttpResponse::Ok()
-
.content_type("text/html")
-
.body(templates::app_page(&did)),
+
Some(did) => {
+
let demo_mode: bool = session.get("demo_mode").unwrap_or(Some(false)).unwrap_or(false);
+
let demo_handle: Option<String> = session.get("demo_handle").unwrap_or(None);
+
+
HttpResponse::Ok()
+
.content_type("text/html")
+
.body(templates::app_page(&did, demo_mode, demo_handle.as_deref()))
+
},
None => HttpResponse::Ok()
.content_type("text/html")
.body(templates::login_page()),
···
}
}
+
#[get("/demo")]
+
pub async fn demo(session: Session) -> HttpResponse {
+
let demo_handle = "pfrazee.com";
+
+
// Resolve handle to DID
+
let resolve_url = format!(
+
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}",
+
demo_handle
+
);
+
+
let did = match reqwest::get(&resolve_url).await {
+
Ok(response) => match response.json::<serde_json::Value>().await {
+
Ok(data) => match data["did"].as_str() {
+
Some(did) => did.to_string(),
+
None => return HttpResponse::InternalServerError().body("failed to resolve demo handle"),
+
},
+
Err(_) => return HttpResponse::InternalServerError().body("failed to parse response"),
+
},
+
Err(_) => return HttpResponse::InternalServerError().body("failed to resolve demo handle"),
+
};
+
+
// Store in session with demo flag
+
session.insert("did", &did).unwrap();
+
session.insert("demo_mode", true).unwrap();
+
session.insert("demo_handle", demo_handle).unwrap();
+
+
HttpResponse::SeeOther()
+
.append_header(("Location", "/"))
+
.finish()
+
}
+
+
#[get("/demo/exit")]
+
pub async fn demo_exit(session: Session) -> HttpResponse {
+
session.purge();
+
HttpResponse::SeeOther()
+
.append_header(("Location", "/?clear_demo=true"))
+
.finish()
+
}
+
#[get("/oauth/callback")]
pub async fn callback(
params: web::Query<OAuthParams>,
+203 -2
src/templates.rs
···
.hidden { display: none; }
.loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; }
+
.demo-btn {
+
font-family: inherit;
+
font-size: 0.9rem;
+
padding: 0.75rem 2rem;
+
cursor: pointer;
+
background: transparent;
+
border: 1px solid rgba(255, 255, 255, 0.15);
+
border-radius: 4px;
+
color: rgba(255, 255, 255, 0.6);
+
transition: all 0.2s;
+
width: 100%;
+
margin-top: 0.75rem;
+
}
+
+
.demo-btn:hover {
+
background: rgba(10, 10, 15, 0.5);
+
border-color: rgba(255, 255, 255, 0.3);
+
color: rgba(255, 255, 255, 0.8);
+
}
+
+
.divider {
+
display: flex;
+
align-items: center;
+
gap: 1rem;
+
margin: 1.5rem 0 1rem;
+
color: rgba(255, 255, 255, 0.3);
+
font-size: 0.7rem;
+
}
+
+
.divider::before,
+
.divider::after {
+
content: '';
+
flex: 1;
+
height: 1px;
+
background: rgba(255, 255, 255, 0.1);
+
}
+
+
.info-toggle {
+
margin-top: 1.5rem;
+
color: rgba(255, 255, 255, 0.5);
+
font-size: 0.75rem;
+
cursor: pointer;
+
border: none;
+
background: none;
+
padding: 0.5rem;
+
transition: color 0.2s;
+
text-decoration: underline;
+
text-underline-offset: 2px;
+
}
+
+
.info-toggle:hover {
+
color: rgba(255, 255, 255, 0.8);
+
}
+
+
.info-content {
+
max-height: 0;
+
overflow: hidden;
+
transition: max-height 0.3s ease;
+
margin-top: 1rem;
+
}
+
+
.info-content.expanded {
+
max-height: 500px;
+
}
+
+
.info-section {
+
background: rgba(10, 10, 15, 0.6);
+
border: 1px solid rgba(255, 255, 255, 0.1);
+
border-radius: 4px;
+
padding: 1.5rem;
+
text-align: left;
+
}
+
+
.info-section h3 {
+
font-size: 0.85rem;
+
font-weight: 500;
+
margin-bottom: 0.75rem;
+
color: rgba(255, 255, 255, 0.9);
+
}
+
+
.info-section p {
+
font-size: 0.7rem;
+
line-height: 1.6;
+
color: rgba(255, 255, 255, 0.6);
+
margin-bottom: 1rem;
+
}
+
+
.info-section p:last-child {
+
margin-bottom: 0;
+
}
+
+
.info-section strong {
+
color: rgba(255, 255, 255, 0.85);
+
}
+
+
.info-section a {
+
color: rgba(255, 255, 255, 0.8);
+
text-decoration: underline;
+
text-underline-offset: 2px;
+
}
+
+
.info-section a:hover {
+
color: rgba(255, 255, 255, 1);
+
}
+
.footer {
position: fixed;
bottom: 1rem;
···
<div class="subtitle">explore the atmosphere</div>
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
<button type="submit">enter</button>
+
+
<div class="divider">or</div>
+
<button type="button" class="demo-btn" id="demoBtn">explore demo</button>
+
+
<button type="button" class="info-toggle" id="infoToggle">what is this?</button>
+
+
<div class="info-content" id="infoContent">
+
<div class="info-section">
+
<h3>visualize your atproto identity</h3>
+
<p>see all the apps writing to your <strong>Personal Data Server</strong> and explore the records they've created. your content, your server, your control.</p>
+
+
<h3>the problem with silos</h3>
+
<p>traditional social platforms lock your content in. switching means starting over, losing your network and history. you build their platform, they control everything.</p>
+
+
<h3>the atproto solution</h3>
+
<p>on <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a>, you own your data. it lives on <strong>your</strong> server. apps like bluesky, whitewind, and frontpage just read and write to your space. switch apps anytime, take everything with you.</p>
+
+
<h3>see it yourself</h3>
+
<p>this isn't just theory. click "explore demo" to see a real atproto account, or log in to visualize your own identity.</p>
+
</div>
+
</div>
</form>
</div>
</div>
···
"#
}
-
pub fn app_page(did: &str) -> String {
+
pub fn app_page(did: &str, demo_mode: bool, demo_handle: Option<&str>) -> String {
+
let demo_banner = if demo_mode && demo_handle.is_some() {
+
format!(r#"
+
<div class="demo-banner" id="demoBanner">
+
<span>demo mode - viewing <strong>{}</strong></span>
+
<a href="/demo/exit" class="demo-exit">exit demo</a>
+
</div>"#, demo_handle.unwrap())
+
} else {
+
String::new()
+
};
+
format!(r#"
<!DOCTYPE html>
<html>
···
max-width: none;
}}
}}
+
+
.demo-banner {{
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
background: rgba(255, 165, 0, 0.15);
+
border-bottom: 1px solid rgba(255, 165, 0, 0.3);
+
padding: 0.5rem 1rem;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
gap: 1rem;
+
z-index: 200;
+
font-size: 0.7rem;
+
color: var(--text);
+
}}
+
+
.demo-banner strong {{
+
color: var(--text);
+
font-weight: 600;
+
}}
+
+
.demo-exit {{
+
color: var(--text-light);
+
text-decoration: none;
+
border: 1px solid var(--border);
+
padding: 0.25rem 0.75rem;
+
border-radius: 2px;
+
transition: all 0.2s ease;
+
font-size: 0.65rem;
+
}}
+
+
.demo-exit:hover {{
+
background: var(--surface);
+
border-color: var(--text-light);
+
color: var(--text);
+
}}
+
+
@media (prefers-color-scheme: dark) {{
+
.demo-banner {{
+
background: rgba(255, 165, 0, 0.1);
+
border-bottom-color: rgba(255, 165, 0, 0.25);
+
}}
+
}}
+
+
/* Adjust elements when demo banner is present */
+
.demo-banner ~ .info {{
+
top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem);
+
}}
+
+
.demo-banner ~ .watch-live-btn {{
+
top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem);
+
}}
+
+
.demo-banner ~ .logout {{
+
top: calc(clamp(1rem, 2vmin, 1.5rem) + 2.5rem);
+
}}
+
+
@media (max-width: 768px) {{
+
.demo-banner ~ .watch-live-btn {{
+
top: calc(clamp(4rem, 8vmin, 5rem) + 2.5rem);
+
}}
+
}}
</style>
</head>
<body>
+
{}
<div class="info" id="infoBtn">?</div>
<button class="watch-live-btn" id="watchLiveBtn">
<span class="watch-indicator"></span>
···
<script src="/static/onboarding.js"></script>
</body>
</html>
-
"#, did)
+
"#, demo_banner, did)
+32
static/login.js
···
+
// Check if we're exiting demo mode
+
const urlParams = new URLSearchParams(window.location.search);
+
if (urlParams.get('clear_demo') === 'true') {
+
localStorage.removeItem('atme_did');
+
// Clear the query param from the URL
+
window.history.replaceState({}, document.title, '/');
+
}
+
// Check for saved session
const savedDid = localStorage.getItem('atme_did');
if (savedDid) {
···
}
renderAtmosphere();
+
+
// Info toggle
+
document.getElementById('infoToggle').addEventListener('click', () => {
+
const content = document.getElementById('infoContent');
+
const toggle = document.getElementById('infoToggle');
+
+
if (content.classList.contains('expanded')) {
+
content.classList.remove('expanded');
+
toggle.textContent = 'what is this?';
+
} else {
+
content.classList.add('expanded');
+
toggle.textContent = 'close';
+
}
+
});
+
+
// Demo mode
+
document.getElementById('demoBtn').addEventListener('click', () => {
+
// Store demo flag and navigate
+
sessionStorage.setItem('atme_demo_mode', 'true');
+
sessionStorage.setItem('atme_demo_handle', 'pfrazee.com');
+
+
// Navigate to demo - this will trigger the login flow with the demo handle
+
window.location.href = '/demo';
+
});