Monorepo for Aesthetic.Computer
aesthetic.computer
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> — 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>