Monorepo for Aesthetic.Computer aesthetic.computer
at main 836 lines 29 kB view raw
1<!-- 2ATProto PDS Landing Page for at.aesthetic.computer 3Mission statement + Top Users + All Media feed 42026.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 .support-links { 112 margin: 0.8em auto 0; 113 max-width: 640px; 114 padding: 0.8em 1em; 115 border: 1px solid rgba(205, 92, 155, 0.35); 116 background: rgba(205, 92, 155, 0.06); 117 border-radius: 6px; 118 font-size: 0.8em; 119 line-height: 1.6; 120 text-align: left; 121 } 122 123 .support-links p { margin: 0; } 124 125 .lexicons { 126 display: flex; 127 gap: 0.4em; 128 justify-content: center; 129 flex-wrap: wrap; 130 margin: 0.8em 0; 131 } 132 133 .lex-tag { 134 font-size: 0.7em; 135 padding: 0.3em 0.6em; 136 background: rgba(205, 92, 155, 0.1); 137 border-radius: 3px; 138 white-space: nowrap; 139 } 140 141 .stats { 142 display: flex; 143 gap: 1em; 144 justify-content: center; 145 flex-wrap: wrap; 146 margin: 0.8em 0; 147 font-size: 0.75em; 148 } 149 150 .stat { 151 padding: 0.3em 0.6em; 152 background: rgba(205, 92, 155, 0.1); 153 border-radius: 3px; 154 } 155 156 .stat strong { color: rgb(205, 92, 155); } 157 158 .tabs { 159 display: flex; 160 gap: 0; 161 border-bottom: 1px solid rgba(205, 92, 155, 0.3); 162 margin: 1em 0 0.5em 0; 163 } 164 165 .tab { 166 padding: 0.6em 1.2em; 167 background: none; 168 border: none; 169 font-family: monospace; 170 font-size: 0.9em; 171 cursor: pointer; 172 border-bottom: 2px solid transparent; 173 color: #666; 174 transition: all 0.2s; 175 } 176 177 .tab:hover { color: rgb(205, 92, 155); } 178 .tab.active { 179 color: rgb(205, 92, 155); 180 border-bottom-color: rgb(205, 92, 155); 181 font-weight: bold; 182 } 183 184 .tab-panel { display: none; } 185 .tab-panel.active { display: block; } 186 187 .search-box { margin: 0.5em 0 1em 0; } 188 189 .search-box input { 190 font-family: monospace; 191 font-size: 0.9em; 192 padding: 0.6em 0.8em; 193 width: 100%; 194 border: 1px solid rgba(205, 92, 155, 0.3); 195 border-radius: 3px; 196 outline: none; 197 background: white; 198 } 199 200 .search-box input:focus { border-color: rgb(205, 92, 155); } 201 202 .user-filters { 203 display: flex; 204 gap: 0.45em; 205 flex-wrap: wrap; 206 margin: 0.2em 0 1em 0; 207 } 208 209 .user-filter { 210 border: 1px solid rgba(205, 92, 155, 0.25); 211 background: rgba(205, 92, 155, 0.08); 212 color: rgba(0, 0, 0, 0.7); 213 font-family: monospace; 214 font-size: 0.75em; 215 padding: 0.35em 0.55em; 216 border-radius: 999px; 217 cursor: pointer; 218 transition: all 0.15s; 219 } 220 221 .user-filter.active { 222 background: rgba(205, 92, 155, 0.2); 223 border-color: rgba(205, 92, 155, 0.45); 224 color: rgb(205, 92, 155); 225 font-weight: bold; 226 } 227 228 .user-filter:hover { background: rgba(205, 92, 155, 0.14); } 229 230 /* User list */ 231 .user-list { display: flex; flex-direction: column; } 232 233 .user-row { 234 background: white; 235 padding: 0.75em; 236 border-bottom: 1px solid rgba(205, 92, 155, 0.1); 237 text-decoration: none; 238 color: inherit; 239 transition: all 0.15s; 240 display: flex; 241 justify-content: space-between; 242 align-items: center; 243 gap: 1em; 244 } 245 246 .user-row:first-child { border-radius: 4px 4px 0 0; } 247 .user-row:last-child { border-radius: 0 0 4px 4px; border-bottom: none; } 248 .user-row:hover { background: rgba(205, 92, 155, 0.05); padding-left: 1em; } 249 250 .user-left { display: flex; align-items: center; gap: 0.75em; flex: 1; min-width: 0; } 251 .user-rank { font-size: 0.75em; color: rgba(205, 92, 155, 0.5); font-weight: bold; min-width: 2.5em; text-align: right; } 252 .user-handle { font-weight: bold; color: rgb(205, 92, 155); word-break: break-all; } 253 .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; } 254 .user-right { display: flex; gap: 0.5em; align-items: center; flex-wrap: wrap; justify-content: flex-end; } 255 .user-badge { font-size: 0.7em; padding: 0.3em 0.5em; background: rgba(205, 92, 155, 0.1); border-radius: 3px; white-space: nowrap; } 256 .user-total { font-size: 0.75em; color: rgba(0,0,0,0.5); font-weight: bold; min-width: 3em; text-align: right; } 257 258 .user-kidlisp-preview { 259 width: 36px; height: 36px; border-radius: 6px; overflow: hidden; flex-shrink: 0; 260 border: 1px solid rgba(205, 92, 155, 0.2); 261 } 262 .user-kidlisp-preview img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 263 264 /* Media feed */ 265 .feed { display: flex; flex-direction: column; gap: 0.4em; } 266 267 .feed-item { 268 background: white; 269 padding: 0.75em; 270 border-radius: 4px; 271 display: flex; 272 align-items: center; 273 gap: 0.75em; 274 } 275 276 .feed-type { 277 font-size: 1.2em; 278 width: 2em; 279 text-align: center; 280 flex-shrink: 0; 281 } 282 283 .feed-body { flex: 1; min-width: 0; } 284 285 .feed-title { 286 font-weight: bold; 287 overflow: hidden; 288 text-overflow: ellipsis; 289 white-space: nowrap; 290 } 291 292 .feed-meta { 293 font-size: 0.75em; 294 color: rgba(0,0,0,0.5); 295 margin-top: 0.2em; 296 } 297 298 .feed-meta a { color: rgb(205, 92, 155); } 299 300 .feed-thumb { 301 width: 48px; height: 48px; border-radius: 4px; overflow: hidden; flex-shrink: 0; 302 border: 1px solid rgba(205, 92, 155, 0.15); 303 } 304 .feed-thumb img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 305 306 .loading { text-align: center; padding: 3em 2em; opacity: 0.6; } 307 .error { text-align: center; padding: 3em 2em; color: #d32f2f; } 308 .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; } 309 @keyframes spin { to { transform: rotate(360deg); } } 310 311 .feed-filters { 312 display: flex; 313 gap: 0.5em; 314 flex-wrap: wrap; 315 margin: 0.5em 0 1em 0; 316 } 317 318 .feed-filter { 319 display: flex; 320 align-items: center; 321 gap: 0.3em; 322 font-size: 0.8em; 323 cursor: pointer; 324 padding: 0.3em 0.6em; 325 background: rgba(205, 92, 155, 0.1); 326 border-radius: 3px; 327 user-select: none; 328 transition: opacity 0.15s; 329 } 330 331 .feed-filter.off { opacity: 0.35; } 332 333 .feed-filter input { display: none; } 334 335 .load-more { 336 display: block; 337 margin: 1em auto; 338 padding: 0.6em 1.5em; 339 font-family: monospace; 340 font-size: 0.85em; 341 background: rgba(205, 92, 155, 0.1); 342 border: 1px solid rgba(205, 92, 155, 0.3); 343 border-radius: 4px; 344 cursor: pointer; 345 color: rgb(205, 92, 155); 346 } 347 .load-more:hover { background: rgba(205, 92, 155, 0.2); } 348 349 footer { text-align: center; padding: 2em 1em 1em; opacity: 0.5; font-size: 0.75em; } 350 351 @media (max-width: 560px) { 352 .user-mood { display: none; } 353 .user-kidlisp-preview { width: 28px; height: 28px; } 354 .user-badge { font-size: 0.6em; } 355 .mission { font-size: 0.8em; padding: 0.8em; } 356 } 357 358 @media (max-width: 400px) { 359 .user-right { display: none; } 360 } 361 362 @media (prefers-color-scheme: dark) { 363 body { background: rgb(64, 56, 74); color: rgba(255, 255, 255, 0.85); } 364 .user-row, .feed-item { background: rgba(255, 255, 255, 0.05); } 365 .user-row:hover, .feed-item:hover { background: rgba(205, 92, 155, 0.1); } 366 .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); } 367 .user-total, .feed-meta { color: rgba(255, 255, 255, 0.5); } 368 .user-mood { color: rgba(255, 255, 255, 0.6); } 369 .mission { background: rgba(205, 92, 155, 0.1); } 370 } 371 </style> 372</head> 373 374<body> 375 <div class="container"> 376 <header> 377 <a id="pals-beacon" href="https://at.aesthetic.computer" aria-label="at.aesthetic.computer"> 378 <div class="pals-logo-container"> 379 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo"> 380 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo-pink"> 381 </div> 382 </a> 383 <h1>at.aesthetic.computer</h1> 384 <div class="subtitle">Self-Hosted ATProto Personal Data Server</div> 385 386 <div class="mission"> 387 <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> 388 <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> 389 </div> 390 391 <div class="support-links"> 392 <p>Learn about operating costs at <a href="https://bills.aesthetic.computer" target="_blank">bills.aesthetic.computer</a>. Help fund infrastructure at <a href="https://give.aesthetic.computer" target="_blank">give.aesthetic.computer</a> to keep the network free and open source.</p> 393 </div> 394 395 <div class="lexicons"> 396 <span class="lex-tag">painting</span> 397 <span class="lex-tag">mood</span> 398 <span class="lex-tag">kidlisp</span> 399 <span class="lex-tag">piece</span> 400 <span class="lex-tag">tape</span> 401 <span class="lex-tag">news</span> 402 <span class="lex-tag">paper</span> 403 </div> 404 405 <div class="stats"> 406 <div class="stat"><strong id="total-users">...</strong> users</div> 407 <div class="stat"><strong id="total-records">...</strong> records</div> 408 <div class="stat"><strong id="active-users">...</strong> active</div> 409 </div> 410 </header> 411 412 <div class="tabs"> 413 <button class="tab" data-tab="users">Top Handles</button> 414 <button class="tab active" data-tab="feed">All Media</button> 415 </div> 416 417 <!-- Top Users --> 418 <div class="tab-panel" id="panel-users"> 419 <div class="search-box"> 420 <input type="text" id="search" placeholder="Search by handle..." autocomplete="off"> 421 </div> 422 <div class="user-filters" id="user-filters"></div> 423 <div id="users-container"> 424 <div class="loading"><div class="spinner"></div><p>Loading users...</p></div> 425 </div> 426 </div> 427 428 <!-- All Media Feed --> 429 <div class="tab-panel active" id="panel-feed"> 430 <div class="feed-filters" id="feed-filters"></div> 431 <div id="feed-container"> 432 <div class="loading"><div class="spinner"></div><p>Loading media...</p></div> 433 </div> 434 </div> 435 436 <footer> 437 <p> 438 Powered by <a href="https://atproto.com" target="_blank">AT Protocol</a> · 439 <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a> · 440 <a href="https://art.at.aesthetic.computer" target="_blank">Guest Art</a> · 441 <a href="https://papers.aesthetic.computer" target="_blank">Papers</a> 442 </p> 443 <p>mail@aesthetic.computer</p> 444 </footer> 445 </div> 446 447 <script src="/media-modal.js"></script> 448 <script src="/media-records.js"></script> 449 <script> 450 const API_URL = 'https://aesthetic.computer'; 451 const PDS_URL = 'https://at.aesthetic.computer'; 452 const COLLECTIONS = [ 453 { id: 'computer.aesthetic.painting', icon: '🎨', label: 'painting' }, 454 { id: 'computer.aesthetic.mood', icon: '💬', label: 'mood' }, 455 { id: 'computer.aesthetic.kidlisp', icon: '📝', label: 'kidlisp' }, 456 { id: 'computer.aesthetic.piece', icon: '🧩', label: 'piece' }, 457 { id: 'computer.aesthetic.tape', icon: '📼', label: 'tape' }, 458 { id: 'computer.aesthetic.news', icon: '📰', label: 'news' }, 459 { id: 'computer.aesthetic.paper', icon: '📄', label: 'paper' }, 460 ]; 461 462 const COLLECTION_BY_LABEL = Object.fromEntries(COLLECTIONS.map(c => [c.label, c.id])); 463 464 let allUsers = []; 465 let feedLoaded = false; 466 let activeUserCollection = 'all'; 467 let userSearchQuery = ''; 468 let didToHandle = new Map(); 469 470 function normalizeHandle(value) { 471 return String(value || '') 472 .trim() 473 .replace(/^@/, '') 474 .replace(/\.at\.aesthetic\.computer$/i, '') 475 .toLowerCase(); 476 } 477 478 function formatDid(did) { 479 if (!did) return 'unknown'; 480 return did.length > 24 ? `${did.slice(0, 20)}...` : did; 481 } 482 483 function userMatchesQuery(user, query) { 484 if (!query) return true; 485 const fields = [ 486 user.handle, 487 user.code, 488 normalizeHandle(user.handle), 489 normalizeHandle(user.code), 490 ]; 491 return fields.some(v => String(v || '').toLowerCase().includes(query)); 492 } 493 494 function getFilteredUsers() { 495 let users = allUsers; 496 if (activeUserCollection !== 'all') { 497 const colId = COLLECTION_BY_LABEL[activeUserCollection]; 498 users = users.filter(user => (user.collections || []).includes(colId)); 499 } 500 const q = normalizeHandle(userSearchQuery); 501 if (q) users = users.filter(user => userMatchesQuery(user, q)); 502 return users; 503 } 504 505 function renderFilteredUsers() { 506 renderUsers(getFilteredUsers()); 507 } 508 509 function buildUserFilters() { 510 const el = document.getElementById('user-filters'); 511 if (!el) return; 512 513 const filters = [{ label: 'all', icon: '⭐' }, ...COLLECTIONS]; 514 el.innerHTML = filters 515 .map(f => `<button class="user-filter ${f.label === activeUserCollection ? 'active' : ''}" data-label="${f.label}">${f.icon} ${f.label}</button>`) 516 .join(''); 517 518 el.querySelectorAll('.user-filter').forEach(btn => { 519 btn.addEventListener('click', () => { 520 activeUserCollection = btn.dataset.label; 521 buildUserFilters(); 522 renderFilteredUsers(); 523 }); 524 }); 525 } 526 527 function repoLabelForItem(item) { 528 if (item._handle) return item._handle; 529 const repo = item.uri ? item.uri.split('/')[2] : item._did; 530 if (!repo) return 'unknown'; 531 if (repo.startsWith('did:')) return formatDid(repo); 532 return `@${normalizeHandle(repo)}`; 533 } 534 535 // --- Tabs --- 536 document.querySelectorAll('.tab').forEach(tab => { 537 tab.addEventListener('click', () => { 538 document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 539 document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); 540 tab.classList.add('active'); 541 document.getElementById('panel-' + tab.dataset.tab).classList.add('active'); 542 if (tab.dataset.tab === 'feed' && !feedLoaded) loadFeed(); 543 }); 544 }); 545 546 // --- Users Tab --- 547 async function fetchUsers() { 548 try { 549 const response = await fetch(`${API_URL}/.netlify/functions/atproto-user-stats?limit=100`); 550 const data = await response.json(); 551 if (!data.users) throw new Error('Invalid response'); 552 allUsers = data.users; 553 554 if (data.stats) { 555 document.getElementById('total-users').textContent = data.stats.totalUsers.toLocaleString(); 556 document.getElementById('total-records').textContent = data.stats.totalRecords.toLocaleString(); 557 document.getElementById('active-users').textContent = data.stats.activeUsers.toLocaleString(); 558 } 559 buildUserFilters(); 560 renderFilteredUsers(); 561 } catch (error) { 562 document.getElementById('users-container').innerHTML = '<div class="error">Failed to load users.</div>'; 563 } 564 } 565 566 function renderUsers(users) { 567 const container = document.getElementById('users-container'); 568 if (!users.length) { container.innerHTML = '<div class="loading">No users found</div>'; return; } 569 570 const list = document.createElement('div'); 571 list.className = 'user-list'; 572 573 users.forEach((user, index) => { 574 const row = document.createElement('a'); 575 row.className = 'user-row'; 576 const identifier = user.handle || user.code; 577 const shortHandle = normalizeHandle(identifier); 578 row.href = `https://${shortHandle}.at.aesthetic.computer`; 579 row.target = '_blank'; 580 581 const badges = []; 582 const badgeMap = { 583 'computer.aesthetic.painting': '🎨', 584 'computer.aesthetic.mood': '💬', 585 'computer.aesthetic.piece': '🧩', 586 'computer.aesthetic.kidlisp': '📝', 587 'computer.aesthetic.tape': '📼', 588 }; 589 for (const [col, emoji] of Object.entries(badgeMap)) { 590 if ((user.collections || []).includes(col)) { 591 badges.push(`<span class="user-badge">${emoji} ${user.recordCounts[col] || 0}</span>`); 592 } 593 } 594 595 const displayHandle = user.isUserCode ? shortHandle : `@${shortHandle}`; 596 const mood = user.latestMood ? `"${user.latestMood.replace(/\s+/g, ' ').trim()}"` : ''; 597 const klCode = user.latestKidlispCode || ''; 598 const klPreview = klCode 599 ? `<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>` 600 : ''; 601 602 row.innerHTML = ` 603 <div class="user-left"> 604 <div class="user-rank">#${index + 1}</div> 605 ${klPreview} 606 <div class="user-handle">${displayHandle}</div> 607 ${mood ? `<div class="user-mood">${mood}</div>` : ''} 608 </div> 609 <div class="user-right"> 610 ${badges.join('')} 611 <div class="user-total">${user.totalRecords}</div> 612 </div>`; 613 list.appendChild(row); 614 }); 615 616 container.innerHTML = ''; 617 container.appendChild(list); 618 } 619 620 document.getElementById('search').addEventListener('input', (e) => { 621 userSearchQuery = e.target.value || ''; 622 renderFilteredUsers(); 623 }); 624 625 // --- All Media Feed --- 626 let allFeedItems = []; 627 let activeFilters = new Set(COLLECTIONS.map(c => c.label)); 628 629 function timeAgo(dateStr) { 630 const now = Date.now(); 631 const then = new Date(dateStr).getTime(); 632 const sec = Math.floor((now - then) / 1000); 633 if (sec < 60) return 'just now'; 634 const min = Math.floor(sec / 60); 635 if (min < 60) return `${min}m ago`; 636 const hr = Math.floor(min / 60); 637 if (hr < 24) return `${hr}h ago`; 638 const days = Math.floor(hr / 24); 639 if (days < 30) return `${days}d ago`; 640 if (days < 365) { 641 const months = Math.floor(days / 30); 642 return `${months}mo ago`; 643 } 644 const years = Math.floor(days / 365); 645 return `${years}y ago`; 646 } 647 648 const mediaRecords = window.ACMediaRecords || {}; 649 const normalizeExternalLink = mediaRecords.normalizeLink || ((link) => String(link || '').trim()); 650 const escapeHtml = mediaRecords.escapeHtml || ((value) => String(value ?? '')); 651 const buildFeedModalPayload = mediaRecords.buildFeedModalPayload 652 || ((item, col, repoLabel, ago, title, primaryLink) => ({ 653 title: `${col.icon} ${title || col.label}`, 654 subtitle: `${col.label} · ${ago || 'recent'} · ${repoLabel || 'unknown'}`, 655 bodyHtml: `<pre>${escapeHtml(JSON.stringify(item, null, 2))}</pre>`, 656 iframeUrl: normalizeExternalLink(primaryLink), 657 actions: [], 658 })); 659 660 function buildFilters() { 661 const el = document.getElementById('feed-filters'); 662 el.innerHTML = COLLECTIONS.map(col => 663 `<label class="feed-filter" data-col="${col.label}"> 664 <input type="checkbox" checked> ${col.icon} ${col.label} 665 </label>` 666 ).join(''); 667 el.querySelectorAll('.feed-filter').forEach(label => { 668 label.addEventListener('click', (e) => { 669 e.preventDefault(); 670 const col = label.dataset.col; 671 if (activeFilters.has(col)) { activeFilters.delete(col); label.classList.add('off'); } 672 else { activeFilters.add(col); label.classList.remove('off'); } 673 renderFeed(allFeedItems, document.getElementById('feed-container')); 674 }); 675 }); 676 } 677 678 async function loadFeed() { 679 feedLoaded = true; 680 const container = document.getElementById('feed-container'); 681 buildFilters(); 682 didToHandle = new Map(); 683 684 try { 685 // Use top users' DIDs from already-loaded data (avoids extra listRepos call) 686 // Fall back to listRepos if users haven't loaded yet 687 let dids = []; 688 if (allUsers.length > 0) { 689 // Get unique user IDs, resolve to DIDs via the users data 690 const seen = new Set(); 691 for (const u of allUsers.slice(0, 15)) { 692 const handle = normalizeHandle(u.handle || u.code || ''); 693 if (handle && !seen.has(handle)) { 694 seen.add(handle); 695 } 696 } 697 // Resolve handles to DIDs 698 const resolves = await Promise.allSettled( 699 [...seen].map(h => 700 fetch(`${PDS_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(h + '.at.aesthetic.computer')}`) 701 .then(r => r.ok ? r.json() : null) 702 .then(d => { 703 if (d?.did) didToHandle.set(d.did, `@${h}`); 704 return d?.did; 705 }) 706 ) 707 ); 708 dids = resolves.map(r => r.value).filter(Boolean); 709 } 710 711 if (dids.length === 0) { 712 const reposRes = await fetch(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=15`); 713 const reposData = await reposRes.json(); 714 const repos = reposData.repos || []; 715 dids = repos.map(r => r.did); 716 for (const repo of repos) { 717 const handle = normalizeHandle(repo.handle || ''); 718 if (repo.did && handle) didToHandle.set(repo.did, `@${handle}`); 719 } 720 } 721 722 // Fire ALL requests in parallel: dids × collections 723 const fetches = []; 724 for (const did of dids.slice(0, 12)) { 725 for (const col of COLLECTIONS) { 726 fetches.push( 727 fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col.id)}&limit=5&reverse=true`) 728 .then(r => r.ok ? r.json() : { records: [] }) 729 .then(data => (data.records || []).map(rec => ({ ...rec.value, uri: rec.uri, _col: col, _did: did, _handle: didToHandle.get(did) || '' }))) 730 .catch(() => []) 731 ); 732 } 733 } 734 735 const results = await Promise.all(fetches); 736 allFeedItems = results.flat(); 737 738 // Sort reverse chrono 739 allFeedItems.sort((a, b) => new Date(b.when || b.createdAt || 0) - new Date(a.when || a.createdAt || 0)); 740 741 renderFeed(allFeedItems, container); 742 } catch (error) { 743 container.innerHTML = '<div class="error">Failed to load media feed.</div>'; 744 } 745 } 746 747 function renderFeed(items, container) { 748 const filtered = items.filter(item => activeFilters.has(item._col.label)); 749 if (!filtered.length) { container.innerHTML = '<div class="loading">No media found</div>'; return; } 750 751 const feed = document.createElement('div'); 752 feed.className = 'feed'; 753 754 for (const item of filtered.slice(0, 150)) { 755 const el = document.createElement('div'); 756 el.className = 'feed-item'; 757 758 const col = item._col; 759 const when = item.when || item.createdAt; 760 const ago = when ? timeAgo(when) : ''; 761 const repoLabel = repoLabelForItem(item); 762 763 let title = ''; 764 let thumb = ''; 765 let link = ''; 766 767 switch (col.label) { 768 case 'painting': 769 title = item.slug || item.code || 'Untitled'; 770 link = item.imageUrl || `https://aesthetic.computer/#${item.code}`; 771 if (item.code) thumb = `<div class="feed-thumb"><img src="https://aesthetic.computer/media/paintings/${item.code}" loading="lazy" alt=""></div>`; 772 break; 773 case 'mood': 774 title = item.mood || ''; 775 break; 776 case 'kidlisp': 777 title = item.source ? item.source.slice(0, 80) : (item.code || ''); 778 if (item.code) { 779 link = item.acUrl || `https://aesthetic.computer/$${item.code}`; 780 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>`; 781 } 782 break; 783 case 'piece': 784 title = item.slug || 'Untitled piece'; 785 break; 786 case 'tape': 787 title = item.code || item.slug || 'Tape'; 788 link = item.acUrl || `https://aesthetic.computer/!${item.code}`; 789 break; 790 case 'news': 791 title = item.headline || ''; 792 link = normalizeExternalLink(item.link || item.url || ''); 793 break; 794 case 'paper': 795 title = item.title || ''; 796 link = normalizeExternalLink(item.pdfUrl || ''); 797 break; 798 } 799 800 const titleHtml = escapeHtml(title || '(untitled)'); 801 const modalPayload = buildFeedModalPayload(item, col, repoLabel, ago, title, link); 802 803 el.innerHTML = ` 804 <div class="feed-type">${col.icon}</div> 805 ${thumb} 806 <div class="feed-body"> 807 <div class="feed-title">${titleHtml}</div> 808 <div class="feed-meta">${col.label} · ${ago} · ${repoLabel}</div> 809 </div>`; 810 el.style.cursor = 'pointer'; 811 el.setAttribute('role', 'button'); 812 el.tabIndex = 0; 813 el.addEventListener('click', () => { 814 if (window.ACMediaModal?.open) window.ACMediaModal.open(modalPayload); 815 }); 816 el.addEventListener('keydown', (event) => { 817 if (event.key === 'Enter' || event.key === ' ') { 818 event.preventDefault(); 819 if (window.ACMediaModal?.open) window.ACMediaModal.open(modalPayload); 820 } 821 }); 822 feed.appendChild(el); 823 } 824 825 container.innerHTML = ''; 826 container.appendChild(feed); 827 } 828 829 // Init 830 fetchUsers().finally(() => { 831 if (!feedLoaded) loadFeed(); 832 }); 833 834 </script> 835</body> 836</html>