Monorepo for Aesthetic.Computer
aesthetic.computer
1<!--
2ATProto PDS Landing Page for at.aesthetic.computer
3Uses ONLY AT Protocol APIs - no aesthetic.computer backend dependencies
4Created: 2025.10.20
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="Personal Data Server for the Aesthetic Computer community">
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 * {
18 box-sizing: border-box;
19 }
20
21 ::-webkit-scrollbar {
22 display: none;
23 }
24
25 body {
26 margin: 0;
27 font-size: 16px;
28 font-family: monospace;
29 -webkit-text-size-adjust: none;
30 background: #f5f5f5;
31 color: #000;
32 line-height: 1.5;
33 }
34
35 .container {
36 max-width: 1200px;
37 margin: 0 auto;
38 padding: 2em 1em;
39 }
40
41 header {
42 text-align: center;
43 padding: 2em 0;
44 border-bottom: 3px solid rgb(205, 92, 155);
45 margin-bottom: 2em;
46 }
47
48 h1 {
49 font-size: 2.5em;
50 font-weight: normal;
51 margin: 0 0 0.3em 0;
52 color: rgb(205, 92, 155);
53 }
54
55 h2 {
56 font-size: 1.5em;
57 font-weight: normal;
58 margin: 2em 0 1em 0;
59 color: rgb(205, 92, 155);
60 }
61
62 .subtitle {
63 font-size: 0.9em;
64 opacity: 0.7;
65 margin: 0.5em 0;
66 }
67
68 .intro {
69 text-align: center;
70 max-width: 600px;
71 margin: 0 auto 2em;
72 line-height: 1.6;
73 }
74
75 .stats-grid {
76 display: grid;
77 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
78 gap: 1em;
79 margin: 2em 0;
80 }
81
82 .stat-card {
83 background: white;
84 padding: 1.5em;
85 border-radius: 8px;
86 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
87 text-align: center;
88 }
89
90 .stat-value {
91 font-size: 2em;
92 font-weight: bold;
93 color: rgb(205, 92, 155);
94 margin: 0.2em 0;
95 }
96
97 .stat-label {
98 font-size: 0.85em;
99 opacity: 0.7;
100 text-transform: uppercase;
101 letter-spacing: 0.05em;
102 }
103
104 .user-grid {
105 display: grid;
106 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
107 gap: 0.75em;
108 margin: 2em 0;
109 }
110
111 .user-card {
112 background: white;
113 padding: 1em;
114 border-radius: 6px;
115 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
116 text-decoration: none;
117 color: inherit;
118 transition: all 0.2s;
119 display: flex;
120 flex-direction: column;
121 align-items: center;
122 text-align: center;
123 }
124
125 .user-card:hover {
126 transform: translateY(-2px);
127 box-shadow: 0 4px 12px rgba(205, 92, 155, 0.2);
128 border: 2px solid rgb(205, 92, 155);
129 padding: calc(1em - 2px);
130 }
131
132 .user-handle {
133 font-weight: bold;
134 color: rgb(205, 92, 155);
135 margin-bottom: 0.5em;
136 word-break: break-word;
137 }
138
139 .user-stats {
140 font-size: 0.75em;
141 opacity: 0.6;
142 margin-top: 0.5em;
143 }
144
145 .user-badge {
146 display: inline-block;
147 padding: 0.2em 0.5em;
148 background: rgba(205, 92, 155, 0.1);
149 border-radius: 3px;
150 font-size: 0.7em;
151 margin: 0.2em;
152 }
153
154 .loading {
155 text-align: center;
156 padding: 3em;
157 opacity: 0.5;
158 }
159
160 .error {
161 text-align: center;
162 padding: 3em;
163 color: #d32f2f;
164 }
165
166 .spinner {
167 display: inline-block;
168 width: 20px;
169 height: 20px;
170 border: 3px solid rgba(205, 92, 155, 0.3);
171 border-radius: 50%;
172 border-top-color: rgb(205, 92, 155);
173 animation: spin 1s ease-in-out infinite;
174 }
175
176 @keyframes spin {
177 to { transform: rotate(360deg); }
178 }
179
180 .search-box {
181 margin: 2em 0;
182 text-align: center;
183 }
184
185 .search-box input {
186 font-family: monospace;
187 font-size: 1em;
188 padding: 0.75em 1em;
189 width: 100%;
190 max-width: 400px;
191 border: 2px solid #e0e0e0;
192 border-radius: 6px;
193 outline: none;
194 transition: border-color 0.2s;
195 }
196
197 .search-box input:focus {
198 border-color: rgb(205, 92, 155);
199 }
200
201 footer {
202 text-align: center;
203 padding: 3em 1em 1em;
204 opacity: 0.5;
205 font-size: 0.85em;
206 }
207
208 footer a {
209 color: rgb(205, 92, 155);
210 text-decoration: none;
211 }
212
213 footer a:hover {
214 text-decoration: underline;
215 }
216
217 @media (prefers-color-scheme: dark) {
218 body {
219 background: rgb(64, 56, 74);
220 color: rgba(255, 255, 255, 0.85);
221 }
222
223 .stat-card, .user-card {
224 background: rgba(255, 255, 255, 0.05);
225 color: rgba(255, 255, 255, 0.85);
226 }
227
228 .search-box input {
229 background: rgba(255, 255, 255, 0.05);
230 border-color: rgba(255, 255, 255, 0.2);
231 color: rgba(255, 255, 255, 0.85);
232 }
233
234 .search-box input:focus {
235 border-color: rgb(205, 92, 155);
236 }
237 }
238 </style>
239</head>
240
241<body>
242 <div class="container">
243 <header>
244 <h1>at.aesthetic.computer</h1>
245 <div class="subtitle">Personal Data Server · ATProto Network</div>
246 </header>
247
248 <div class="intro">
249 <p>
250 Welcome to the <strong>Aesthetic Computer</strong> Personal Data Server (PDS).
251 This server hosts ATProto records for the community, including paintings, moods, pieces, and kidlisp code.
252 </p>
253 <p>
254 Browse user pages below to explore their creative work! 🎨
255 </p>
256 </div>
257
258 <div class="stats-grid">
259 <div class="stat-card">
260 <div class="stat-value" id="total-users">...</div>
261 <div class="stat-label">Total Users</div>
262 </div>
263 <div class="stat-card">
264 <div class="stat-value" id="total-records">...</div>
265 <div class="stat-label">Total Records</div>
266 </div>
267 <div class="stat-card">
268 <div class="stat-value" id="active-today">...</div>
269 <div class="stat-label">Active Today</div>
270 </div>
271 </div>
272
273 <h2>🌟 Top Active Users</h2>
274 <div class="search-box">
275 <input
276 type="text"
277 id="search"
278 placeholder="Search users by handle..."
279 autocomplete="off"
280 >
281 </div>
282
283 <div id="users-container">
284 <div class="loading">
285 <div class="spinner"></div>
286 <p>Loading users from ATProto...</p>
287 </div>
288 </div>
289
290 <footer>
291 <p>
292 Powered by <a href="https://atproto.com" target="_blank">AT Protocol</a> ·
293 Part of the <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a> network
294 </p>
295 </footer>
296 </div>
297
298 <script>
299 const PDS_URL = 'https://at.aesthetic.computer';
300
301 let allUsers = [];
302 let displayedUsers = [];
303 let loadingProgress = 0;
304
305 // Fetch list of repositories (users) from PDS
306 async function fetchUsers() {
307 try {
308 const response = await fetch(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=1000`);
309 const data = await response.json();
310
311 if (!data.repos || !Array.isArray(data.repos)) {
312 throw new Error('Invalid response from PDS');
313 }
314
315 const totalRepos = Math.min(data.repos.length, 200); // Limit to avoid too many requests
316
317 // Get details for each repo (with progress)
318 for (let i = 0; i < totalRepos; i++) {
319 const repo = data.repos[i];
320 loadingProgress = Math.round((i / totalRepos) * 100);
321 updateLoadingProgress();
322
323 try {
324 const detailsResponse = await fetch(
325 `${PDS_URL}/xrpc/com.atproto.repo.describeRepo?repo=${repo.did}`
326 );
327 const details = await detailsResponse.json();
328
329 if (details.handle) {
330 allUsers.push({
331 did: repo.did,
332 handle: details.handle,
333 collections: details.collections || [],
334 recordCount: details.collections?.length || 0, // Use collection count as proxy
335 lastActive: repo.head
336 });
337 }
338 } catch (e) {
339 // Skip failed repos
340 }
341
342 // Batch render every 20 users
343 if (i % 20 === 0) {
344 displayedUsers = [...allUsers].sort((a, b) => b.recordCount - a.recordCount);
345 updateStats();
346 renderUsers(displayedUsers);
347 }
348 }
349
350 // Final sort and render
351 allUsers.sort((a, b) => b.recordCount - a.recordCount);
352 displayedUsers = allUsers;
353
354 updateStats();
355 renderUsers(displayedUsers);
356 } catch (error) {
357 console.error('Error fetching users:', error);
358 document.getElementById('users-container').innerHTML =
359 '<div class="error">Failed to load users from PDS. Try refreshing the page.</div>';
360 }
361 }
362
363 function updateLoadingProgress() {
364 const container = document.getElementById('users-container');
365 if (allUsers.length === 0) {
366 container.innerHTML = `
367 <div class="loading">
368 <div class="spinner"></div>
369 <p>Loading users from ATProto... ${loadingProgress}%</p>
370 </div>
371 `;
372 }
373 }
374
375 // Update stats display
376 function updateStats() {
377 document.getElementById('total-users').textContent = allUsers.length;
378
379 const totalRecords = allUsers.reduce((sum, u) => sum + u.recordCount, 0);
380 document.getElementById('total-records').textContent = totalRecords.toLocaleString();
381
382 // For "active today", we'd need timestamps which requires more API calls
383 // For now, show users with any records
384 const activeUsers = allUsers.filter(u => u.recordCount > 0).length;
385 document.getElementById('active-today').textContent = activeUsers;
386 }
387
388 // Render user cards
389 function renderUsers(users) {
390 const container = document.getElementById('users-container');
391
392 if (users.length === 0) {
393 container.innerHTML = '<div class="loading">No users found</div>';
394 return;
395 }
396
397 const grid = document.createElement('div');
398 grid.className = 'user-grid';
399
400 users.forEach(user => {
401 const card = document.createElement('a');
402 card.className = 'user-card';
403 card.href = `https://${user.handle}.${PDS_URL.replace('https://', '')}`;
404 card.target = '_blank';
405
406 const badges = [];
407 if (user.collections.includes('computer.aesthetic.painting')) {
408 badges.push('<span class="user-badge">🎨 Paintings</span>');
409 }
410 if (user.collections.includes('computer.aesthetic.mood')) {
411 badges.push('<span class="user-badge">💬 Moods</span>');
412 }
413 if (user.collections.includes('computer.aesthetic.piece')) {
414 badges.push('<span class="user-badge">🎵 Pieces</span>');
415 }
416 if (user.collections.includes('computer.aesthetic.kidlisp')) {
417 badges.push('<span class="user-badge">📝 Code</span>');
418 }
419 if (user.collections.includes('computer.aesthetic.tape')) {
420 badges.push('<span class="user-badge">📼 Tapes</span>');
421 }
422
423 card.innerHTML = `
424 <div class="user-handle">@${user.handle.replace('.at.aesthetic.computer', '')}</div>
425 <div>${badges.join(' ')}</div>
426 <div class="user-stats">${user.recordCount} record${user.recordCount !== 1 ? 's' : ''}</div>
427 `;
428
429 grid.appendChild(card);
430 });
431
432 container.innerHTML = '';
433 container.appendChild(grid);
434 }
435
436 // Search functionality
437 document.getElementById('search').addEventListener('input', (e) => {
438 const query = e.target.value.toLowerCase();
439
440 if (!query) {
441 displayedUsers = allUsers;
442 } else {
443 displayedUsers = allUsers.filter(user =>
444 user.handle.toLowerCase().includes(query)
445 );
446 }
447
448 renderUsers(displayedUsers);
449 });
450
451 // Initialize
452 fetchUsers();
453 </script>
454</body>
455</html>