Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at: share media modal + record rendering helpers across pages

+968 -501
+30 -5
at/index.html
··· 444 444 </footer> 445 445 </div> 446 446 447 + <script src="/media-modal.js"></script> 448 + <script src="/media-records.js"></script> 447 449 <script> 448 450 const API_URL = 'https://aesthetic.computer'; 449 451 const PDS_URL = 'https://at.aesthetic.computer'; ··· 643 645 return `${years}y ago`; 644 646 } 645 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 + 646 660 function buildFilters() { 647 661 const el = document.getElementById('feed-filters'); 648 662 el.innerHTML = COLLECTIONS.map(col => ··· 775 789 break; 776 790 case 'news': 777 791 title = item.headline || ''; 778 - link = item.link || ''; 792 + link = normalizeExternalLink(item.link || item.url || ''); 779 793 break; 780 794 case 'paper': 781 795 title = item.title || ''; 782 - link = item.pdfUrl || ''; 796 + link = normalizeExternalLink(item.pdfUrl || ''); 783 797 break; 784 798 } 785 799 786 - const titleHtml = link 787 - ? `<a href="${link}" target="_blank">${title}</a>` 788 - : title; 800 + const titleHtml = escapeHtml(title || '(untitled)'); 801 + const modalPayload = buildFeedModalPayload(item, col, repoLabel, ago, title, link); 789 802 790 803 el.innerHTML = ` 791 804 <div class="feed-type">${col.icon}</div> ··· 794 807 <div class="feed-title">${titleHtml}</div> 795 808 <div class="feed-meta">${col.label} · ${ago} · ${repoLabel}</div> 796 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 + }); 797 822 feed.appendChild(el); 798 823 } 799 824
+567 -383
at/landing-page.html
··· 1 1 <!-- 2 2 ATProto PDS Landing Page for at.aesthetic.computer 3 - Top 100 Users - Simple flat list design 4 - Created: 2025.10.25 3 + Mission statement + Top Users + All Media feed 4 + 2026.03.23 5 5 --> 6 6 <!DOCTYPE html> 7 7 <html lang="en"> ··· 9 9 <meta charset="UTF-8"> 10 10 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 11 11 <title>at · Aesthetic Computer</title> 12 - <meta name="description" content="Personal Data Server for the Aesthetic Computer community"> 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 13 <link rel="icon" type="image/png" 14 14 href="https://pals-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com/painting-2023.7.29.20.39.png"> 15 - 15 + 16 16 <style> 17 - * { 18 - box-sizing: border-box; 19 - } 17 + * { box-sizing: border-box; } 18 + ::-webkit-scrollbar { display: none; } 20 19 21 - ::-webkit-scrollbar { 22 - display: none; 23 - } 24 - 25 20 body { 26 21 margin: 0; 27 22 font-size: 14px; ··· 38 33 padding: 1em 0.5em; 39 34 } 40 35 36 + a { color: rgb(205, 92, 155); text-decoration: none; } 37 + a:hover { text-decoration: underline; } 38 + 41 39 header { 42 40 text-align: center; 43 41 padding: 1em 0; ··· 60 58 61 59 #pals-beacon .pals-logo, 62 60 #pals-beacon .pals-logo-pink { 63 - width: 100px; 61 + width: 80px; 64 62 height: auto; 65 63 } 66 64 ··· 73 71 position: absolute; 74 72 top: 0; 75 73 left: 0; 76 - opacity: 1; 77 - filter: hue-rotate(-30deg) saturate(1.5) brightness(1.2) drop-shadow(0 0 8px rgba(255, 100, 200, 0.8)) drop-shadow(0 0 16px rgba(255, 100, 200, 0.5)); 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)); 78 76 animation: pals-idle 2s ease-in-out infinite; 79 77 } 80 78 81 - #pals-beacon:hover .pals-logo { 82 - filter: grayscale(100%) opacity(0.1); 83 - } 84 - 85 - #pals-beacon:hover .pals-logo-pink { 86 - animation: pals-energy 0.4s ease-in-out infinite; 87 - } 88 - 89 79 @keyframes pals-idle { 90 - 0%, 100% { 91 - transform: rotate(0deg) scale(1); 92 - opacity: 0.7; 93 - filter: hue-rotate(-30deg) saturate(1.2) brightness(1.1) drop-shadow(0 0 6px rgba(255, 100, 200, 0.5)) drop-shadow(0 0 12px rgba(255, 100, 200, 0.3)); 94 - } 95 - 50% { 96 - transform: rotate(-1deg) scale(1.01); 97 - opacity: 1; 98 - filter: hue-rotate(-30deg) saturate(1.5) brightness(1.2) drop-shadow(0 0 10px rgba(255, 100, 200, 0.8)) drop-shadow(0 0 20px rgba(255, 100, 200, 0.5)); 99 - } 100 - } 101 - 102 - @keyframes pals-energy { 103 - 0%, 100% { 104 - transform: rotate(0deg) scale(1); 105 - filter: drop-shadow(0 0 12px rgba(255, 100, 200, 1)) drop-shadow(0 0 24px rgba(255, 100, 200, 0.7)); 106 - } 107 - 25% { 108 - transform: rotate(-4deg) scale(1.05); 109 - filter: drop-shadow(0 0 16px rgba(255, 100, 200, 1)) drop-shadow(0 0 32px rgba(255, 100, 200, 0.8)); 110 - } 111 - 50% { 112 - transform: rotate(3deg) scale(1.08); 113 - filter: drop-shadow(0 0 20px rgba(255, 100, 200, 1)) drop-shadow(0 0 40px rgba(255, 100, 200, 0.9)); 114 - } 115 - 75% { 116 - transform: rotate(-2deg) scale(1.04); 117 - filter: drop-shadow(0 0 16px rgba(255, 100, 200, 1)) drop-shadow(0 0 32px rgba(255, 100, 200, 0.8)); 118 - } 80 + 0%, 100% { transform: scale(1); opacity: 0.7; } 81 + 50% { transform: scale(1.01); opacity: 1; } 119 82 } 120 83 121 84 h1 { ··· 131 94 margin: 0.3em 0; 132 95 } 133 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 + 134 141 .stats { 135 142 display: flex; 136 143 gap: 1em; 137 144 justify-content: center; 138 145 flex-wrap: wrap; 139 - margin: 0.5em 0; 146 + margin: 0.8em 0; 140 147 font-size: 0.75em; 141 148 } 142 149 ··· 146 153 border-radius: 3px; 147 154 } 148 155 149 - .stat strong { 150 - color: rgb(205, 92, 155); 151 - } 156 + .stat strong { color: rgb(205, 92, 155); } 152 157 153 - .section-title { 154 - font-size: 1.2em; 155 - font-weight: normal; 156 - margin: 1em 0 0.5em 0; 157 - padding-bottom: 0.3em; 158 - border-bottom: 1px solid rgba(205, 92, 155, 0.3); 158 + .tabs { 159 159 display: flex; 160 - justify-content: space-between; 161 - align-items: center; 160 + gap: 0; 161 + border-bottom: 1px solid rgba(205, 92, 155, 0.3); 162 + margin: 1em 0 0.5em 0; 162 163 } 163 164 164 - .section-count { 165 - font-size: 0.7em; 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 { 166 179 color: rgb(205, 92, 155); 180 + border-bottom-color: rgb(205, 92, 155); 167 181 font-weight: bold; 168 182 } 169 183 170 - .search-box { 171 - margin: 1em 0; 172 - } 184 + .tab-panel { display: none; } 185 + .tab-panel.active { display: block; } 186 + 187 + .search-box { margin: 0.5em 0 1em 0; } 173 188 174 189 .search-box input { 175 190 font-family: monospace; ··· 180 195 border-radius: 3px; 181 196 outline: none; 182 197 background: white; 183 - transition: border-color 0.2s; 184 198 } 185 199 186 - .search-box input:focus { 187 - border-color: rgb(205, 92, 155); 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; 188 207 } 189 208 190 - .user-list { 191 - display: flex; 192 - flex-direction: column; 193 - gap: 0; 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; 194 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; } 195 232 196 233 .user-row { 197 234 background: white; ··· 199 236 border-bottom: 1px solid rgba(205, 92, 155, 0.1); 200 237 text-decoration: none; 201 238 color: inherit; 202 - transition: all 0.2s; 239 + transition: all 0.15s; 203 240 display: flex; 204 241 justify-content: space-between; 205 242 align-items: center; 206 243 gap: 1em; 207 244 } 208 245 209 - .user-row:first-child { 210 - border-radius: 4px 4px 0 0; 211 - } 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; } 212 249 213 - .user-row:last-child { 214 - border-radius: 0 0 4px 4px; 215 - border-bottom: none; 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); 216 261 } 262 + .user-kidlisp-preview img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 217 263 218 - .user-row:hover { 219 - background: rgba(205, 92, 155, 0.05); 220 - padding-left: 1em; 221 - } 264 + /* Media feed */ 265 + .feed { display: flex; flex-direction: column; gap: 0.4em; } 222 266 223 - .user-left { 267 + .feed-item { 268 + background: white; 269 + padding: 0.75em; 270 + border-radius: 4px; 224 271 display: flex; 225 272 align-items: center; 226 273 gap: 0.75em; 227 - flex: 1; 228 - min-width: 0; 229 274 } 230 275 231 - .user-kidlisp-preview { 232 - width: 36px; 233 - height: 36px; 234 - border-radius: 6px; 235 - overflow: hidden; 276 + .feed-type { 277 + font-size: 1.2em; 278 + width: 2em; 279 + text-align: center; 236 280 flex-shrink: 0; 237 - border: 1px solid rgba(205, 92, 155, 0.2); 238 281 } 239 282 240 - .user-kidlisp-preview img { 241 - width: 100%; 242 - height: 100%; 243 - object-fit: cover; 244 - image-rendering: pixelated; 245 - } 283 + .feed-body { flex: 1; min-width: 0; } 246 284 247 - .user-mood { 248 - font-size: 0.75em; 249 - color: rgba(0, 0, 0, 0.6); 250 - margin-left: 0.5em; 251 - max-width: 320px; 285 + .feed-title { 286 + font-weight: bold; 252 287 overflow: hidden; 253 288 text-overflow: ellipsis; 254 289 white-space: nowrap; 255 290 } 256 291 257 - .user-rank { 292 + .feed-meta { 258 293 font-size: 0.75em; 259 - color: rgba(205, 92, 155, 0.5); 260 - font-weight: bold; 261 - min-width: 2.5em; 262 - text-align: right; 294 + color: rgba(0,0,0,0.5); 295 + margin-top: 0.2em; 263 296 } 264 297 265 - .user-handle { 266 - font-weight: bold; 267 - color: rgb(205, 92, 155); 268 - word-break: break-all; 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); 269 303 } 304 + .feed-thumb img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; } 270 305 271 - .user-right { 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 { 272 312 display: flex; 273 313 gap: 0.5em; 274 - align-items: center; 275 314 flex-wrap: wrap; 276 - justify-content: flex-end; 315 + margin: 0.5em 0 1em 0; 277 316 } 278 317 279 - .user-badge { 280 - font-size: 0.7em; 281 - padding: 0.3em 0.5em; 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; 282 325 background: rgba(205, 92, 155, 0.1); 283 326 border-radius: 3px; 284 - white-space: nowrap; 327 + user-select: none; 328 + transition: opacity 0.15s; 285 329 } 286 330 287 - .user-total { 288 - font-size: 0.75em; 289 - color: rgba(0, 0, 0, 0.5); 290 - font-weight: bold; 291 - min-width: 3em; 292 - text-align: right; 293 - } 331 + .feed-filter.off { opacity: 0.35; } 294 332 295 - .loading { 296 - text-align: center; 297 - padding: 3em 2em; 298 - opacity: 0.6; 299 - } 333 + .feed-filter input { display: none; } 300 334 301 - .error { 302 - text-align: center; 303 - padding: 3em 2em; 304 - color: #d32f2f; 305 - } 306 - 307 - .spinner { 308 - display: inline-block; 309 - width: 16px; 310 - height: 16px; 311 - border: 2px solid rgba(205, 92, 155, 0.3); 312 - border-radius: 50%; 313 - border-top-color: rgb(205, 92, 155); 314 - animation: spin 1s ease-in-out infinite; 315 - } 316 - 317 - @keyframes spin { 318 - to { transform: rotate(360deg); } 319 - } 320 - 321 - footer { 322 - text-align: center; 323 - padding: 2em 1em 1em; 324 - opacity: 0.5; 325 - font-size: 0.75em; 326 - } 327 - 328 - footer a { 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; 329 345 color: rgb(205, 92, 155); 330 - text-decoration: none; 331 346 } 347 + .load-more:hover { background: rgba(205, 92, 155, 0.2); } 332 348 333 - footer a:hover { 334 - text-decoration: underline; 335 - } 336 - 337 - @media (max-width: 768px) { 338 - .user-mood { 339 - max-width: 150px; 340 - } 341 - 342 - .user-right { 343 - gap: 0.3em; 344 - } 345 - 346 - .user-badge { 347 - font-size: 0.65em; 348 - padding: 0.2em 0.4em; 349 - } 350 - } 349 + footer { text-align: center; padding: 2em 1em 1em; opacity: 0.5; font-size: 0.75em; } 351 350 352 351 @media (max-width: 560px) { 353 - .container { 354 - padding: 0.5em 0.3em; 355 - } 356 - 357 - .user-row { 358 - padding: 0.5em; 359 - gap: 0.5em; 360 - } 361 - 362 - .user-left { 363 - gap: 0.5em; 364 - } 365 - 366 - .user-handle { 367 - font-size: 0.85em; 368 - } 369 - 370 - .user-mood { 371 - display: none; 372 - } 373 - 374 - .user-kidlisp-preview { 375 - width: 28px; 376 - height: 28px; 377 - border-radius: 4px; 378 - } 379 - 380 - .user-rank { 381 - font-size: 0.65em; 382 - min-width: 2em; 383 - } 384 - 385 - .user-badge { 386 - font-size: 0.6em; 387 - } 388 - 389 - .user-total { 390 - font-size: 0.65em; 391 - min-width: 2.5em; 392 - } 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; } 393 356 } 394 357 395 358 @media (max-width: 400px) { 396 - .user-right { 397 - display: none; 398 - } 399 - 400 - .user-handle { 401 - font-size: 0.8em; 402 - } 359 + .user-right { display: none; } 403 360 } 404 361 405 362 @media (prefers-color-scheme: dark) { 406 - body { 407 - background: rgb(64, 56, 74); 408 - color: rgba(255, 255, 255, 0.85); 409 - } 410 - 411 - .user-row { 412 - background: rgba(255, 255, 255, 0.05); 413 - border-bottom-color: rgba(205, 92, 155, 0.2); 414 - } 415 - 416 - .user-row:hover { 417 - background: rgba(205, 92, 155, 0.1); 418 - } 419 - 420 - .search-box input { 421 - background: rgba(255, 255, 255, 0.05); 422 - border-color: rgba(255, 255, 255, 0.2); 423 - color: rgba(255, 255, 255, 0.85); 424 - } 425 - 426 - .search-box input:focus { 427 - border-color: rgb(205, 92, 155); 428 - } 429 - 430 - .user-total { 431 - color: rgba(255, 255, 255, 0.5); 432 - } 433 - 434 - .user-mood { 435 - color: rgba(255, 255, 255, 0.6); 436 - } 437 - 438 - .user-kidlisp-preview { 439 - border-color: rgba(255, 255, 255, 0.15); 440 - } 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); } 441 370 } 442 371 </style> 443 372 </head> ··· 445 374 <body> 446 375 <div class="container"> 447 376 <header> 448 - <a id="pals-beacon" href="https://aesthetic.computer" target="_blank" aria-label="Aesthetic Computer"> 377 + <a id="pals-beacon" href="https://at.aesthetic.computer" aria-label="at.aesthetic.computer"> 449 378 <div class="pals-logo-container"> 450 379 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo"> 451 380 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo-pink"> 452 381 </div> 453 382 </a> 454 383 <h1>at.aesthetic.computer</h1> 455 - <div class="subtitle">Personal Data Server · ATProto Network</div> 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 + 456 405 <div class="stats"> 457 406 <div class="stat"><strong id="total-users">...</strong> users</div> 458 407 <div class="stat"><strong id="total-records">...</strong> records</div> 459 - <div class="stat"><strong id="active-today">...</strong> active today</div> 460 - </div> 461 - <div style="margin-top: 1em; padding: 0.75em; background: rgba(205, 92, 155, 0.1); border-radius: 4px;"> 462 - <a href="https://art.at.aesthetic.computer" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none; font-size: 0.9em;"> 463 - 🎨 Anonymously recorded tapes and other media at art.at.aesthetic.computer → 464 - </a> 408 + <div class="stat"><strong id="active-users">...</strong> active</div> 465 409 </div> 466 410 </header> 467 411 468 - <div class="section-title"> 469 - <span>🌟 Top Users</span> 470 - <span class="section-count" id="showing-count">...</span> 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> 471 415 </div> 472 416 473 - <div class="search-box"> 474 - <input 475 - type="text" 476 - id="search" 477 - placeholder="Search by handle..." 478 - autocomplete="off" 479 - > 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> 480 426 </div> 481 427 482 - <div id="users-container"> 483 - <div class="loading"> 484 - <div class="spinner"></div> 485 - <p>Loading users...</p> 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> 486 433 </div> 487 434 </div> 488 435 489 436 <footer> 490 437 <p> 491 - Powered by <a href="https://atproto.com" target="_blank">AT Protocol</a> · 492 - Part of the <a href="https://aesthetic.computer" target="_blank">Aesthetic Computer</a> network 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> 493 442 </p> 443 + <p>mail@aesthetic.computer</p> 494 444 </footer> 495 445 </div> 496 446 447 + <script src="/media-modal.js"></script> 448 + <script src="/media-records.js"></script> 497 449 <script> 498 450 const API_URL = 'https://aesthetic.computer'; 499 - 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 + 500 464 let allUsers = []; 501 - let displayedUsers = []; 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 + } 502 508 503 - // Fetch user stats from aesthetic.computer API (aggregated from MongoDB) 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 --- 504 547 async function fetchUsers() { 505 548 try { 506 549 const response = await fetch(`${API_URL}/.netlify/functions/atproto-user-stats?limit=100`); 507 550 const data = await response.json(); 508 - 509 - if (!data.users || !Array.isArray(data.users)) { 510 - throw new Error('Invalid response from API'); 511 - } 512 - 551 + if (!data.users) throw new Error('Invalid response'); 513 552 allUsers = data.users; 514 - displayedUsers = allUsers; 515 - 516 - // Update stats from API 553 + 517 554 if (data.stats) { 518 555 document.getElementById('total-users').textContent = data.stats.totalUsers.toLocaleString(); 519 556 document.getElementById('total-records').textContent = data.stats.totalRecords.toLocaleString(); 520 - document.getElementById('active-today').textContent = data.stats.activeUsers.toLocaleString(); 557 + document.getElementById('active-users').textContent = data.stats.activeUsers.toLocaleString(); 521 558 } 522 - 523 - renderUsers(displayedUsers); 559 + buildUserFilters(); 560 + renderFilteredUsers(); 524 561 } catch (error) { 525 - console.error('Error fetching users:', error); 526 - document.getElementById('users-container').innerHTML = 527 - '<div class="error">Failed to load users. Try refreshing the page.</div>'; 562 + document.getElementById('users-container').innerHTML = '<div class="error">Failed to load users.</div>'; 528 563 } 529 564 } 530 565 531 - // Render user list (flat, top 100 style) 532 566 function renderUsers(users) { 533 567 const container = document.getElementById('users-container'); 534 - 535 - if (users.length === 0) { 536 - container.innerHTML = '<div class="loading">No users found</div>'; 537 - document.getElementById('showing-count').textContent = '0 users'; 538 - return; 539 - } 540 - 541 - document.getElementById('showing-count').textContent = `${users.length} user${users.length !== 1 ? 's' : ''}`; 568 + if (!users.length) { container.innerHTML = '<div class="loading">No users found</div>'; return; } 542 569 543 570 const list = document.createElement('div'); 544 571 list.className = 'user-list'; ··· 546 573 users.forEach((user, index) => { 547 574 const row = document.createElement('a'); 548 575 row.className = 'user-row'; 549 - 550 - // Use the actual handle if available, otherwise use the code 551 576 const identifier = user.handle || user.code; 552 - const shortHandle = identifier.replace('.at.aesthetic.computer', '').replace('@', ''); 577 + const shortHandle = normalizeHandle(identifier); 553 578 row.href = `https://${shortHandle}.at.aesthetic.computer`; 554 579 row.target = '_blank'; 555 580 556 - // Build badges 557 581 const badges = []; 558 - if (user.collections.includes('computer.aesthetic.painting')) { 559 - const count = user.recordCounts['computer.aesthetic.painting'] || 0; 560 - badges.push(`<span class="user-badge">🎨 ${count}</span>`); 561 - } 562 - if (user.collections.includes('computer.aesthetic.mood')) { 563 - const count = user.recordCounts['computer.aesthetic.mood'] || 0; 564 - badges.push(`<span class="user-badge">💬 ${count}</span>`); 565 - } 566 - if (user.collections.includes('computer.aesthetic.piece')) { 567 - const count = user.recordCounts['computer.aesthetic.piece'] || 0; 568 - badges.push(`<span class="user-badge">🎵 ${count}</span>`); 569 - } 570 - if (user.collections.includes('computer.aesthetic.kidlisp')) { 571 - const count = user.recordCounts['computer.aesthetic.kidlisp'] || 0; 572 - badges.push(`<span class="user-badge">📝 ${count}</span>`); 573 - } 574 - if (user.collections.includes('computer.aesthetic.tape')) { 575 - const count = user.recordCounts['computer.aesthetic.tape'] || 0; 576 - badges.push(`<span class="user-badge">📼 ${count}</span>`); 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 + } 577 593 } 578 594 579 595 const displayHandle = user.isUserCode ? shortHandle : `@${shortHandle}`; 580 - const moodText = user.latestMood ? user.latestMood.replace(/\s+/g, ' ').trim() : ''; 581 - const moodPreview = moodText ? `“${moodText}”` : ''; 582 - const kidlispCode = user.latestKidlispCode || ''; 583 - const kidlispPreview = kidlispCode 584 - ? `<div class="user-kidlisp-preview"><img src="https://oven.aesthetic.computer/grab/webp/100/100/$${kidlispCode}?duration=2000&fps=8&quality=70&density=1&nowait=true" alt="${displayHandle} KidLisp preview" loading="lazy"></div>` 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>` 585 600 : ''; 586 601 587 602 row.innerHTML = ` 588 603 <div class="user-left"> 589 604 <div class="user-rank">#${index + 1}</div> 590 - ${kidlispPreview} 605 + ${klPreview} 591 606 <div class="user-handle">${displayHandle}</div> 592 - ${moodPreview ? `<div class="user-mood">${moodPreview}</div>` : ''} 607 + ${mood ? `<div class="user-mood">${mood}</div>` : ''} 593 608 </div> 594 609 <div class="user-right"> 595 - <div class="user-badges">${badges.join('')}</div> 610 + ${badges.join('')} 596 611 <div class="user-total">${user.totalRecords}</div> 597 - </div> 598 - `; 599 - 612 + </div>`; 600 613 list.appendChild(row); 601 614 }); 602 615 ··· 604 617 container.appendChild(list); 605 618 } 606 619 607 - // Search functionality 608 620 document.getElementById('search').addEventListener('input', (e) => { 609 - const query = e.target.value.toLowerCase(); 610 - 611 - if (!query) { 612 - displayedUsers = allUsers; 613 - } else { 614 - displayedUsers = allUsers.filter(user => { 615 - const handle = (user.handle || user.code).toLowerCase(); 616 - return handle.includes(query); 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')); 617 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>'; 618 744 } 619 - 620 - renderUsers(displayedUsers); 621 - }); 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 = ''; 622 766 623 - // Auto-refresh placeholder images after they load (placeholders have no-cache header) 624 - // We can detect baking placeholders because they're much smaller and have SVG text content 625 - document.addEventListener('load', (e) => { 626 - if (e.target.tagName === 'IMG' && e.target.src.includes('nowait=true')) { 627 - // Placeholder images are very small (~2-5KB), real WebPs are larger 628 - // Retry loading after a delay to get the real image 629 - const img = e.target; 630 - const naturalSize = img.naturalWidth * img.naturalHeight; 631 - 632 - // Small placeholder-like images should retry 633 - if (naturalSize > 0 && !img.dataset.retried) { 634 - // Schedule a retry after 10-15 seconds 635 - const retryDelay = 10000 + Math.random() * 5000; 636 - setTimeout(() => { 637 - img.dataset.retried = 'true'; 638 - // Bust the cache by adding a timestamp 639 - const url = new URL(img.src); 640 - url.searchParams.set('t', Date.now()); 641 - img.src = url.toString(); 642 - }, retryDelay); 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; 643 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); 644 823 } 645 - }, true); 646 824 647 - // Initialize 648 - fetchUsers(); 825 + container.innerHTML = ''; 826 + container.appendChild(feed); 827 + } 828 + 829 + // Init 830 + fetchUsers().finally(() => { 831 + if (!feedLoaded) loadFeed(); 832 + }); 649 833 </script> 650 834 </body> 651 - </html> 835 + </html>
+225
at/media-modal.js
··· 1 + (function initACMediaModal(global) { 2 + if (global.ACMediaModal) return; 3 + 4 + function ensureStyles() { 5 + if (document.getElementById("ac-media-modal-styles")) return; 6 + const style = document.createElement("style"); 7 + style.id = "ac-media-modal-styles"; 8 + style.textContent = ` 9 + .ac-media-modal { 10 + position: fixed; 11 + inset: 0; 12 + z-index: 9999; 13 + display: none; 14 + } 15 + .ac-media-modal.active { 16 + display: block; 17 + } 18 + .ac-media-modal-backdrop { 19 + position: absolute; 20 + inset: 0; 21 + background: rgba(0, 0, 0, 0.75); 22 + } 23 + .ac-media-modal-panel { 24 + position: relative; 25 + z-index: 1; 26 + width: min(1000px, calc(100vw - 2rem)); 27 + max-height: calc(100vh - 2rem); 28 + margin: 1rem auto; 29 + overflow: hidden; 30 + background: #fff; 31 + color: #000; 32 + border: 2px solid rgba(205, 92, 155, 0.55); 33 + border-radius: 8px; 34 + display: flex; 35 + flex-direction: column; 36 + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); 37 + } 38 + .ac-media-modal-header { 39 + padding: 0.9rem 1rem; 40 + border-bottom: 1px solid rgba(205, 92, 155, 0.25); 41 + background: rgba(205, 92, 155, 0.1); 42 + } 43 + .ac-media-modal-title { 44 + margin: 0; 45 + font-size: 1rem; 46 + line-height: 1.3; 47 + } 48 + .ac-media-modal-subtitle { 49 + margin-top: 0.3rem; 50 + opacity: 0.7; 51 + font-size: 0.8rem; 52 + } 53 + .ac-media-modal-close { 54 + position: absolute; 55 + top: 0.45rem; 56 + right: 0.55rem; 57 + border: 1px solid rgba(205, 92, 155, 0.45); 58 + border-radius: 4px; 59 + background: #fff; 60 + color: rgb(205, 92, 155); 61 + font-family: monospace; 62 + font-size: 0.8rem; 63 + cursor: pointer; 64 + padding: 0.25rem 0.5rem; 65 + } 66 + .ac-media-modal-main { 67 + padding: 0.9rem 1rem 1rem; 68 + overflow: auto; 69 + display: grid; 70 + gap: 0.8rem; 71 + } 72 + .ac-media-modal-body { 73 + font-size: 0.85rem; 74 + line-height: 1.5; 75 + } 76 + .ac-media-modal-iframe-wrap { 77 + border: 1px solid rgba(205, 92, 155, 0.25); 78 + border-radius: 6px; 79 + overflow: hidden; 80 + min-height: 320px; 81 + } 82 + .ac-media-modal-iframe { 83 + width: 100%; 84 + height: min(60vh, 560px); 85 + border: none; 86 + display: block; 87 + background: #fff; 88 + } 89 + .ac-media-modal-actions { 90 + display: flex; 91 + flex-wrap: wrap; 92 + gap: 0.5rem; 93 + } 94 + .ac-media-modal-action { 95 + display: inline-block; 96 + padding: 0.38rem 0.65rem; 97 + border-radius: 4px; 98 + border: 1px solid rgba(205, 92, 155, 0.45); 99 + background: rgba(205, 92, 155, 0.12); 100 + color: rgb(205, 92, 155); 101 + text-decoration: none; 102 + font-size: 0.78rem; 103 + font-family: monospace; 104 + } 105 + .ac-media-modal-action:hover { 106 + background: rgba(205, 92, 155, 0.2); 107 + } 108 + @media (prefers-color-scheme: dark) { 109 + .ac-media-modal-panel { 110 + background: rgb(42, 37, 50); 111 + color: rgba(255, 255, 255, 0.92); 112 + border-color: rgba(205, 92, 155, 0.6); 113 + } 114 + .ac-media-modal-header { 115 + background: rgba(205, 92, 155, 0.15); 116 + } 117 + .ac-media-modal-close { 118 + background: rgba(255, 255, 255, 0.08); 119 + } 120 + .ac-media-modal-iframe-wrap { 121 + border-color: rgba(255, 255, 255, 0.12); 122 + } 123 + } 124 + `; 125 + document.head.appendChild(style); 126 + } 127 + 128 + function ensureModal() { 129 + let modal = document.getElementById("ac-media-modal"); 130 + if (modal) return modal; 131 + 132 + modal = document.createElement("div"); 133 + modal.id = "ac-media-modal"; 134 + modal.className = "ac-media-modal"; 135 + modal.innerHTML = ` 136 + <div class="ac-media-modal-backdrop"></div> 137 + <div class="ac-media-modal-panel" role="dialog" aria-modal="true"> 138 + <button class="ac-media-modal-close" type="button" aria-label="Close">Close</button> 139 + <div class="ac-media-modal-header"> 140 + <h3 class="ac-media-modal-title"></h3> 141 + <div class="ac-media-modal-subtitle"></div> 142 + </div> 143 + <div class="ac-media-modal-main"> 144 + <div class="ac-media-modal-body"></div> 145 + <div class="ac-media-modal-iframe-wrap" hidden> 146 + <iframe class="ac-media-modal-iframe" loading="lazy" referrerpolicy="no-referrer"></iframe> 147 + </div> 148 + <div class="ac-media-modal-actions"></div> 149 + </div> 150 + </div> 151 + `; 152 + 153 + const close = function close() { 154 + modal.classList.remove("active"); 155 + const iframe = modal.querySelector(".ac-media-modal-iframe"); 156 + if (iframe) iframe.src = ""; 157 + document.body.style.overflow = ""; 158 + document.removeEventListener("keydown", onEscape); 159 + }; 160 + 161 + const onEscape = function onEscape(event) { 162 + if (event.key === "Escape") close(); 163 + }; 164 + 165 + modal.querySelector(".ac-media-modal-close").addEventListener("click", close); 166 + modal 167 + .querySelector(".ac-media-modal-backdrop") 168 + .addEventListener("click", close); 169 + 170 + modal.__acClose = close; 171 + modal.__acOnEscape = onEscape; 172 + 173 + document.body.appendChild(modal); 174 + return modal; 175 + } 176 + 177 + function open(options) { 178 + const opts = options || {}; 179 + ensureStyles(); 180 + const modal = ensureModal(); 181 + 182 + const titleEl = modal.querySelector(".ac-media-modal-title"); 183 + const subtitleEl = modal.querySelector(".ac-media-modal-subtitle"); 184 + const bodyEl = modal.querySelector(".ac-media-modal-body"); 185 + const iframeWrap = modal.querySelector(".ac-media-modal-iframe-wrap"); 186 + const iframeEl = modal.querySelector(".ac-media-modal-iframe"); 187 + const actionsEl = modal.querySelector(".ac-media-modal-actions"); 188 + 189 + titleEl.textContent = opts.title || "Media"; 190 + subtitleEl.textContent = opts.subtitle || ""; 191 + bodyEl.innerHTML = opts.bodyHtml || ""; 192 + 193 + const iframeUrl = typeof opts.iframeUrl === "string" ? opts.iframeUrl : ""; 194 + if (iframeUrl) { 195 + iframeEl.src = iframeUrl; 196 + iframeWrap.hidden = false; 197 + } else { 198 + iframeEl.src = ""; 199 + iframeWrap.hidden = true; 200 + } 201 + 202 + const actions = Array.isArray(opts.actions) ? opts.actions : []; 203 + actionsEl.innerHTML = actions 204 + .filter((action) => action && typeof action.url === "string" && action.url) 205 + .map((action) => { 206 + const label = String(action.label || "Open"); 207 + const href = String(action.url); 208 + return `<a class="ac-media-modal-action" href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`; 209 + }) 210 + .join(""); 211 + 212 + modal.classList.add("active"); 213 + document.body.style.overflow = "hidden"; 214 + document.removeEventListener("keydown", modal.__acOnEscape); 215 + document.addEventListener("keydown", modal.__acOnEscape); 216 + } 217 + 218 + function close() { 219 + const modal = document.getElementById("ac-media-modal"); 220 + if (!modal || !modal.__acClose) return; 221 + modal.__acClose(); 222 + } 223 + 224 + global.ACMediaModal = { open, close }; 225 + })(window);
+121
at/media-records.js
··· 1 + (function initACMediaRecords(global) { 2 + if (global.ACMediaRecords) return; 3 + 4 + function normalizeLink(link) { 5 + if (!link || typeof link !== "string") return ""; 6 + const trimmed = link.trim(); 7 + if (!trimmed) return ""; 8 + if (/^https?:\/\//i.test(trimmed)) return trimmed; 9 + if (/^at:\/\//i.test(trimmed)) return `https://pdsls.dev/${trimmed}`; 10 + if (/^[\w.-]+\.[a-z]{2,}([/?#].*)?$/i.test(trimmed)) return `https://${trimmed}`; 11 + return ""; 12 + } 13 + 14 + function recordFallbackLink(item) { 15 + if (!item?.uri || typeof item.uri !== "string") return ""; 16 + const atUri = item.uri.startsWith("at://") 17 + ? item.uri 18 + : `at://${item.uri.split("//")[1] || item.uri}`; 19 + return `https://pdsls.dev/${atUri}`; 20 + } 21 + 22 + function escapeHtml(value) { 23 + return String(value ?? "") 24 + .replace(/&/g, "&amp;") 25 + .replace(/</g, "&lt;") 26 + .replace(/>/g, "&gt;") 27 + .replace(/"/g, "&quot;") 28 + .replace(/'/g, "&#39;"); 29 + } 30 + 31 + function buildFeedModalPayload(item, col, repoLabel, ago, title, primaryLink) { 32 + const recordLink = recordFallbackLink(item); 33 + const safePrimaryLink = normalizeLink(primaryLink); 34 + const safeCode = item?.code ? encodeURIComponent(String(item.code)) : ""; 35 + let iframeUrl = ""; 36 + let previewHtml = ""; 37 + let extraHtml = ""; 38 + 39 + switch (col.label) { 40 + case "painting": 41 + if (safeCode) { 42 + previewHtml = `<img src="https://aesthetic.computer/media/paintings/${safeCode}" alt="" style="width:100%;max-height:360px;object-fit:contain;border:1px solid rgba(205,92,155,0.2);border-radius:6px;background:#fff;">`; 43 + } 44 + break; 45 + case "kidlisp": 46 + if (safeCode) { 47 + previewHtml = `<img src="https://oven.aesthetic.computer/grab/webp/640/360/$${safeCode}?duration=2500&fps=8&quality=80&density=1&nowait=true" alt="" style="width:100%;max-height:360px;object-fit:cover;border:1px solid rgba(205,92,155,0.2);border-radius:6px;">`; 48 + } 49 + if (item?.source) { 50 + extraHtml = `<pre style="margin:0;padding:0.75em;background:rgba(205,92,155,0.08);border:1px solid rgba(205,92,155,0.2);border-radius:6px;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto;">${escapeHtml(String(item.source).slice(0, 1200))}</pre>`; 51 + } 52 + break; 53 + case "mood": 54 + if (item?.mood) { 55 + extraHtml = `<blockquote style="margin:0;padding:0.75em;border-left:3px solid rgba(205,92,155,0.6);background:rgba(205,92,155,0.08);border-radius:4px;">${escapeHtml(item.mood)}</blockquote>`; 56 + } 57 + break; 58 + case "news": { 59 + const newsUrl = safePrimaryLink || "https://news.aesthetic.computer"; 60 + iframeUrl = newsUrl; 61 + if (item?.body) { 62 + extraHtml = `<div style="padding:0.75em;background:rgba(205,92,155,0.08);border:1px solid rgba(205,92,155,0.2);border-radius:6px;max-height:220px;overflow:auto;">${escapeHtml(String(item.body).slice(0, 1800))}</div>`; 63 + } 64 + break; 65 + } 66 + case "paper": 67 + iframeUrl = safePrimaryLink; 68 + break; 69 + default: 70 + iframeUrl = ""; 71 + } 72 + 73 + const metadata = [ 74 + ["Type", col.label], 75 + ["When", ago || "recent"], 76 + ["From", repoLabel || "unknown"], 77 + ["Rkey", item?.uri ? String(item.uri).split("/").pop() : ""], 78 + ] 79 + .filter((entry) => entry[1]) 80 + .map((entry) => `<div><strong>${escapeHtml(entry[0])}:</strong> ${escapeHtml(entry[1])}</div>`) 81 + .join(""); 82 + 83 + const rawRecord = escapeHtml(JSON.stringify(item, null, 2)); 84 + const bodyHtml = ` 85 + ${previewHtml} 86 + ${extraHtml} 87 + <div style="display:grid;gap:0.35em;font-size:0.82em;line-height:1.45;padding:0.65em 0.75em;border:1px solid rgba(205,92,155,0.2);border-radius:6px;background:rgba(205,92,155,0.06);">${metadata}</div> 88 + <details> 89 + <summary style="cursor:pointer;opacity:0.8;">Raw record</summary> 90 + <pre style="margin-top:0.5em;padding:0.65em;background:rgba(0,0,0,0.05);border-radius:6px;overflow:auto;font-size:0.75em;max-height:220px;">${rawRecord}</pre> 91 + </details> 92 + `; 93 + 94 + const actions = []; 95 + if (safePrimaryLink) actions.push({ label: "Open Link", url: safePrimaryLink }); 96 + if (col.label === "news") actions.push({ label: "Open News Site", url: "https://news.aesthetic.computer" }); 97 + if (recordLink) actions.push({ label: "View Record", url: recordLink }); 98 + 99 + const seen = new Set(); 100 + const dedupedActions = actions.filter((action) => { 101 + if (seen.has(action.url)) return false; 102 + seen.add(action.url); 103 + return true; 104 + }); 105 + 106 + return { 107 + title: `${col.icon} ${title || col.label}`, 108 + subtitle: `${col.label} · ${ago || "recent"} · ${repoLabel || "unknown"}`, 109 + bodyHtml, 110 + iframeUrl, 111 + actions: dedupedActions, 112 + }; 113 + } 114 + 115 + global.ACMediaRecords = { 116 + buildFeedModalPayload, 117 + escapeHtml, 118 + normalizeLink, 119 + recordFallbackLink, 120 + }; 121 + })(window);
+4 -2
at/pds/scripts/README.md
··· 39 39 **Default file map:** 40 40 - `at/index.html -> /data/www/index.html` 41 41 - `at/user-page.html -> /data/www/user.html` 42 + - `at/media-modal.js -> /data/www/media-modal.js` 43 + - `at/media-records.js -> /data/www/media-records.js` 42 44 43 45 **Configurable via env:** 44 - - `AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/landing-page.html:landing-page.html"` 46 + - `AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js;at/landing-page.html:landing-page.html"` 45 47 46 48 **Typical server install (cron every minute):** 47 49 ```bash ··· 74 76 at/scripts/deploy-at-frontend.sh 75 77 76 78 # Optional custom map 77 - AT_FRONTEND_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/landing-page.html:landing-page.html" \ 79 + AT_FRONTEND_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js;at/landing-page.html:landing-page.html" \ 78 80 at/scripts/deploy-at-frontend.sh 79 81 ``` 80 82
+2 -2
at/pds/scripts/auto-sync-frontend.sh
··· 11 11 # AC_CONTAINER_WEBROOT="/data/www" 12 12 # AC_STATE_DIR="/var/lib/at-frontend-sync" 13 13 # AC_HEALTH_URL="https://at.aesthetic.computer/xrpc/_health" 14 - # AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html" 14 + # AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js" 15 15 # AC_FORCE="1" # force deploy even if SHA unchanged 16 16 # 17 17 # AC_FILE_MAP format: ··· 23 23 AC_CONTAINER_WEBROOT="${AC_CONTAINER_WEBROOT:-/data/www}" 24 24 AC_STATE_DIR="${AC_STATE_DIR:-/var/lib/at-frontend-sync}" 25 25 AC_HEALTH_URL="${AC_HEALTH_URL:-https://at.aesthetic.computer/xrpc/_health}" 26 - AC_FILE_MAP="${AC_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html}" 26 + AC_FILE_MAP="${AC_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js}" 27 27 AC_FORCE="${AC_FORCE:-0}" 28 28 29 29 RAW_BASE="https://raw.githubusercontent.com/${AC_REPO}"
+2 -2
at/scripts/deploy-at-frontend.sh
··· 10 10 # AT_PDS_SSH_KEY_PATH (default: ~/.ssh/aesthetic_pds) 11 11 # AT_PDS_CONTAINER (default: caddy) 12 12 # AT_PDS_CONTAINER_WEBROOT (default: /data/www) 13 - # AT_FRONTEND_FILE_MAP (default: at/index.html:index.html;at/user-page.html:user.html) 13 + # AT_FRONTEND_FILE_MAP (default: at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js) 14 14 # 15 15 # AT_FRONTEND_FILE_MAP format: 16 16 # "repo/source/path:container/target/path;repo/source2:path2" ··· 20 20 AT_PDS_SSH_KEY_PATH="${AT_PDS_SSH_KEY_PATH:-$HOME/.ssh/aesthetic_pds}" 21 21 AT_PDS_CONTAINER="${AT_PDS_CONTAINER:-caddy}" 22 22 AT_PDS_CONTAINER_WEBROOT="${AT_PDS_CONTAINER_WEBROOT:-/data/www}" 23 - AT_FRONTEND_FILE_MAP="${AT_FRONTEND_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html}" 23 + AT_FRONTEND_FILE_MAP="${AT_FRONTEND_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html;at/media-modal.js:media-modal.js;at/media-records.js:media-records.js}" 24 24 25 25 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 26 26 REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+17 -107
at/user-page.html
··· 363 363 color: rgb(180, 70, 135); 364 364 } 365 365 366 - /* Modal overlay for aesthetic.computer iframe */ 367 - .modal-overlay { 368 - display: none; 369 - position: fixed; 370 - top: 0; 371 - left: 0; 372 - right: 0; 373 - bottom: 0; 374 - background: rgba(0, 0, 0, 0.85); 375 - z-index: 9999; 376 - animation: fadeIn 0.2s ease-out; 377 - } 378 - 379 - .modal-overlay.active { 380 - display: flex; 381 - align-items: center; 382 - justify-content: center; 383 - padding: 60px 20px 20px; 384 - } 385 - 386 - .modal-content { 387 - width: min(90vw, calc((100vh - 100px) * 4 / 3)); 388 - max-width: 1200px; 389 - aspect-ratio: 4 / 3; 390 - background: black; 391 - border: 2px solid rgb(205, 92, 155); 392 - border-radius: 8px; 393 - position: relative; 394 - overflow: hidden; 395 - animation: scaleIn 0.3s ease-out; 396 - display: flex; 397 - flex-direction: column; 398 - } 399 - 400 - @media (max-width: 768px) { 401 - .modal-content { 402 - width: min(95vw, calc((100vh - 80px) * 4 / 3)); 403 - } 404 - } 405 - 406 - .modal-close { 407 - position: fixed; 408 - top: 10px; 409 - left: 50%; 410 - transform: translateX(-50%); 411 - background: rgb(205, 92, 155); 412 - color: white; 413 - border: none; 414 - padding: 0.5em 1em; 415 - font-family: monospace; 416 - font-size: 1em; 417 - cursor: pointer; 418 - border-radius: 4px; 419 - z-index: 10000; 420 - transition: background 0.2s; 421 - } 422 - 423 - .modal-close:hover { 424 - background: rgb(255, 120, 200); 425 - } 426 - 427 - .modal-iframe { 428 - width: 100%; 429 - height: 100%; 430 - border: none; 431 - border-radius: 8px; 432 - box-shadow: 0 8px 24px rgba(0,0,0,0.5); 433 - } 434 - 435 366 #pals { 436 367 position: fixed; 437 368 bottom: 5px; 438 369 right: 16px; 439 370 user-select: none; 440 - } @keyframes fadeIn { 441 - from { opacity: 0; } 442 - to { opacity: 1; } 443 - } 444 - 445 - @keyframes scaleIn { 446 - from { transform: scale(0.9); opacity: 0; } 447 - to { transform: scale(1); opacity: 1; } 448 371 } 449 372 450 373 /* Preview box scrolling animation */ ··· 642 565 </svg> 643 566 </div> 644 567 645 - <!-- Modal for aesthetic.computer iframe --> 646 - <div id="ac-modal" class="modal-overlay"> 647 - <div class="modal-content"> 648 - <button class="modal-close" onclick="closeModal()">✕ Close</button> 649 - <iframe id="ac-iframe" class="modal-iframe" src=""></iframe> 650 - </div> 651 - </div> 652 - 568 + <script src="/media-modal.js"></script> 653 569 <script> 654 570 // Determine base URLs based on current environment 655 571 function getBaseURL() { ··· 675 591 676 592 console.log('🌐 API Base URL:', API_BASE_URL); 677 593 678 - // Modal functions 594 + // Shared modal functions 679 595 function openModal(url) { 680 - const modal = document.getElementById('ac-modal'); 681 - const iframe = document.getElementById('ac-iframe'); 682 - iframe.src = url; 683 - modal.classList.add('active'); 684 - document.body.style.overflow = 'hidden'; 596 + if (!url) return; 597 + if (!window.ACMediaModal?.open) { 598 + window.open(url, '_blank', 'noopener,noreferrer'); 599 + return; 600 + } 601 + window.ACMediaModal.open({ 602 + title: 'Aesthetic Computer', 603 + subtitle: 'Embedded view', 604 + iframeUrl: url, 605 + bodyHtml: '', 606 + actions: [{ label: 'Open In New Tab', url }], 607 + }); 685 608 } 686 609 687 610 function closeModal() { 688 - const modal = document.getElementById('ac-modal'); 689 - const iframe = document.getElementById('ac-iframe'); 690 - modal.classList.remove('active'); 691 - iframe.src = ''; 692 - document.body.style.overflow = ''; 611 + if (!window.ACMediaModal?.close) return; 612 + window.ACMediaModal.close(); 693 613 } 694 - 695 - // Close modal on escape key 696 - document.addEventListener('keydown', (e) => { 697 - if (e.key === 'Escape') closeModal(); 698 - }); 699 - 700 - // Close modal on background click 701 - document.getElementById('ac-modal').addEventListener('click', (e) => { 702 - if (e.target.id === 'ac-modal') closeModal(); 703 - }); 704 614 705 615 // Intercept aesthetic.computer and pdsls.dev links (left-click only) 706 616 document.addEventListener('click', (e) => { ··· 2218 2128 init(); 2219 2129 </script> 2220 2130 </body> 2221 - </html> 2131 + </html>