fun login page #5

merged
opened by zzstoatzz.io targeting main from login-atmosphere-viz

Add some floating logos on the login page.

Changed files
+313 -13
src
+313 -13
src/templates.rs
···
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
-
body { font-family: 'Monaco', 'Courier New', monospace; display: flex; align-items: center; justify-content: center; height: 100vh; background: #000; color: #0f0; }
-
.container { text-align: center; }
-
h1 { font-size: 2rem; margin-bottom: 2rem; }
-
input { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem; margin: 0.5rem; background: #000; border: 1px solid #0f0; color: #0f0; }
-
button { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem 1rem; cursor: pointer; background: #000; border: 1px solid #0f0; color: #0f0; }
-
button:hover { background: #0f0; color: #000; }
+
+
body {
+
font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace;
+
height: 100vh;
+
background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%);
+
color: #e5e5e5;
+
overflow: hidden;
+
perspective: 1000px;
+
}
+
+
.atmosphere {
+
position: fixed;
+
inset: 0;
+
transform-style: preserve-3d;
+
animation: rotate 120s infinite linear;
+
}
+
+
@keyframes rotate {
+
from { transform: rotateY(0deg); }
+
to { transform: rotateY(360deg); }
+
}
+
+
.app-orb {
+
position: absolute;
+
border-radius: 50%;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
transition: all 0.3s ease;
+
cursor: pointer;
+
backdrop-filter: blur(4px);
+
}
+
+
.app-orb:hover {
+
transform: scale(1.2) !important;
+
z-index: 100;
+
}
+
+
.app-orb img {
+
width: 100%;
+
height: 100%;
+
border-radius: 50%;
+
object-fit: cover;
+
}
+
+
.app-orb .fallback {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: rgba(255, 255, 255, 0.9);
+
}
+
+
.app-tooltip {
+
position: absolute;
+
background: rgba(10, 10, 15, 0.95);
+
border: 1px solid rgba(255, 255, 255, 0.1);
+
padding: 0.5rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.7rem;
+
white-space: nowrap;
+
pointer-events: none;
+
opacity: 0;
+
transition: opacity 0.2s;
+
z-index: 1000;
+
}
+
+
.app-orb:hover .app-tooltip {
+
opacity: 1;
+
}
+
+
.container {
+
position: fixed;
+
inset: 0;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
z-index: 10;
+
}
+
+
.login-card {
+
background: rgba(10, 10, 15, 0.8);
+
border: 1px solid rgba(255, 255, 255, 0.1);
+
padding: 2.5rem 3rem;
+
border-radius: 8px;
+
backdrop-filter: blur(10px);
+
text-align: center;
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+
}
+
+
h1 {
+
font-size: 2rem;
+
margin-bottom: 0.5rem;
+
font-weight: 300;
+
letter-spacing: 0.05em;
+
}
+
+
.subtitle {
+
font-size: 0.75rem;
+
color: rgba(255, 255, 255, 0.5);
+
margin-bottom: 2rem;
+
}
+
+
input {
+
font-family: inherit;
+
font-size: 0.9rem;
+
padding: 0.75rem 1rem;
+
margin-bottom: 1rem;
+
background: rgba(255, 255, 255, 0.05);
+
border: 1px solid rgba(255, 255, 255, 0.1);
+
border-radius: 4px;
+
color: #e5e5e5;
+
width: 100%;
+
min-width: 300px;
+
transition: all 0.2s;
+
}
+
+
input:focus {
+
outline: none;
+
border-color: rgba(255, 255, 255, 0.3);
+
background: rgba(255, 255, 255, 0.08);
+
}
+
+
input::placeholder {
+
color: rgba(255, 255, 255, 0.3);
+
}
+
+
button {
+
font-family: inherit;
+
font-size: 0.9rem;
+
padding: 0.75rem 2rem;
+
cursor: pointer;
+
background: rgba(255, 255, 255, 0.1);
+
border: 1px solid rgba(255, 255, 255, 0.2);
+
border-radius: 4px;
+
color: #e5e5e5;
+
transition: all 0.2s;
+
width: 100%;
+
}
+
+
button:hover {
+
background: rgba(255, 255, 255, 0.15);
+
border-color: rgba(255, 255, 255, 0.3);
+
}
+
.hidden { display: none; }
-
.loading { color: #0f0; opacity: 0.5; }
+
.loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; }
+
+
.footer {
+
position: fixed;
+
bottom: 1rem;
+
left: 50%;
+
transform: translateX(-50%);
+
font-size: 0.7rem;
+
color: rgba(255, 255, 255, 0.3);
+
z-index: 20;
+
}
+
+
.footer a {
+
color: rgba(255, 255, 255, 0.5);
+
text-decoration: none;
+
transition: color 0.2s;
+
}
+
+
.footer a:hover {
+
color: rgba(255, 255, 255, 0.8);
+
}
</style>
</head>
<body>
+
<div class="atmosphere" id="atmosphere"></div>
+
<div class="container">
-
<div id="restoring" class="loading hidden">restoring session...</div>
-
<form id="loginForm" method="post" action="/login">
-
<h1>@me</h1>
-
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
-
<button type="submit">login</button>
-
</form>
+
<div class="login-card">
+
<div id="restoring" class="loading hidden">restoring session...</div>
+
<form id="loginForm" method="post" action="/login">
+
<h1>@me</h1>
+
<div class="subtitle">explore the atmosphere</div>
+
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
+
<button type="submit">enter</button>
+
</form>
+
</div>
+
</div>
+
+
<div class="footer">
+
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.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>
</body>
</html>