Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(at): redesign PDS landing page with mission statement + All Media feed

- Mission statement explaining AC's self-hosted ATProto PDS
- Shows all 7 lexicon types as tags (painting, mood, kidlisp, piece, tape, news, paper)
- Two tabs: Top Users (existing leaderboard) and All Media (reverse-chrono feed)
- All Media tab fetches records from all collections across repos via XRPC
- Dark mode support, responsive layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+595
+595
at/index.html
··· 1 + <!-- 2 + ATProto PDS Landing Page for at.aesthetic.computer 3 + Mission statement + Top Users + All Media feed 4 + 2026.03.23 5 + --> 6 + <!DOCTYPE html> 7 + <html lang="en"> 8 + <head> 9 + <meta charset="UTF-8"> 10 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 11 + <title>at · Aesthetic Computer</title> 12 + <meta name="description" content="A self-hosted ATProto Personal Data Server for the Aesthetic Computer community — paintings, moods, code, tapes, papers, and more."> 13 + <link rel="icon" type="image/png" 14 + href="https://pals-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com/painting-2023.7.29.20.39.png"> 15 + 16 + <style> 17 + * { box-sizing: border-box; } 18 + ::-webkit-scrollbar { display: none; } 19 + 20 + body { 21 + margin: 0; 22 + font-size: 14px; 23 + font-family: monospace; 24 + -webkit-text-size-adjust: none; 25 + background: #f5f5f5; 26 + color: #000; 27 + line-height: 1.4; 28 + } 29 + 30 + .container { 31 + max-width: 900px; 32 + margin: 0 auto; 33 + padding: 1em 0.5em; 34 + } 35 + 36 + a { color: rgb(205, 92, 155); text-decoration: none; } 37 + a:hover { text-decoration: underline; } 38 + 39 + header { 40 + text-align: center; 41 + padding: 1em 0; 42 + border-bottom: 2px solid rgb(205, 92, 155); 43 + margin-bottom: 1em; 44 + } 45 + 46 + #pals-beacon { 47 + display: inline-flex; 48 + align-items: center; 49 + justify-content: center; 50 + margin-bottom: 0.2em; 51 + text-decoration: none; 52 + } 53 + 54 + #pals-beacon .pals-logo-container { 55 + position: relative; 56 + display: inline-block; 57 + } 58 + 59 + #pals-beacon .pals-logo, 60 + #pals-beacon .pals-logo-pink { 61 + width: 80px; 62 + height: auto; 63 + } 64 + 65 + #pals-beacon .pals-logo { 66 + filter: grayscale(100%) opacity(0.3); 67 + transition: filter 0.3s, opacity 0.3s; 68 + } 69 + 70 + #pals-beacon .pals-logo-pink { 71 + position: absolute; 72 + top: 0; 73 + left: 0; 74 + opacity: 0.7; 75 + filter: hue-rotate(-30deg) saturate(1.5) brightness(1.2) drop-shadow(0 0 8px rgba(255, 100, 200, 0.8)); 76 + animation: pals-idle 2s ease-in-out infinite; 77 + } 78 + 79 + @keyframes pals-idle { 80 + 0%, 100% { transform: scale(1); opacity: 0.7; } 81 + 50% { transform: scale(1.01); opacity: 1; } 82 + } 83 + 84 + h1 { 85 + font-size: 1.5em; 86 + font-weight: normal; 87 + margin: 0 0 0.3em 0; 88 + color: rgb(205, 92, 155); 89 + } 90 + 91 + .subtitle { 92 + font-size: 0.85em; 93 + opacity: 0.7; 94 + margin: 0.3em 0; 95 + } 96 + 97 + .mission { 98 + margin: 1em auto; 99 + max-width: 640px; 100 + padding: 1em; 101 + background: rgba(205, 92, 155, 0.06); 102 + border-radius: 6px; 103 + font-size: 0.85em; 104 + line-height: 1.6; 105 + text-align: left; 106 + } 107 + 108 + .mission p { margin: 0 0 0.6em 0; } 109 + .mission p:last-child { margin: 0; } 110 + 111 + .lexicons { 112 + display: flex; 113 + gap: 0.4em; 114 + justify-content: center; 115 + flex-wrap: wrap; 116 + margin: 0.8em 0; 117 + } 118 + 119 + .lex-tag { 120 + font-size: 0.7em; 121 + padding: 0.3em 0.6em; 122 + background: rgba(205, 92, 155, 0.1); 123 + border-radius: 3px; 124 + white-space: nowrap; 125 + } 126 + 127 + .stats { 128 + display: flex; 129 + gap: 1em; 130 + justify-content: center; 131 + flex-wrap: wrap; 132 + margin: 0.8em 0; 133 + font-size: 0.75em; 134 + } 135 + 136 + .stat { 137 + padding: 0.3em 0.6em; 138 + background: rgba(205, 92, 155, 0.1); 139 + border-radius: 3px; 140 + } 141 + 142 + .stat strong { color: rgb(205, 92, 155); } 143 + 144 + .tabs { 145 + display: flex; 146 + gap: 0; 147 + border-bottom: 1px solid rgba(205, 92, 155, 0.3); 148 + margin: 1em 0 0.5em 0; 149 + } 150 + 151 + .tab { 152 + padding: 0.6em 1.2em; 153 + background: none; 154 + border: none; 155 + font-family: monospace; 156 + font-size: 0.9em; 157 + cursor: pointer; 158 + border-bottom: 2px solid transparent; 159 + color: #666; 160 + transition: all 0.2s; 161 + } 162 + 163 + .tab:hover { color: rgb(205, 92, 155); } 164 + .tab.active { 165 + color: rgb(205, 92, 155); 166 + border-bottom-color: rgb(205, 92, 155); 167 + font-weight: bold; 168 + } 169 + 170 + .tab-panel { display: none; } 171 + .tab-panel.active { display: block; } 172 + 173 + .search-box { margin: 0.5em 0 1em 0; } 174 + 175 + .search-box input { 176 + font-family: monospace; 177 + font-size: 0.9em; 178 + padding: 0.6em 0.8em; 179 + width: 100%; 180 + border: 1px solid rgba(205, 92, 155, 0.3); 181 + border-radius: 3px; 182 + outline: none; 183 + background: white; 184 + } 185 + 186 + .search-box input:focus { border-color: rgb(205, 92, 155); } 187 + 188 + /* User list */ 189 + .user-list { display: flex; flex-direction: column; } 190 + 191 + .user-row { 192 + background: white; 193 + padding: 0.75em; 194 + border-bottom: 1px solid rgba(205, 92, 155, 0.1); 195 + text-decoration: none; 196 + color: inherit; 197 + transition: all 0.15s; 198 + display: flex; 199 + justify-content: space-between; 200 + align-items: center; 201 + gap: 1em; 202 + } 203 + 204 + .user-row:first-child { border-radius: 4px 4px 0 0; } 205 + .user-row:last-child { border-radius: 0 0 4px 4px; border-bottom: none; } 206 + .user-row:hover { background: rgba(205, 92, 155, 0.05); padding-left: 1em; } 207 + 208 + .user-left { display: flex; align-items: center; gap: 0.75em; flex: 1; min-width: 0; } 209 + .user-rank { font-size: 0.75em; color: rgba(205, 92, 155, 0.5); font-weight: bold; min-width: 2.5em; text-align: right; } 210 + .user-handle { font-weight: bold; color: rgb(205, 92, 155); word-break: break-all; } 211 + .user-mood { font-size: 0.75em; color: rgba(0,0,0,0.6); margin-left: 0.5em; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 212 + .user-right { display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; justify-content: flex-end; } 213 + .user-badge { font-size: 0.7em; padding: 0.3em 0.5em; background: rgba(205, 92, 155, 0.1); border-radius: 3px; white-space: nowrap; } 214 + .user-total { font-size: 0.75em; color: rgba(0,0,0,0.5); font-weight: bold; min-width: 3em; text-align: right; } 215 + 216 + .user-kidlisp-preview { 217 + width: 36px; height: 36px; border-radius: 6px; overflow: hidden; flex-shrink: 0; 218 + border: 1px solid rgba(205, 92, 155, 0.2); 219 + } 220 + .user-kidlisp-preview img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 221 + 222 + /* Media feed */ 223 + .feed { display: flex; flex-direction: column; gap: 0.4em; } 224 + 225 + .feed-item { 226 + background: white; 227 + padding: 0.75em; 228 + border-radius: 4px; 229 + display: flex; 230 + align-items: center; 231 + gap: 0.75em; 232 + } 233 + 234 + .feed-type { 235 + font-size: 1.2em; 236 + width: 2em; 237 + text-align: center; 238 + flex-shrink: 0; 239 + } 240 + 241 + .feed-body { flex: 1; min-width: 0; } 242 + 243 + .feed-title { 244 + font-weight: bold; 245 + overflow: hidden; 246 + text-overflow: ellipsis; 247 + white-space: nowrap; 248 + } 249 + 250 + .feed-meta { 251 + font-size: 0.75em; 252 + color: rgba(0,0,0,0.5); 253 + margin-top: 0.2em; 254 + } 255 + 256 + .feed-meta a { color: rgb(205, 92, 155); } 257 + 258 + .feed-thumb { 259 + width: 48px; height: 48px; border-radius: 4px; overflow: hidden; flex-shrink: 0; 260 + border: 1px solid rgba(205, 92, 155, 0.15); 261 + } 262 + .feed-thumb img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 263 + 264 + .loading { text-align: center; padding: 3em 2em; opacity: 0.6; } 265 + .error { text-align: center; padding: 3em 2em; color: #d32f2f; } 266 + .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(205, 92, 155, 0.3); border-radius: 50%; border-top-color: rgb(205, 92, 155); animation: spin 1s ease-in-out infinite; } 267 + @keyframes spin { to { transform: rotate(360deg); } } 268 + 269 + .load-more { 270 + display: block; 271 + margin: 1em auto; 272 + padding: 0.6em 1.5em; 273 + font-family: monospace; 274 + font-size: 0.85em; 275 + background: rgba(205, 92, 155, 0.1); 276 + border: 1px solid rgba(205, 92, 155, 0.3); 277 + border-radius: 4px; 278 + cursor: pointer; 279 + color: rgb(205, 92, 155); 280 + } 281 + .load-more:hover { background: rgba(205, 92, 155, 0.2); } 282 + 283 + footer { text-align: center; padding: 2em 1em 1em; opacity: 0.5; font-size: 0.75em; } 284 + 285 + @media (max-width: 560px) { 286 + .user-mood { display: none; } 287 + .user-kidlisp-preview { width: 28px; height: 28px; } 288 + .user-badge { font-size: 0.6em; } 289 + .mission { font-size: 0.8em; padding: 0.8em; } 290 + } 291 + 292 + @media (max-width: 400px) { 293 + .user-right { display: none; } 294 + } 295 + 296 + @media (prefers-color-scheme: dark) { 297 + body { background: rgb(64, 56, 74); color: rgba(255, 255, 255, 0.85); } 298 + .user-row, .feed-item { background: rgba(255, 255, 255, 0.05); } 299 + .user-row:hover, .feed-item:hover { background: rgba(205, 92, 155, 0.1); } 300 + .search-box input { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.85); } 301 + .user-total, .feed-meta { color: rgba(255, 255, 255, 0.5); } 302 + .user-mood { color: rgba(255, 255, 255, 0.6); } 303 + .mission { background: rgba(205, 92, 155, 0.1); } 304 + } 305 + </style> 306 + </head> 307 + 308 + <body> 309 + <div class="container"> 310 + <header> 311 + <a id="pals-beacon" href="https://aesthetic.computer" target="_blank" aria-label="Aesthetic Computer"> 312 + <div class="pals-logo-container"> 313 + <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo"> 314 + <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo-pink"> 315 + </div> 316 + </a> 317 + <h1>at.aesthetic.computer</h1> 318 + <div class="subtitle">Self-Hosted ATProto Personal Data Server</div> 319 + 320 + <div class="mission"> 321 + <p>This is the federated data layer for <a href="https://aesthetic.computer">Aesthetic Computer</a> &mdash; a mobile-first runtime and social network for creative computing.</p> 322 + <p>Every painting, mood, tape, piece of code, and paper created on AC is stored here as an open ATProto record, addressable by anyone on the network. Your data lives on infrastructure we operate, not on a platform you don't control.</p> 323 + </div> 324 + 325 + <div class="lexicons"> 326 + <span class="lex-tag">painting</span> 327 + <span class="lex-tag">mood</span> 328 + <span class="lex-tag">kidlisp</span> 329 + <span class="lex-tag">piece</span> 330 + <span class="lex-tag">tape</span> 331 + <span class="lex-tag">news</span> 332 + <span class="lex-tag">paper</span> 333 + </div> 334 + 335 + <div class="stats"> 336 + <div class="stat"><strong id="total-users">...</strong> users</div> 337 + <div class="stat"><strong id="total-records">...</strong> records</div> 338 + <div class="stat"><strong id="active-users">...</strong> active</div> 339 + </div> 340 + </header> 341 + 342 + <div class="tabs"> 343 + <button class="tab active" data-tab="users">Top Users</button> 344 + <button class="tab" data-tab="feed">All Media</button> 345 + </div> 346 + 347 + <!-- Top Users --> 348 + <div class="tab-panel active" id="panel-users"> 349 + <div class="search-box"> 350 + <input type="text" id="search" placeholder="Search by handle..." autocomplete="off"> 351 + </div> 352 + <div id="users-container"> 353 + <div class="loading"><div class="spinner"></div><p>Loading users...</p></div> 354 + </div> 355 + </div> 356 + 357 + <!-- All Media Feed --> 358 + <div class="tab-panel" id="panel-feed"> 359 + <div id="feed-container"> 360 + <div class="loading"><div class="spinner"></div><p>Loading media...</p></div> 361 + </div> 362 + </div> 363 + 364 + <footer> 365 + <p> 366 + Powered by <a href="https://atproto.com" target="_blank">AT Protocol</a> · 367 + <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a> · 368 + <a href="https://art.at.aesthetic.computer" target="_blank">Guest Art</a> · 369 + <a href="https://papers.aesthetic.computer" target="_blank">Papers</a> 370 + </p> 371 + <p>mail@aesthetic.computer</p> 372 + </footer> 373 + </div> 374 + 375 + <script> 376 + const API_URL = 'https://aesthetic.computer'; 377 + const PDS_URL = 'https://at.aesthetic.computer'; 378 + const COLLECTIONS = [ 379 + { id: 'computer.aesthetic.painting', icon: '🎨', label: 'painting' }, 380 + { id: 'computer.aesthetic.mood', icon: '💬', label: 'mood' }, 381 + { id: 'computer.aesthetic.kidlisp', icon: '📝', label: 'kidlisp' }, 382 + { id: 'computer.aesthetic.piece', icon: '🧩', label: 'piece' }, 383 + { id: 'computer.aesthetic.tape', icon: '📼', label: 'tape' }, 384 + { id: 'computer.aesthetic.news', icon: '📰', label: 'news' }, 385 + { id: 'computer.aesthetic.paper', icon: '📄', label: 'paper' }, 386 + ]; 387 + 388 + let allUsers = []; 389 + let feedLoaded = false; 390 + 391 + // --- Tabs --- 392 + document.querySelectorAll('.tab').forEach(tab => { 393 + tab.addEventListener('click', () => { 394 + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 395 + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); 396 + tab.classList.add('active'); 397 + document.getElementById('panel-' + tab.dataset.tab).classList.add('active'); 398 + if (tab.dataset.tab === 'feed' && !feedLoaded) loadFeed(); 399 + }); 400 + }); 401 + 402 + // --- Users Tab --- 403 + async function fetchUsers() { 404 + try { 405 + const response = await fetch(`${API_URL}/.netlify/functions/atproto-user-stats?limit=100`); 406 + const data = await response.json(); 407 + if (!data.users) throw new Error('Invalid response'); 408 + allUsers = data.users; 409 + 410 + if (data.stats) { 411 + document.getElementById('total-users').textContent = data.stats.totalUsers.toLocaleString(); 412 + document.getElementById('total-records').textContent = data.stats.totalRecords.toLocaleString(); 413 + document.getElementById('active-users').textContent = data.stats.activeUsers.toLocaleString(); 414 + } 415 + renderUsers(allUsers); 416 + } catch (error) { 417 + document.getElementById('users-container').innerHTML = '<div class="error">Failed to load users.</div>'; 418 + } 419 + } 420 + 421 + function renderUsers(users) { 422 + const container = document.getElementById('users-container'); 423 + if (!users.length) { container.innerHTML = '<div class="loading">No users found</div>'; return; } 424 + 425 + const list = document.createElement('div'); 426 + list.className = 'user-list'; 427 + 428 + users.forEach((user, index) => { 429 + const row = document.createElement('a'); 430 + row.className = 'user-row'; 431 + const identifier = user.handle || user.code; 432 + const shortHandle = identifier.replace('.at.aesthetic.computer', '').replace('@', ''); 433 + row.href = `https://${shortHandle}.at.aesthetic.computer`; 434 + row.target = '_blank'; 435 + 436 + const badges = []; 437 + const badgeMap = { 438 + 'computer.aesthetic.painting': '🎨', 439 + 'computer.aesthetic.mood': '💬', 440 + 'computer.aesthetic.piece': '🧩', 441 + 'computer.aesthetic.kidlisp': '📝', 442 + 'computer.aesthetic.tape': '📼', 443 + }; 444 + for (const [col, emoji] of Object.entries(badgeMap)) { 445 + if (user.collections.includes(col)) { 446 + badges.push(`<span class="user-badge">${emoji} ${user.recordCounts[col] || 0}</span>`); 447 + } 448 + } 449 + 450 + const displayHandle = user.isUserCode ? shortHandle : `@${shortHandle}`; 451 + const mood = user.latestMood ? `"${user.latestMood.replace(/\s+/g, ' ').trim()}"` : ''; 452 + const klCode = user.latestKidlispCode || ''; 453 + const klPreview = klCode 454 + ? `<div class="user-kidlisp-preview"><img src="https://oven.aesthetic.computer/grab/webp/100/100/$${klCode}?duration=2000&fps=8&quality=70&density=1&nowait=true" alt="" loading="lazy"></div>` 455 + : ''; 456 + 457 + row.innerHTML = ` 458 + <div class="user-left"> 459 + <div class="user-rank">#${index + 1}</div> 460 + ${klPreview} 461 + <div class="user-handle">${displayHandle}</div> 462 + ${mood ? `<div class="user-mood">${mood}</div>` : ''} 463 + </div> 464 + <div class="user-right"> 465 + ${badges.join('')} 466 + <div class="user-total">${user.totalRecords}</div> 467 + </div>`; 468 + list.appendChild(row); 469 + }); 470 + 471 + container.innerHTML = ''; 472 + container.appendChild(list); 473 + } 474 + 475 + document.getElementById('search').addEventListener('input', (e) => { 476 + const q = e.target.value.toLowerCase(); 477 + renderUsers(q ? allUsers.filter(u => (u.handle || u.code).toLowerCase().includes(q)) : allUsers); 478 + }); 479 + 480 + // --- All Media Feed --- 481 + async function loadFeed() { 482 + feedLoaded = true; 483 + const container = document.getElementById('feed-container'); 484 + 485 + try { 486 + // Fetch repos, then latest records from each collection across top repos 487 + const reposRes = await fetch(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=50`); 488 + const reposData = await reposRes.json(); 489 + const repos = (reposData.repos || []).map(r => r.did); 490 + 491 + const allItems = []; 492 + 493 + // For each collection, fetch recent records from all repos (limit per repo for speed) 494 + for (const col of COLLECTIONS) { 495 + for (const did of repos.slice(0, 20)) { 496 + try { 497 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col.id)}&limit=5`); 498 + if (!res.ok) continue; 499 + const data = await res.json(); 500 + for (const rec of (data.records || [])) { 501 + allItems.push({ ...rec.value, uri: rec.uri, _col: col, _did: did }); 502 + } 503 + } catch { /* skip */ } 504 + } 505 + } 506 + 507 + // Sort by when (reverse chrono) 508 + allItems.sort((a, b) => { 509 + const da = new Date(a.when || a.createdAt || 0); 510 + const db = new Date(b.when || b.createdAt || 0); 511 + return db - da; 512 + }); 513 + 514 + renderFeed(allItems.slice(0, 100), container); 515 + } catch (error) { 516 + container.innerHTML = '<div class="error">Failed to load media feed.</div>'; 517 + } 518 + } 519 + 520 + function renderFeed(items, container) { 521 + if (!items.length) { container.innerHTML = '<div class="loading">No media found</div>'; return; } 522 + 523 + const feed = document.createElement('div'); 524 + feed.className = 'feed'; 525 + 526 + for (const item of items) { 527 + const el = document.createElement('div'); 528 + el.className = 'feed-item'; 529 + 530 + const col = item._col; 531 + const when = item.when || item.createdAt; 532 + const dateStr = when ? new Date(when).toLocaleDateString() : ''; 533 + const handle = item.uri ? item.uri.split('/')[2] : item._did; 534 + const shortDid = handle.length > 20 ? handle.slice(0, 16) + '...' : handle; 535 + 536 + let title = ''; 537 + let thumb = ''; 538 + let link = ''; 539 + 540 + switch (col.label) { 541 + case 'painting': 542 + title = item.slug || item.code || 'Untitled'; 543 + link = item.imageUrl || `https://aesthetic.computer/#${item.code}`; 544 + if (item.code) thumb = `<div class="feed-thumb"><img src="https://aesthetic.computer/media/paintings/${item.code}" loading="lazy" alt=""></div>`; 545 + break; 546 + case 'mood': 547 + title = item.mood || ''; 548 + break; 549 + case 'kidlisp': 550 + title = item.source ? item.source.slice(0, 80) : (item.code || ''); 551 + if (item.code) { 552 + link = item.acUrl || `https://aesthetic.computer/$${item.code}`; 553 + thumb = `<div class="feed-thumb"><img src="https://oven.aesthetic.computer/grab/webp/100/100/$${item.code}?duration=1000&fps=4&quality=60&density=1&nowait=true" loading="lazy" alt=""></div>`; 554 + } 555 + break; 556 + case 'piece': 557 + title = item.slug || 'Untitled piece'; 558 + break; 559 + case 'tape': 560 + title = item.code || item.slug || 'Tape'; 561 + link = item.acUrl || `https://aesthetic.computer/!${item.code}`; 562 + break; 563 + case 'news': 564 + title = item.headline || ''; 565 + link = item.link || ''; 566 + break; 567 + case 'paper': 568 + title = item.title || ''; 569 + link = item.pdfUrl || ''; 570 + break; 571 + } 572 + 573 + const titleHtml = link 574 + ? `<a href="${link}" target="_blank">${title}</a>` 575 + : title; 576 + 577 + el.innerHTML = ` 578 + <div class="feed-type">${col.icon}</div> 579 + ${thumb} 580 + <div class="feed-body"> 581 + <div class="feed-title">${titleHtml}</div> 582 + <div class="feed-meta">${col.label} · ${dateStr} · ${shortDid}</div> 583 + </div>`; 584 + feed.appendChild(el); 585 + } 586 + 587 + container.innerHTML = ''; 588 + container.appendChild(feed); 589 + } 590 + 591 + // Init 592 + fetchUsers(); 593 + </script> 594 + </body> 595 + </html>