Monorepo for Aesthetic.Computer
aesthetic.computer
1<!--
2ATProto User Page for *.at.aesthetic.computer
3Shows all records for a specific user's handle
4Uses ONLY ATProto APIs - no aesthetic.computer backend dependencies
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 id="page-title">Loading...</title>
12 <meta name="description" content="Personal ATProto data page">
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 <script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
16
17 <style>
18 * {
19 box-sizing: border-box;
20 }
21
22 ::-webkit-scrollbar {
23 display: none;
24 }
25
26 body {
27 margin: 0;
28 font-size: 14px;
29 font-family: monospace;
30 -webkit-text-size-adjust: none;
31 background: #f5f5f5;
32 color: #000;
33 line-height: 1.4;
34 }
35
36 .container {
37 max-width: 1400px;
38 margin: 0 auto;
39 padding: 1em 0.5em;
40 }
41
42 header {
43 text-align: center;
44 padding: 1em 0;
45 border-bottom: 2px solid rgb(205, 92, 155);
46 margin-bottom: 1em;
47 }
48
49 h1 {
50 font-size: 1.5em;
51 font-weight: normal;
52 margin: 0 0 0.3em 0;
53 color: rgb(205, 92, 155);
54 }
55
56 .subtitle {
57 font-size: 0.85em;
58 opacity: 0.7;
59 margin: 0.3em 0;
60 }
61
62 .stats {
63 display: flex;
64 gap: 1em;
65 justify-content: center;
66 flex-wrap: wrap;
67 margin: 0.5em 0;
68 font-size: 0.75em;
69 }
70
71 .stat {
72 padding: 0.3em 0.6em;
73 background: rgba(205, 92, 155, 0.1);
74 border-radius: 3px;
75 }
76
77 .stat strong {
78 color: rgb(205, 92, 155);
79 }
80
81 .loading {
82 text-align: center;
83 padding: 4em 2em;
84 font-size: 1.2em;
85 opacity: 0.6;
86 }
87
88 .error {
89 text-align: center;
90 padding: 4em 2em;
91 color: #d32f2f;
92 font-size: 1.1em;
93 }
94
95 .section {
96 margin: 1.5em 0;
97 }
98
99 .section-title {
100 font-size: 1.2em;
101 font-weight: normal;
102 margin: 0 0 0.5em 0;
103 padding-bottom: 0.3em;
104 border-bottom: 1px solid rgba(205, 92, 155, 0.3);
105 display: flex;
106 justify-content: space-between;
107 align-items: center;
108 }
109
110 .section-count {
111 font-size: 0.7em;
112 color: rgb(205, 92, 155);
113 font-weight: bold;
114 }
115
116 .records-grid {
117 display: grid;
118 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
119 gap: 0.5em;
120 }
121
122 .record-card {
123 background: white;
124 padding: 0.75em;
125 border-radius: 4px;
126 box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
127 transition: transform 0.2s, box-shadow 0.2s;
128 }
129
130 .record-card:hover {
131 transform: translateY(-1px);
132 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
133 }
134
135 .kidlisp-preview-card {
136 position: relative;
137 border-radius: 8px;
138 overflow: hidden;
139 background: #0d0d1a;
140 aspect-ratio: 1;
141 display: flex;
142 align-items: stretch;
143 justify-content: stretch;
144 margin: 0.5em 0;
145 }
146
147 .kidlisp-preview-card img.kidlisp-webp {
148 width: 100%;
149 height: 100%;
150 object-fit: cover;
151 image-rendering: pixelated;
152 opacity: 0.6;
153 transform: scale(1.05);
154 }
155
156 .kidlisp-preview-overlay {
157 position: absolute;
158 inset: 0;
159 padding: 0.7em 0.8em;
160 color: #fff;
161 font-family: monospace;
162 font-size: 0.75em;
163 line-height: 1.35;
164 text-shadow: 0 1px 2px rgba(0,0,0,0.9);
165 pointer-events: none;
166 display: flex;
167 flex-direction: column;
168 justify-content: space-between;
169 background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.5) 80%);
170 }
171
172 .kidlisp-preview-code {
173 overflow: hidden;
174 max-height: 60%;
175 white-space: pre-wrap;
176 word-break: break-word;
177 }
178
179 .kidlisp-preview-qr {
180 align-self: flex-end;
181 display: flex;
182 flex-direction: column;
183 align-items: flex-end;
184 line-height: 0;
185 }
186
187 .kidlisp-preview-qr img {
188 display: block;
189 image-rendering: pixelated;
190 width: 44px;
191 height: 44px;
192 background: #fff;
193 padding: 2px;
194 }
195
196 .kidlisp-preview-label {
197 font-family: monospace;
198 font-size: 1em;
199 color: #fff;
200 background: #000;
201 padding: 0.15em 0.4em;
202 margin-bottom: 4px;
203 line-height: 1;
204 }
205
206 .record-header {
207 display: flex;
208 justify-content: space-between;
209 align-items: start;
210 margin-bottom: 0.5em;
211 gap: 0.5em;
212 }
213
214 .lexicon-badge {
215 font-size: 0.65em;
216 padding: 0.3em 0.6em;
217 background: transparent;
218 border: none;
219 color: rgb(150, 150, 150);
220 border-radius: 3px;
221 cursor: pointer;
222 transition: all 0.2s;
223 white-space: nowrap;
224 }
225
226 .lexicon-badge:hover {
227 color: rgb(200, 200, 200);
228 text-decoration: underline;
229 }
230
231 .color-cycle-a {
232 animation: colorCycleA 1.8s steps(1) infinite;
233 }
234
235 .color-cycle-r {
236 animation: colorCycleR 1.8s steps(1) infinite;
237 }
238
239 .color-cycle-t {
240 animation: colorCycleT 1.8s steps(1) infinite;
241 }
242
243 @keyframes colorCycleA {
244 0% { color: rgb(205, 92, 155); }
245 16.6% { color: rgb(255, 120, 100); }
246 33.3% { color: rgb(255, 200, 100); }
247 50% { color: rgb(150, 230, 150); }
248 66.6% { color: rgb(100, 180, 255); }
249 83.3% { color: rgb(180, 130, 230); }
250 100% { color: rgb(205, 92, 155); }
251 }
252
253 @keyframes colorCycleR {
254 0% { color: rgb(255, 120, 100); }
255 16.6% { color: rgb(255, 200, 100); }
256 33.3% { color: rgb(150, 230, 150); }
257 50% { color: rgb(100, 180, 255); }
258 66.6% { color: rgb(180, 130, 230); }
259 83.3% { color: rgb(205, 92, 155); }
260 100% { color: rgb(255, 120, 100); }
261 }
262
263 @keyframes colorCycleT {
264 0% { color: rgb(255, 200, 100); }
265 16.6% { color: rgb(150, 230, 150); }
266 33.3% { color: rgb(100, 180, 255); }
267 50% { color: rgb(180, 130, 230); }
268 66.6% { color: rgb(205, 92, 155); }
269 83.3% { color: rgb(255, 120, 100); }
270 100% { color: rgb(255, 200, 100); }
271 }
272
273 /* Generic color cycle for handle characters */
274 .color-cycle {
275 animation: colorCycleGeneric 1.8s steps(1) infinite;
276 }
277
278 @keyframes colorCycleGeneric {
279 0% { color: rgb(205, 92, 155); }
280 16.6% { color: rgb(255, 120, 100); }
281 33.3% { color: rgb(255, 200, 100); }
282 50% { color: rgb(150, 230, 150); }
283 66.6% { color: rgb(100, 180, 255); }
284 83.3% { color: rgb(180, 130, 230); }
285 100% { color: rgb(205, 92, 155); }
286 }
287
288 .record-type {
289 font-size: 0.65em;
290 padding: 0.2em 0.5em;
291 background: rgb(205, 92, 155);
292 color: white;
293 border-radius: 2px;
294 text-transform: uppercase;
295 letter-spacing: 0.05em;
296 }
297
298 .record-date {
299 font-size: 0.7em;
300 opacity: 0.6;
301 white-space: nowrap;
302 }
303
304 .record-content {
305 margin: 0.5em 0;
306 }
307
308 .record-image {
309 width: 100%;
310 max-width: 200px;
311 margin: 0.5em 0;
312 border-radius: 2px;
313 border: none;
314 image-rendering: pixelated;
315 image-rendering: crisp-edges;
316 }
317
318 .record-text {
319 font-size: 0.85em;
320 line-height: 1.3;
321 white-space: pre-wrap;
322 word-wrap: break-word;
323 }
324
325 .record-meta {
326 margin-top: 0.5em;
327 padding-top: 0.5em;
328 border-top: 1px solid #e0e0e0;
329 font-size: 0.7em;
330 opacity: 0.7;
331 display: flex;
332 gap: 0.5em;
333 flex-wrap: wrap;
334 }
335
336 .record-link {
337 color: rgb(205, 92, 155);
338 text-decoration: none;
339 font-size: 0.75em;
340 display: inline-block;
341 margin-top: 0.3em;
342 transition: color 0.2s;
343 }
344
345 .record-link:hover {
346 text-decoration: underline;
347 color: rgb(255, 120, 200);
348 }
349
350 /* Style all links */
351 a {
352 color: rgb(205, 92, 155);
353 text-decoration: none;
354 transition: all 0.2s;
355 }
356
357 a:hover {
358 color: rgb(255, 120, 200);
359 text-decoration: underline;
360 }
361
362 a:visited {
363 color: rgb(180, 70, 135);
364 }
365
366 #pals {
367 position: fixed;
368 bottom: 5px;
369 right: 16px;
370 user-select: none;
371 }
372
373 /* Preview box scrolling animation */
374 .source-preview {
375 position: relative;
376 max-height: 120px;
377 overflow: hidden;
378 background: linear-gradient(135deg, rgba(205, 92, 155, 0.05), rgba(100, 180, 255, 0.05));
379 border: 1px solid rgba(205, 92, 155, 0.2);
380 }
381
382 .source-preview:hover .preview-scroll-container {
383 animation-play-state: paused !important;
384 }
385
386 .preview-scroll-container {
387 display: flex;
388 animation: previewScrollLoop 40s linear infinite;
389 will-change: transform;
390 }
391
392 .line-numbers {
393 flex-shrink: 0;
394 width: 3em;
395 text-align: right;
396 padding-right: 0.5em;
397 user-select: none;
398 font-size: 0.9em;
399 line-height: inherit;
400 background: rgba(0, 0, 0, 0.1);
401 border-right: 1px solid rgba(0, 0, 0, 0.15);
402 }
403
404 .line-numbers div {
405 padding: 0.1em 0;
406 }
407
408 /* Striped color palette for line numbers */
409 .line-numbers div:nth-child(6n+1) { color: rgb(205, 92, 155); opacity: 0.8; }
410 .line-numbers div:nth-child(6n+2) { color: rgb(255, 120, 100); opacity: 0.8; }
411 .line-numbers div:nth-child(6n+3) { color: rgb(255, 200, 100); opacity: 0.8; }
412 .line-numbers div:nth-child(6n+4) { color: rgb(150, 230, 150); opacity: 0.8; }
413 .line-numbers div:nth-child(6n+5) { color: rgb(100, 180, 255); opacity: 0.8; }
414 .line-numbers div:nth-child(6n+6) { color: rgb(180, 130, 230); opacity: 0.8; }
415
416 .preview-content {
417 display: block;
418 flex: 1;
419 padding-left: 0.5em;
420 }
421
422 /* Infinite scroll - content is duplicated so it wraps seamlessly */
423 @keyframes previewScrollLoop {
424 0% {
425 transform: translateY(0);
426 }
427 100% {
428 transform: translateY(-50%);
429 }
430 }
431
432 .empty-state {
433 text-align: center;
434 padding: 2em 1em;
435 opacity: 0.5;
436 font-style: italic;
437 }
438
439 .tabs {
440 display: flex;
441 gap: 0.3em;
442 margin-bottom: 1em;
443 border-bottom: 1px solid #e0e0e0;
444 flex-wrap: wrap;
445 }
446
447 .tab {
448 padding: 0.5em 1em;
449 background: none;
450 border: none;
451 font-family: monospace;
452 font-size: 0.85em;
453 cursor: pointer;
454 border-bottom: 2px solid transparent;
455 transition: all 0.2s;
456 color: #666;
457 }
458
459 .tab:hover {
460 color: rgb(205, 92, 155);
461 background: rgba(205, 92, 155, 0.05);
462 }
463
464 .tab.active {
465 color: rgb(205, 92, 155);
466 border-bottom-color: rgb(205, 92, 155);
467 font-weight: bold;
468 }
469
470 .tab.disabled {
471 opacity: 0.3;
472 cursor: not-allowed;
473 }
474
475 #scroll-top:hover {
476 transform: translateY(-2px);
477 box-shadow: 0 6px 16px rgba(0,0,0,0.4);
478 }
479
480 #scroll-top:active {
481 transform: translateY(0);
482 }
483
484 @media (prefers-color-scheme: dark) {
485 body {
486 background: rgb(40, 35, 45);
487 color: rgba(255, 255, 255, 0.9);
488 }
489
490 .record-card {
491 background: rgba(255, 255, 255, 0.05);
492 color: rgba(255, 255, 255, 0.9);
493 }
494
495 .stat {
496 background: rgba(205, 92, 155, 0.2);
497 }
498
499 .tabs {
500 border-bottom-color: rgba(255, 255, 255, 0.1);
501 }
502
503 .tab {
504 color: rgba(255, 255, 255, 0.6);
505 }
506
507 .record-meta {
508 border-top-color: rgba(255, 255, 255, 0.1);
509 }
510 }
511
512 @media (max-width: 600px) {
513 body {
514 font-size: 16px;
515 }
516
517 h1 {
518 font-size: 1.5em;
519 }
520
521 .stats {
522 flex-direction: column;
523 align-items: center;
524 }
525
526 .tab {
527 padding: 0.6em 1em;
528 }
529 }
530 </style>
531</head>
532
533<body>
534 <div class="container">
535 <header>
536 <a href="https://at.aesthetic.computer" style="text-decoration: none; color: inherit;"><div style="font-size: 0.75em; color: rgb(205, 92, 155); margin-bottom: 0.3em;">← at.aesthetic.computer</div></a>
537 <h1 id="handle-display">Loading...</h1>
538 <div class="subtitle" id="did-display"></div>
539 <div class="stats" id="stats"></div>
540 </header>
541
542 <div id="loading" class="loading">
543 🔍 Loading ATProto records...
544 </div>
545
546 <div id="error" class="error" style="display: none;"></div>
547
548 <div id="content" style="display: none;">
549 <div class="tabs" id="tabs"></div>
550 <div id="records-container"></div>
551 </div>
552
553 <!-- Floating AC logo -->
554 <svg
555 id="pals"
556 width="64"
557 height="64"
558 viewBox="0 0 24 24"
559 fill="none"
560 xmlns="http://www.w3.org/2000/svg"
561 onclick="window.scrollTo({ top: 0, behavior: 'smooth' })"
562 style="cursor: pointer;"
563 >
564 <path fill-rule="evenodd" clip-rule="evenodd" d="M14.8982 5.10335C15.5333 4.92226 16.0802 4.97918 16.6196 5.27392L16.6294 5.27925L16.6386 5.28543C16.8852 5.45034 17.0637 5.6336 17.1768 5.8569C17.2893 6.07886 17.3264 6.31921 17.3264 6.57986C17.3264 6.8465 17.3041 7.09444 17.2269 7.3334C17.2091 7.38846 17.1886 7.44241 17.1652 7.4955C17.23 7.47358 17.2711 7.4571 17.2859 7.44936C17.3941 7.3926 17.5907 7.24475 17.8166 7.06782C17.8604 7.03348 17.9051 6.99825 17.9497 6.96304C18.1213 6.82774 18.2924 6.69283 18.4123 6.61077C18.6212 6.46789 18.9896 6.23185 19.3662 6.01043C19.7366 5.79269 20.1374 5.57551 20.4022 5.48361C20.7689 5.35632 21.2081 5.38009 21.5334 5.72086C21.7339 5.93084 21.8023 6.15795 21.7913 6.36941C21.7808 6.57 21.7001 6.73725 21.6399 6.84298L21.6299 6.86056L21.6171 6.87629C21.4157 7.12388 21.1869 7.28577 20.956 7.41907C20.851 7.47973 20.743 7.53584 20.6386 7.59007L20.6124 7.60369C20.4982 7.66307 20.3871 7.72144 20.2759 7.78716C20.0167 7.94039 19.4561 8.36643 19.1861 8.59807C18.854 8.88299 18.5291 9.22697 18.2969 9.64591C18.2562 9.71926 18.2357 9.85967 18.246 10.0459C18.2558 10.2216 18.2902 10.397 18.3192 10.5068C18.3474 10.6137 18.369 10.7073 18.39 10.7987C18.4496 11.0574 18.5052 11.2984 18.696 11.7739C18.8086 12.0543 18.9341 12.2641 19.0783 12.5052C19.1295 12.5907 19.183 12.6802 19.2391 12.7781C19.2555 12.8067 19.2708 12.8346 19.2856 12.8619C19.3428 12.9667 19.3941 13.0606 19.4805 13.1372C19.5653 13.2123 19.6973 13.2788 19.9584 13.218L19.9665 13.2161L19.9748 13.2147C20.347 13.1537 20.6475 13.0147 20.955 12.8725C20.9664 12.8672 20.9778 12.862 20.9891 12.8567C21.298 12.714 21.636 12.5602 22.0258 12.5602C22.3606 12.5602 22.6158 12.7005 22.7802 12.9167C22.9376 13.1238 23 13.384 23 13.6229C23 13.9455 22.7996 14.1926 22.6034 14.3582C22.4028 14.5276 22.1652 14.6481 21.9994 14.718C21.48 14.9369 20.4859 15.2891 19.7384 15.3685C19.7042 15.3721 19.661 15.3789 19.6105 15.3867C19.1991 15.4509 18.3 15.591 17.7518 14.7545C17.6407 14.585 17.4006 14.2594 17.1498 13.9515C17.0248 13.7981 16.8997 13.6521 16.7888 13.5341C16.6729 13.4106 16.5881 13.3346 16.5416 13.305C16.38 13.2022 16.262 13.2217 16.1713 13.2721C16.0628 13.3325 15.9743 13.4514 15.9373 13.5606C15.8308 13.8749 15.691 14.2874 15.5776 14.6879C15.4273 15.2186 15.2045 16.0282 15.0393 16.6782C15.0149 16.7741 14.9905 16.8863 14.963 17.0127C14.957 17.0402 14.9509 17.0685 14.9445 17.0974C14.9098 17.2562 14.8705 17.4305 14.8234 17.6033C14.7321 17.9384 14.6013 18.3097 14.3836 18.556C14.0202 18.9669 13.4846 19.0727 13.0429 18.9548C12.6009 18.8368 12.2125 18.4785 12.2125 17.943C12.2125 17.6301 12.3162 17.124 12.4343 16.6504C12.5535 16.1718 12.6958 15.6946 12.7899 15.416C12.9757 14.749 13.2116 13.8949 13.3866 13.1212C13.3979 13.0613 13.4099 12.9976 13.4226 12.9309C13.4978 12.5343 13.5932 12.031 13.6699 11.5717C13.7149 11.3024 13.7529 11.0514 13.7766 10.8478C13.8015 10.633 13.8065 10.5012 13.7992 10.4515C13.7844 10.3497 13.751 10.315 13.729 10.2987C13.699 10.2766 13.6441 10.2559 13.5426 10.2494C13.4417 10.2429 13.3238 10.2517 13.1852 10.2649C13.1692 10.2664 13.1529 10.268 13.1363 10.2696C13.0172 10.2811 12.8839 10.294 12.7597 10.294C12.6223 10.294 12.472 10.2977 12.3149 10.3015C11.9756 10.3098 11.605 10.3188 11.2661 10.2933C11.1149 10.2819 10.9352 10.258 10.7544 10.2337L10.7295 10.2304C10.5535 10.2067 10.3742 10.1825 10.2029 10.1659C10.0225 10.1485 9.86134 10.1404 9.7317 10.1484C9.59167 10.157 9.53289 10.1823 9.51826 10.1932C9.40563 10.2772 9.36293 10.3344 9.34618 10.3683C9.33318 10.3946 9.32868 10.4202 9.3367 10.4673C9.34595 10.5217 9.36711 10.5816 9.40163 10.6792C9.40461 10.6877 9.40769 10.6964 9.41087 10.7054C9.44816 10.8111 9.49265 10.9424 9.52178 11.0999C9.55533 11.2814 9.5886 11.4647 9.62198 11.6487C9.7153 12.1629 9.80952 12.6821 9.91334 13.1798C9.9259 13.24 9.93878 13.3014 9.95186 13.3637C10.1143 14.1372 10.3081 15.0602 10.3081 15.8244C10.3081 15.9889 10.3121 16.1554 10.3162 16.3235C10.3297 16.8792 10.3436 17.4525 10.2136 18.0277C10.0889 18.5797 9.55124 18.8059 9.10572 18.8008C8.66723 18.7957 8.13248 18.5567 8.08837 17.9935C8.06444 17.6878 8.09368 17.4281 8.12633 17.2004C8.13244 17.1578 8.13853 17.1171 8.1444 17.0777C8.17087 16.9005 8.19298 16.7524 8.19298 16.5993C8.19298 15.8848 8.00978 15.083 7.83775 14.4909C7.75439 14.204 7.63364 14.1146 7.55668 14.0888C7.47435 14.0612 7.35495 14.0767 7.21784 14.1711C7.12371 14.2358 7.02 14.3956 6.91136 14.6516C6.83797 14.8246 6.77436 15.01 6.71076 15.1953C6.68322 15.2755 6.65569 15.3557 6.62737 15.4349C6.44732 15.9385 6.18242 16.5995 5.96374 17.0833C5.90321 17.2173 5.85247 17.3496 5.80243 17.4829C5.79639 17.4991 5.79033 17.5152 5.78426 17.5315C5.74091 17.6474 5.69659 17.7658 5.64795 17.8792C5.53571 18.1408 5.39094 18.3993 5.13767 18.6146L5.12889 18.6221L5.11943 18.6287C4.88967 18.7898 4.54244 18.8806 4.21559 18.8642C3.88604 18.8477 3.51569 18.7163 3.3221 18.3609C3.22342 18.1798 3.20996 17.9754 3.22908 17.7902C3.24839 17.6032 3.3033 17.4128 3.36603 17.2414C3.42919 17.0687 3.50363 16.9062 3.56667 16.7745C3.58947 16.7269 3.60969 16.6854 3.62724 16.6494C3.6617 16.5786 3.68592 16.5289 3.69936 16.495C3.8768 16.0476 4.01924 15.64 4.17636 15.1904C4.26827 14.9274 4.3652 14.65 4.47711 14.3419C4.60715 13.9838 4.95824 12.8655 5.14491 12.0888C5.19511 11.8799 5.24781 11.7216 5.28981 11.5955C5.30443 11.5516 5.31775 11.5115 5.32922 11.4746C5.37289 11.3342 5.40057 11.2104 5.40057 11.0093C5.40057 10.9784 5.3803 10.9448 5.35211 10.9295C5.34057 10.9232 5.32949 10.921 5.31788 10.9221C5.30669 10.9232 5.28313 10.9285 5.24908 10.9554C5.15797 11.0275 5.02885 11.1337 4.88463 11.2523C4.62733 11.4639 4.32194 11.715 4.09846 11.8826C3.78743 12.1158 3.44918 12.4103 3.10959 12.706C2.99451 12.8062 2.87926 12.9066 2.76487 13.0047C2.58673 13.1575 2.3447 13.326 2.06932 13.376C1.92662 13.4019 1.77378 13.3961 1.62075 13.3392C1.46845 13.2826 1.33057 13.1809 1.20826 13.0366C0.991479 12.781 0.95847 12.4944 1.04269 12.2282C1.12188 11.9778 1.30054 11.7539 1.49196 11.5725C1.60625 11.4642 1.75653 11.3228 1.92542 11.164C2.46962 10.6521 3.20715 9.9583 3.55739 9.60275C3.70303 9.4549 3.82293 9.3268 3.93164 9.21066C4.21275 8.91035 4.41901 8.68999 4.80176 8.415C4.86144 8.35575 4.92154 8.30978 4.97317 8.27381C4.96626 8.26895 4.95902 8.26391 4.95145 8.2587C4.53475 7.97188 4.10253 7.21461 4.52248 6.34306C4.68856 5.9984 4.89668 5.74448 5.14374 5.56752C5.39101 5.39041 5.6638 5.30006 5.94419 5.26279C6.4907 5.19016 7.04612 5.30816 7.42088 5.58929C7.72296 5.8159 7.96946 6.15062 8.06084 6.52803C8.16526 6.95934 8.07218 7.30168 7.98025 7.53605C7.96274 7.58069 7.94515 7.6217 7.92956 7.65735C8.38866 7.7876 8.68645 7.8138 9.01858 7.84303C9.0943 7.8497 9.17182 7.85652 9.25343 7.86477C9.31219 7.8707 9.39816 7.87955 9.50086 7.89011C9.89664 7.93083 10.5409 7.99711 10.8338 8.02111C11.262 8.05621 12.0343 8.11934 12.7225 8.02302C13.095 7.96395 13.3919 7.92769 13.6272 7.90177C13.7148 7.89213 13.7918 7.8841 13.8602 7.87697C13.9759 7.86491 14.067 7.85542 14.1427 7.84501C14.088 7.79376 14.027 7.72972 13.9687 7.65108C13.7716 7.38488 13.6315 6.98552 13.759 6.39462C13.8933 5.77205 14.2887 5.27713 14.8982 5.10335ZM14.3079 7.98743C14.3079 7.98742 14.3077 7.98718 14.3072 7.98672C14.3077 7.9872 14.3079 7.98743 14.3079 7.98743ZM16.3689 5.69496C15.9552 5.4716 15.5479 5.42594 15.0363 5.57182C14.6356 5.68607 14.3482 6.01348 14.2442 6.49583C14.1451 6.9551 14.2576 7.21261 14.3697 7.36393C14.4296 7.44486 14.4963 7.50454 14.5562 7.55437C14.562 7.55923 14.5685 7.56462 14.5755 7.57035C14.5985 7.58926 14.6259 7.61178 14.6458 7.63026C14.6596 7.64302 14.6809 7.66378 14.7001 7.69016C14.7164 7.7125 14.7547 7.77006 14.7547 7.85171C14.7547 7.96533 14.7273 8.10948 14.587 8.20991C14.482 8.28514 14.3415 8.30979 14.2213 8.32669C14.1353 8.33878 14.0269 8.3501 13.8989 8.36346C13.8319 8.37045 13.7596 8.37799 13.6824 8.3865C13.4524 8.41184 13.163 8.44719 12.7992 8.50491L12.797 8.50526L12.7948 8.50558C12.0497 8.61026 11.2306 8.5431 10.8053 8.50823L10.7926 8.50719C10.4937 8.48269 9.83874 8.4153 9.4439 8.37468C9.34318 8.36432 9.25938 8.35569 9.20274 8.34997C9.12713 8.34233 9.05328 8.33587 8.97943 8.32941C8.59427 8.29573 8.20917 8.26205 7.57537 8.06072L7.54617 8.05145L7.52016 8.03546C7.41937 7.97351 7.37992 7.87416 7.37749 7.78687C7.37557 7.71796 7.39598 7.6556 7.40927 7.6188C7.4236 7.57911 7.44274 7.5356 7.45971 7.49703L7.46158 7.4928C7.48022 7.45041 7.49907 7.4075 7.5175 7.36051C7.58929 7.1775 7.65106 6.94132 7.57835 6.641C7.51721 6.38847 7.34484 6.14571 7.12007 5.9771C6.8714 5.79055 6.45604 5.68696 6.01061 5.74616C5.79502 5.77481 5.60406 5.84123 5.43573 5.96179C5.26721 6.0825 5.10792 6.26713 4.97069 6.55193C4.67103 7.17382 4.98354 7.68542 5.23585 7.85909C5.34648 7.93524 5.44869 8.01423 5.50733 8.10184C5.54069 8.1517 5.57312 8.22342 5.56708 8.3111C5.56098 8.39945 5.51857 8.46412 5.48267 8.50442C5.44885 8.54238 5.40977 8.57112 5.38263 8.59003C5.36351 8.60336 5.34006 8.61864 5.31952 8.63202C5.31133 8.63736 5.30361 8.64239 5.2968 8.64688C5.24196 8.68306 5.19124 8.71952 5.14493 8.76759L5.12909 8.78404L5.11044 8.79733C4.75876 9.04799 4.5912 9.22704 4.32176 9.51494C4.21003 9.63433 4.08078 9.77243 3.91362 9.94213C3.55595 10.3052 2.80487 11.0117 2.26019 11.5241C2.09389 11.6805 1.94683 11.8188 1.83608 11.9238L1.83607 11.9238C1.67225 12.079 1.56004 12.2347 1.51628 12.373C1.47755 12.4955 1.4895 12.6067 1.58913 12.7242L1.58914 12.7242C1.66719 12.8163 1.73782 12.8613 1.79609 12.8829C1.85363 12.9043 1.91343 12.9083 1.97935 12.8963C2.12123 12.8706 2.28041 12.7731 2.43882 12.6372C2.5461 12.5451 2.6567 12.4488 2.76895 12.3511C3.11216 12.0522 3.47086 11.7398 3.79775 11.4947C4.01563 11.3313 4.29585 11.1007 4.54485 10.8957C4.69383 10.7731 4.83163 10.6597 4.93822 10.5753C5.14798 10.4094 5.39517 10.3956 5.59199 10.5026C5.77395 10.6014 5.89654 10.7962 5.89654 11.0093C5.89654 11.2684 5.85837 11.4408 5.80353 11.6172C5.78813 11.6668 5.77232 11.7142 5.75615 11.7628C5.71555 11.8848 5.67258 12.0138 5.62758 12.201C5.4365 12.9961 5.0801 14.1317 4.94419 14.5059C4.83695 14.8012 4.7417 15.0737 4.65024 15.3353C4.49033 15.7927 4.34202 16.2169 4.16143 16.6723C4.14095 16.7239 4.10332 16.8012 4.06288 16.8842C4.0472 16.9164 4.0311 16.9495 4.01541 16.9823C3.95473 17.109 3.88802 17.2553 3.83273 17.4065C3.77699 17.5588 3.73614 17.7075 3.72252 17.8395C3.7087 17.9733 3.72513 18.0679 3.75929 18.1306C3.84089 18.2804 4.01052 18.3656 4.24086 18.3771C4.46841 18.3885 4.69552 18.3225 4.82254 18.2377C4.98849 18.0935 5.09436 17.9148 5.19099 17.6896C5.23478 17.5875 5.27479 17.4806 5.31861 17.3635C5.3247 17.3472 5.33086 17.3308 5.33712 17.3141C5.38755 17.1797 5.44298 17.0347 5.51051 16.8852C5.72338 16.4142 5.98345 15.7655 6.15948 15.2732C6.18339 15.2063 6.20838 15.1335 6.23446 15.0575C6.30034 14.8656 6.37323 14.6533 6.45363 14.4638C6.56239 14.2075 6.71075 13.9247 6.93342 13.7715C7.15058 13.622 7.43534 13.5329 7.71667 13.6272C8.00337 13.7232 8.20577 13.9822 8.31465 14.3569C8.49077 14.9631 8.68895 15.8166 8.68895 16.5993C8.68895 16.7915 8.66032 16.9819 8.63345 17.1605C8.62795 17.197 8.62253 17.2331 8.61745 17.2685C8.5865 17.4843 8.56307 17.703 8.58288 17.956C8.5982 18.1516 8.79173 18.3094 9.11151 18.313C9.42426 18.3166 9.67486 18.1635 9.72945 17.9218C9.84509 17.4101 9.83345 16.9177 9.82064 16.376C9.81644 16.1982 9.81212 16.0152 9.81212 15.8244C9.81212 15.1129 9.62868 14.2379 9.46366 13.4507C9.45148 13.3926 9.43939 13.3349 9.42748 13.2778C9.32227 12.7734 9.22637 12.245 9.13272 11.7289C9.09957 11.5463 9.0667 11.3651 9.0338 11.1872C9.01181 11.0682 8.97786 10.9661 8.94228 10.8652C8.93868 10.855 8.93495 10.8445 8.93114 10.8339C8.90026 10.7471 8.8642 10.6458 8.84752 10.5478C8.82676 10.4258 8.83197 10.2929 8.90009 10.1551C8.96445 10.0248 9.07427 9.9122 9.21849 9.8046C9.35612 9.70192 9.54184 9.6714 9.70072 9.66162C9.86998 9.6512 10.0621 9.66217 10.2516 9.68053C10.4325 9.69806 10.6203 9.72334 10.7939 9.74672L10.8216 9.75045C11.006 9.77525 11.1703 9.79689 11.3039 9.80695C11.6184 9.83063 11.9425 9.82254 12.2673 9.81444C12.4316 9.81035 12.5961 9.80624 12.7597 9.80624C12.8578 9.80624 12.9647 9.79597 13.0871 9.7842C13.1036 9.78261 13.1205 9.78099 13.1376 9.77937C13.2736 9.76649 13.4288 9.75328 13.5749 9.76267C13.7205 9.77202 13.8865 9.80514 14.0267 9.90866C14.1749 10.0181 14.2609 10.1809 14.2902 10.3825C14.308 10.5047 14.2931 10.6992 14.2693 10.9032C14.2443 11.1184 14.2048 11.3785 14.1593 11.6507C14.0817 12.1157 13.9851 12.625 13.91 13.0213C13.8971 13.0893 13.8848 13.1539 13.8734 13.2144L13.8726 13.2187L13.8717 13.2228C13.693 14.0134 13.4526 14.8832 13.2665 15.5512L13.2647 15.5576L13.2626 15.5639C13.1732 15.8277 13.0333 16.2957 12.916 16.7665C12.7965 17.2461 12.7085 17.6974 12.7085 17.943C12.7085 18.2088 12.894 18.4096 13.1729 18.4841C13.4521 18.5586 13.7844 18.4902 14.0093 18.2359C14.1458 18.0815 14.2541 17.8083 14.3444 17.477C14.3881 17.3167 14.4252 17.1524 14.4596 16.9949C14.4656 16.9678 14.4714 16.9407 14.4773 16.9139C14.5048 16.7873 14.5314 16.6648 14.5581 16.5599C14.7248 15.904 14.9488 15.0899 15.0998 14.557C15.2169 14.1438 15.3601 13.721 15.4661 13.4085L15.4668 13.4064C15.5361 13.202 15.695 12.9767 15.9271 12.8476C16.1771 12.7085 16.4931 12.6932 16.811 12.8955C16.9151 12.9617 17.0368 13.0792 17.1532 13.2032C17.2747 13.3326 17.4078 13.4881 17.5368 13.6466C17.7942 13.9624 18.0454 14.3021 18.1687 14.4903C18.529 15.0401 19.0678 14.9663 19.5043 14.9065C19.567 14.898 19.6276 14.8897 19.6852 14.8836C20.3572 14.8122 21.2962 14.4837 21.8041 14.2697C21.9448 14.2104 22.1323 14.1132 22.2803 13.9882C22.4326 13.8596 22.504 13.7357 22.504 13.6229C22.504 13.4595 22.4602 13.3104 22.3829 13.2087C22.3125 13.1161 22.2047 13.048 22.0258 13.048C21.7624 13.048 21.5204 13.1501 21.2 13.2982C21.1848 13.3052 21.1693 13.3124 21.1538 13.3196C20.8554 13.4578 20.5021 13.6215 20.0644 13.6945C19.6566 13.7872 19.3585 13.6856 19.1484 13.4995C18.9898 13.3588 18.889 13.1701 18.8331 13.0654C18.823 13.0465 18.8143 13.0303 18.8071 13.0176C18.7602 12.9358 18.7123 12.8558 18.6642 12.7754C18.5145 12.5254 18.3627 12.2719 18.2347 11.953C18.0289 11.4402 17.964 11.1584 17.9028 10.8925C17.8827 10.8056 17.8631 10.7204 17.8391 10.6294C17.8037 10.4951 17.7627 10.2871 17.7508 10.0724C17.7395 9.86822 17.7512 9.61118 17.8614 9.41243C18.1313 8.92552 18.5018 8.53787 18.8601 8.23051C19.1397 7.99063 19.7252 7.54356 20.0204 7.3691C20.1445 7.29577 20.2664 7.23185 20.3806 7.1725L20.4049 7.15984C20.5114 7.1045 20.6099 7.05333 20.7049 6.99846C20.9004 6.88556 21.0698 6.76333 21.2168 6.58743C21.2585 6.51101 21.2916 6.42806 21.2959 6.34449C21.3001 6.26538 21.2799 6.16786 21.1719 6.05471C21.0165 5.89202 20.8027 5.86182 20.5672 5.94356C20.3557 6.01698 19.9948 6.20913 19.6207 6.42908C19.2529 6.64535 18.8941 6.87534 18.6955 7.01117C18.5909 7.08275 18.4392 7.20237 18.2698 7.33588C18.2229 7.37287 18.1746 7.41091 18.1256 7.4493C17.9134 7.61561 17.6767 7.79714 17.5193 7.87974C17.4582 7.91177 17.362 7.94576 17.2726 7.97451C17.1769 8.00529 17.0675 8.03675 16.9677 8.06233C16.8714 8.08699 16.7721 8.10934 16.7016 8.11789C16.6834 8.1201 16.6606 8.12222 16.6371 8.12214H16.6358C16.6227 8.12216 16.5634 8.12225 16.5035 8.09117C16.465 8.07123 16.3993 8.02361 16.3756 7.93227C16.3529 7.84493 16.3841 7.77575 16.4022 7.74494C16.4208 7.71319 16.4444 7.6894 16.4629 7.67362C16.613 7.50624 16.7012 7.34969 16.7542 7.18571C16.8097 7.01388 16.8305 6.82185 16.8305 6.57986C16.8305 6.36355 16.7994 6.20559 16.7329 6.07444C16.6682 5.94668 16.5594 5.82392 16.3689 5.69496Z" fill="rgb(205, 92, 155)"/>
565 </svg>
566 </div>
567
568 <script src="/media-modal.js"></script>
569 <script>
570 // Determine base URLs based on current environment
571 function getBaseURL() {
572 const hostname = window.location.hostname;
573
574 // Dev mode: localhost or 127.0.0.1
575 if (hostname === '127.0.0.1' || hostname === 'localhost') {
576 // Always use localhost:8888 for dev (not 127.0.0.1 due to SSL cert)
577 return 'https://localhost:8888';
578 }
579
580 // Production
581 return 'https://aesthetic.computer';
582 }
583
584 const API_BASE_URL = getBaseURL();
585 const PDS_URL = 'https://at.aesthetic.computer';
586 const RECORDS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
587 const FORCE_REFRESH = (() => {
588 const params = new URLSearchParams(window.location.search);
589 return params.has('refresh') || params.has('nocache');
590 })();
591
592 console.log('🌐 API Base URL:', API_BASE_URL);
593
594 // Shared modal functions
595 function openModal(url) {
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 });
608 }
609
610 function closeModal() {
611 if (!window.ACMediaModal?.close) return;
612 window.ACMediaModal.close();
613 }
614
615 // Intercept aesthetic.computer and pdsls.dev links (left-click only)
616 document.addEventListener('click', (e) => {
617 const link = e.target.closest('a');
618 if (link && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) {
619 const href = link.getAttribute('href');
620 // Intercept aesthetic.computer URLs (not at.aesthetic.computer) and pdsls.dev URLs
621 if (href &&
622 ((href.includes('aesthetic.computer') && !href.includes('at.aesthetic.computer')) ||
623 href.includes('pdsls.dev'))) {
624 e.preventDefault();
625 openModal(href);
626 }
627 }
628 });
629
630 function normalizeHandle(handle) {
631 if (!handle) return null;
632 if (handle.includes('.')) return handle;
633 return `${handle}.at.aesthetic.computer`;
634 }
635
636 function getHandleFromQuery() {
637 const params = new URLSearchParams(window.location.search);
638 const handle = params.get('handle') || params.get('h');
639 return normalizeHandle(handle);
640 }
641
642 // Extract handle from subdomain
643 function getHandleFromSubdomain() {
644 const hostname = window.location.hostname;
645
646 // Query param override (local testing)
647 const queryHandle = getHandleFromQuery();
648 if (queryHandle) {
649 console.log(`🔧 Using handle from query param: ${queryHandle}`);
650 return queryHandle;
651 }
652
653 // Dev mode: default to 'art' handle when running on localhost
654 if (hostname === '127.0.0.1' || hostname === 'localhost') {
655 console.log('🔧 Dev mode: defaulting to art.at.aesthetic.computer');
656 return 'art.at.aesthetic.computer';
657 }
658
659 // Match: handle.at.aesthetic.computer
660 const match = hostname.match(/^([^.]+)\.at\.aesthetic\.computer$/);
661 if (match) {
662 return normalizeHandle(match[1]);
663 }
664 return null;
665 }
666
667 function getCacheKey(handle, collection) {
668 return `ac:at-records:${handle}:${collection}`;
669 }
670
671 function loadCachedRecords(handle, collection) {
672 if (FORCE_REFRESH) return null;
673 try {
674 const key = getCacheKey(handle, collection);
675 const raw = localStorage.getItem(key);
676 if (!raw) return null;
677 const parsed = JSON.parse(raw);
678 if (!parsed || !parsed.ts || !Array.isArray(parsed.records)) return null;
679 if (Date.now() - parsed.ts > RECORDS_CACHE_TTL_MS) return null;
680 return parsed.records;
681 } catch (error) {
682 return null;
683 }
684 }
685
686 function saveCachedRecords(handle, collection, records) {
687 try {
688 const key = getCacheKey(handle, collection);
689 localStorage.setItem(key, JSON.stringify({ ts: Date.now(), records }));
690 } catch (error) {
691 // Ignore storage errors (quota, etc.)
692 }
693 }
694
695 function clearRecordsCache(handle) {
696 try {
697 const prefix = `ac:at-records:${handle}:`;
698 Object.keys(localStorage)
699 .filter(key => key.startsWith(prefix))
700 .forEach(key => localStorage.removeItem(key));
701 } catch (error) {
702 // Ignore storage errors
703 }
704 }
705
706 // Resolve handle to DID
707 async function resolveDID(handle) {
708 try {
709 const response = await fetch(`${PDS_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
710 if (!response.ok) throw new Error(`Failed to resolve handle: ${response.status}`);
711 const data = await response.json();
712 return data.did;
713 } catch (error) {
714 console.error('Error resolving DID:', error);
715 throw error;
716 }
717 }
718
719 // List all records for a collection
720 async function listRecords(did, collection, handle, limit = 100) {
721 const cached = handle ? loadCachedRecords(handle, collection) : null;
722 if (cached) return cached;
723
724 const records = [];
725 let cursor = undefined;
726
727 try {
728 do {
729 const url = new URL(`${PDS_URL}/xrpc/com.atproto.repo.listRecords`);
730 url.searchParams.append('repo', did);
731 url.searchParams.append('collection', collection);
732 url.searchParams.append('limit', limit);
733 if (cursor) url.searchParams.append('cursor', cursor);
734
735 const response = await fetch(url);
736 if (!response.ok) {
737 if (response.status === 400) {
738 // Collection might not exist for this user
739 return [];
740 }
741 throw new Error(`Failed to list records: ${response.status}`);
742 }
743
744 const data = await response.json();
745 records.push(...(data.records || []));
746 cursor = data.cursor;
747 } while (cursor);
748
749 if (handle) saveCachedRecords(handle, collection, records);
750 return records;
751 } catch (error) {
752 console.error(`Error listing ${collection}:`, error);
753 return [];
754 }
755 }
756
757 // Format date
758 function formatDate(dateString) {
759 const date = new Date(dateString);
760 return date.toLocaleString('en-US', {
761 year: 'numeric',
762 month: 'short',
763 day: 'numeric',
764 hour: '2-digit',
765 minute: '2-digit'
766 });
767 }
768
769 // Get record key from URI
770 function getRkey(uri) {
771 return uri.split('/').pop();
772 }
773
774 function getLexicon(uri) {
775 // URI format: at://did:plc:xyz/computer.aesthetic.painting/rkey
776 const parts = uri.split('//')[1].split('/');
777 return parts[1]; // computer.aesthetic.painting
778 }
779
780 function getLexiconTypeName(lexicon) {
781 // Get just the type name (e.g., "painting" from "computer.aesthetic.painting")
782 return lexicon.split('.').pop();
783 }
784
785 // Parse kidlisp color escape codes (like \red\, \blue\, etc.) and convert to HTML
786 function parseColoredKidlisp(coloredString) {
787 if (!coloredString) return '';
788
789 const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
790
791 // Color mapping from kidlisp names to CSS colors
792 const colorMap = {
793 'red': '#ff0000',
794 'darkred': '#8b0000',
795 'orange': '#ff6600',
796 'yellow': isDark ? '#ffff00' : '#d4a000',
797 'lime': isDark ? '#00ff00' : '#00a000',
798 'limegreen': '#32cd32',
799 'green': '#008000',
800 'cyan': isDark ? '#00ffff' : '#008b8b',
801 'blue': '#0000ff',
802 'purple': '#800080',
803 'magenta': '#ff00ff',
804 'hotpink': '#ff69b4',
805 'white': isDark ? '#ffffff' : '#333333',
806 'black': isDark ? '#000000' : '#ffffff',
807 'gray': '#808080',
808 'grey': '#808080',
809 'mediumseagreen': '#3cb371',
810 'pink': '#ffc0cb',
811 'brown': '#a52a2a',
812 'violet': '#ee82ee',
813 'teal': '#008080',
814 'navy': '#000080',
815 'maroon': '#800000',
816 'olive': '#808000',
817 'silver': '#c0c0c0'
818 };
819
820 const result = [];
821 let i = 0;
822 let currentColor = isDark ? '#ffffff' : '#333333';
823 let textBuffer = '';
824
825 function flushBuffer() {
826 if (textBuffer) {
827 const escaped = textBuffer
828 .replace(/&/g, '&')
829 .replace(/</g, '<')
830 .replace(/>/g, '>');
831 result.push(`<span style="color: ${currentColor}">${escaped}</span>`);
832 textBuffer = '';
833 }
834 }
835
836 while (i < coloredString.length) {
837 if (coloredString[i] === '\\' && i + 1 < coloredString.length) {
838 // Find the closing \
839 let j = i + 1;
840 while (j < coloredString.length && coloredString[j] !== '\\') {
841 j++;
842 }
843 if (j < coloredString.length) {
844 const colorCode = coloredString.substring(i + 1, j);
845
846 // Flush any buffered text before changing color
847 flushBuffer();
848
849 // Parse the color code
850 if (colorMap[colorCode.toLowerCase()]) {
851 currentColor = colorMap[colorCode.toLowerCase()];
852 } else if (/^\d+,\d+,\d+(,[\d.]+)?$/.test(colorCode)) {
853 // RGB or RGBA value like "255,20,147" or "255,255,255,0.5"
854 currentColor = `rgb(${colorCode})`;
855 } else {
856 // Unknown color code - try as CSS color name
857 currentColor = colorCode;
858 }
859 i = j + 1;
860 continue;
861 }
862 }
863
864 // Regular character - add to buffer
865 textBuffer += coloredString[i];
866 i++;
867 }
868
869 // Flush any remaining text
870 flushBuffer();
871
872 return result.join('');
873 }
874
875 function buildQrDataUrl(text, cellSize = 4, margin = 0) {
876 try {
877 if (typeof qrcode === 'undefined') return '';
878 const qr = qrcode(0, 'M');
879 qr.addData(text);
880 qr.make();
881 return qr.createDataURL(cellSize, margin);
882 } catch (error) {
883 return '';
884 }
885 }
886
887 // Load kidlisp.mjs and generate colored string
888 let kidlispModule = null;
889 let highlightCache = new Map(); // Cache highlighted results
890 let sourceMap = window.sourceMap || (window.sourceMap = {}); // Cache source code for modal
891
892 const kidlispModuleUrls = [
893 'https://aesthetic.computer/aesthetic.computer/lib/kidlisp.mjs',
894 'https://aesthetic.computer/lib/kidlisp.mjs',
895 `${window.location.origin}/aesthetic.computer/lib/kidlisp.mjs`,
896 `${window.location.origin}/lib/kidlisp.mjs`,
897 ];
898
899 async function loadKidlispModule() {
900 if (kidlispModule) return kidlispModule;
901
902 for (const url of kidlispModuleUrls) {
903 try {
904 const module = await import(url);
905 if (module && module.KidLisp) {
906 kidlispModule = module;
907 console.log(`✅ Kidlisp module loaded successfully from ${url}`);
908 return kidlispModule;
909 }
910 } catch (error) {
911 console.warn(`⚠️ Failed to load kidlisp module from ${url}:`, error);
912 }
913 }
914
915 console.error('❌ Failed to load kidlisp module from all known locations');
916 return null;
917 }
918
919 async function highlightKidlisp(source) {
920 if (!source) return '';
921
922 // Check cache first
923 if (highlightCache.has(source)) {
924 return highlightCache.get(source);
925 }
926
927 try {
928 const module = await loadKidlispModule();
929 if (module && module.KidLisp) {
930 // Create a kidlisp instance for syntax highlighting
931 const kid = new module.KidLisp();
932 kid.initializeSyntaxHighlighting(source);
933 const coloredString = kid.buildColoredKidlispString();
934
935 if (coloredString && coloredString.trim()) {
936 console.log('🎨 KidLisp colored string sample:', coloredString.substring(0, 100));
937
938 const result = parseColoredKidlisp(coloredString);
939
940 // Cache the result
941 highlightCache.set(source, result);
942 return result;
943 }
944 } else {
945 console.warn('⚠️ Kidlisp module or KidLisp class not found');
946 }
947 } catch (error) {
948 console.error('❌ Syntax highlighting failed:', error);
949 }
950
951 // Fallback: simple syntax highlighting
952 const fallback = highlightKidLispSimple(source);
953 highlightCache.set(source, fallback);
954 return fallback;
955 }
956
957 async function openLexiconModal(lexicon) {
958 // Load the lexicon JSON file
959 const lexiconPath = lexicon.replace(/\./g, '/');
960 try {
961 const response = await fetch(`/lexicons/${lexiconPath}.json`);
962 if (!response.ok) {
963 throw new Error(`Failed to load lexicon: ${response.status}`);
964 }
965 const lexiconData = await response.json();
966
967 // Create a formatted HTML view of the lexicon
968 const formattedJson = JSON.stringify(lexiconData, null, 2);
969 const htmlContent = `
970 <!DOCTYPE html>
971 <html>
972 <head>
973 <style>
974 body {
975 margin: 0;
976 padding: 2em;
977 background: black;
978 color: rgb(200, 200, 200);
979 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
980 font-size: 11px;
981 line-height: 1.4;
982 }
983 h1 {
984 color: rgb(205, 92, 155);
985 font-size: 1.2em;
986 margin-bottom: 0.8em;
987 font-weight: normal;
988 }
989 pre {
990 margin: 0;
991 white-space: pre-wrap;
992 word-break: break-word;
993 }
994 .json-key { color: rgb(100, 150, 200); }
995 .json-string { color: rgb(152, 195, 121); }
996 .json-number { color: rgb(209, 154, 102); }
997 .json-boolean { color: rgb(198, 120, 221); }
998 .json-null { color: rgb(229, 192, 123); }
999 </style>
1000 </head>
1001 <body>
1002 <h1>${lexicon}</h1>
1003 <pre>${syntaxHighlight(formattedJson)}</pre>
1004 </body>
1005 </html>
1006 `;
1007
1008 // Create blob URL and open in modal
1009 const blob = new Blob([htmlContent], { type: 'text/html' });
1010 const blobUrl = URL.createObjectURL(blob);
1011 openModal(blobUrl);
1012
1013 // Clean up blob URL after modal opens
1014 setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
1015 } catch (error) {
1016 console.error('Failed to load lexicon:', error);
1017 alert(`Failed to load lexicon: ${error.message}`);
1018 }
1019 }
1020
1021 function syntaxHighlight(json) {
1022 return json
1023 .replace(/&/g, '&')
1024 .replace(/</g, '<')
1025 .replace(/>/g, '>')
1026 .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
1027 let cls = 'json-number';
1028 if (/^"/.test(match)) {
1029 if (/:$/.test(match)) {
1030 cls = 'json-key';
1031 } else {
1032 cls = 'json-string';
1033 }
1034 } else if (/true|false/.test(match)) {
1035 cls = 'json-boolean';
1036 } else if (/null/.test(match)) {
1037 cls = 'json-null';
1038 }
1039 return '<span class="' + cls + '">' + match + '</span>';
1040 });
1041 }
1042
1043 // Simple JavaScript syntax highlighter
1044 function highlightJavaScript(code) {
1045 // Escape HTML first
1046 const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
1047
1048 // Parse and highlight in order of precedence to avoid conflicts
1049 let result = '';
1050 let i = 0;
1051
1052 while (i < escaped.length) {
1053 let matched = false;
1054
1055 // Multi-line comments
1056 if (escaped.substr(i, 2) === '/*') {
1057 let end = escaped.indexOf('*/', i + 2);
1058 if (end === -1) end = escaped.length;
1059 else end += 2;
1060 result += '<span style="color: rgb(120, 120, 120);">' + escaped.substring(i, end) + '</span>';
1061 i = end;
1062 continue;
1063 }
1064
1065 // Single-line comments
1066 if (escaped.substr(i, 2) === '//') {
1067 let end = escaped.indexOf('\n', i);
1068 if (end === -1) end = escaped.length;
1069 else end += 1;
1070 result += '<span style="color: rgb(120, 120, 120);">' + escaped.substring(i, end) + '</span>';
1071 i = end;
1072 continue;
1073 }
1074
1075 // Strings
1076 if (escaped[i] === '"' || escaped[i] === "'" || escaped[i] === '`') {
1077 const quote = escaped[i];
1078 let end = i + 1;
1079 while (end < escaped.length && escaped[end] !== quote) {
1080 if (escaped[end] === '\\') end += 2;
1081 else end++;
1082 }
1083 end++; // Include closing quote
1084 result += '<span style="color: rgb(150, 255, 150);">' + escaped.substring(i, end) + '</span>';
1085 i = end;
1086 continue;
1087 }
1088
1089 // Numbers
1090 if (/\d/.test(escaped[i])) {
1091 let end = i;
1092 while (end < escaped.length && /[\d.]/.test(escaped[end])) end++;
1093 result += '<span style="color: rgb(255, 200, 100);">' + escaped.substring(i, end) + '</span>';
1094 i = end;
1095 continue;
1096 }
1097
1098 // Keywords and identifiers
1099 if (/[a-zA-Z_$]/.test(escaped[i])) {
1100 let end = i;
1101 while (end < escaped.length && /[a-zA-Z0-9_$]/.test(escaped[end])) end++;
1102 const word = escaped.substring(i, end);
1103
1104 const keywords = ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'async', 'await', 'class', 'extends', 'import', 'export', 'from', 'default', 'new', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'delete', 'in', 'of', 'void', 'yield', 'static', 'get', 'set', 'super', 'this'];
1105
1106 if (keywords.includes(word)) {
1107 result += '<span style="color: rgb(100, 150, 255);">' + word + '</span>';
1108 } else {
1109 // Check if it's a function call (followed by opening paren)
1110 let j = end;
1111 while (j < escaped.length && /\s/.test(escaped[j])) j++;
1112 if (escaped[j] === '(') {
1113 result += '<span style="color: rgb(255, 220, 100);">' + word + '</span>';
1114 } else {
1115 result += word;
1116 }
1117 }
1118 i = end;
1119 continue;
1120 }
1121
1122 // Default: just add the character
1123 result += escaped[i];
1124 i++;
1125 }
1126
1127 return result;
1128 }
1129
1130 // KidLisp syntax highlighter - simple fallback
1131 function highlightKidLispSimple(code) {
1132 // Escape HTML first
1133 code = code.replace(/</g, '<').replace(/>/g, '>');
1134
1135 // Comments (semicolons)
1136 code = code.replace(/(;.*$)/gm,
1137 '<span style="color: rgb(120, 120, 120);">$1</span>');
1138
1139 // Strings
1140 code = code.replace(/("(?:\\.|[^"\\])*")/g,
1141 '<span style="color: rgb(150, 255, 150);">$1</span>');
1142
1143 // Numbers
1144 code = code.replace(/\b(-?\d+\.?\d*)\b/g,
1145 '<span style="color: rgb(255, 200, 100);">$1</span>');
1146
1147 // Common KidLisp forms/functions (after opening paren)
1148 code = code.replace(/\(([a-zA-Z_][a-zA-Z0-9_-]*)/g,
1149 '(<span style="color: rgb(147, 51, 234);">$1</span>');
1150
1151 // Parentheses
1152 code = code.replace(/([()])/g,
1153 '<span style="color: rgb(200, 200, 200);">$1</span>');
1154
1155 return code;
1156 }
1157
1158 async function openSourceModal(uri, source, slug) {
1159 // Detect file type from slug
1160 const isJavaScript = slug && slug.endsWith('.mjs');
1161 const isKidLisp = slug && slug.endsWith('.lisp');
1162
1163 // Apply syntax highlighting
1164 let highlightedSource;
1165 if (isJavaScript) {
1166 highlightedSource = highlightJavaScript(source);
1167 } else if (isKidLisp) {
1168 // Use the proper KidLisp highlighter with color codes
1169 highlightedSource = await highlightKidlisp(source);
1170 if (!highlightedSource || highlightedSource.trim() === '') {
1171 // Fallback to simple highlighter if the full one fails
1172 highlightedSource = highlightKidLispSimple(source);
1173 }
1174 } else {
1175 highlightedSource = source.replace(/</g, '<').replace(/>/g, '>');
1176 }
1177
1178 // Create a formatted HTML view of the source code with animated scrolling
1179 const htmlContent = `
1180 <!DOCTYPE html>
1181 <html>
1182 <head>
1183 <style>
1184 * {
1185 box-sizing: border-box;
1186 }
1187 body {
1188 margin: 0;
1189 padding: 0;
1190 background: black;
1191 color: rgb(200, 200, 200);
1192 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
1193 font-size: 11px;
1194 line-height: 1.4;
1195 overflow: hidden;
1196 height: 100vh;
1197 display: flex;
1198 flex-direction: column;
1199 }
1200 .header {
1201 padding: 1.5em 2em;
1202 background: rgba(0, 0, 0, 0.8);
1203 border-bottom: 1px solid rgb(205, 92, 155);
1204 flex-shrink: 0;
1205 }
1206 h1 {
1207 color: rgb(205, 92, 155);
1208 font-size: 1.2em;
1209 margin: 0 0 0.5em 0;
1210 font-weight: normal;
1211 }
1212 .controls {
1213 display: flex;
1214 gap: 1em;
1215 align-items: center;
1216 margin-top: 0.8em;
1217 }
1218 button {
1219 background: rgba(205, 92, 155, 0.2);
1220 border: 1px solid rgb(205, 92, 155);
1221 color: rgb(205, 92, 155);
1222 padding: 0.4em 0.8em;
1223 cursor: pointer;
1224 font-family: inherit;
1225 font-size: 0.9em;
1226 border-radius: 3px;
1227 }
1228 button:hover {
1229 background: rgba(205, 92, 155, 0.3);
1230 }
1231 button.active {
1232 background: rgb(205, 92, 155);
1233 color: black;
1234 }
1235 .scroll-speed {
1236 display: flex;
1237 gap: 0.5em;
1238 align-items: center;
1239 }
1240 .scroll-speed label {
1241 font-size: 0.85em;
1242 color: rgb(150, 150, 150);
1243 }
1244 .scroll-speed input {
1245 width: 80px;
1246 }
1247 a {
1248 color: rgb(205, 92, 155);
1249 text-decoration: none;
1250 }
1251 a:hover {
1252 text-decoration: underline;
1253 }
1254 .code-container {
1255 flex: 1;
1256 overflow: hidden;
1257 position: relative;
1258 }
1259 .code-scroll {
1260 padding: 2em;
1261 overflow-y: auto;
1262 height: 100%;
1263 position: relative;
1264 }
1265 .code-scroll.auto-scrolling {
1266 overflow-y: hidden; /* Hide scrollbar during animation */
1267 animation: autoScroll var(--scroll-duration, 60s) linear infinite;
1268 }
1269 .code-scroll.auto-scrolling pre {
1270 animation: smoothScroll var(--scroll-duration, 60s) linear infinite;
1271 }
1272 pre {
1273 margin: 0;
1274 white-space: pre-wrap;
1275 word-break: break-word;
1276 }
1277 @keyframes autoScroll {
1278 0% {
1279 scroll-behavior: smooth;
1280 }
1281 100% {
1282 scroll-behavior: smooth;
1283 }
1284 }
1285 @keyframes smoothScroll {
1286 from {
1287 transform: translateY(0);
1288 }
1289 to {
1290 transform: translateY(calc(-100% + 100vh - 250px));
1291 }
1292 }
1293 .badge {
1294 display: inline-block;
1295 padding: 0.2em 0.6em;
1296 background: rgba(100, 150, 255, 0.2);
1297 border: 1px solid rgb(100, 150, 255);
1298 color: rgb(100, 150, 255);
1299 border-radius: 3px;
1300 font-size: 0.75em;
1301 margin-left: 0.5em;
1302 }
1303 </style>
1304 </head>
1305 <body>
1306 <div class="header">
1307 <h1>
1308 ${slug ? `piece: ${slug}` : 'piece source'}
1309 ${isJavaScript ? '<span class="badge">.mjs</span>' : ''}
1310 ${isKidLisp ? '<span class="badge">.lisp</span>' : ''}
1311 <span class="badge" style="background: rgba(100, 100, 100, 0.2); border-color: rgb(150, 150, 150); color: rgb(150, 150, 150);">${source.split('\\n').length} lines</span>
1312 </h1>
1313 ${slug ? `<p style="margin: 0;"><a href="https://aesthetic.computer/${slug}" target="_blank">→ open on aesthetic.computer</a></p>` : ''}
1314 <div class="controls">
1315 <button id="scrollBtn" onclick="toggleScroll()">▶ Auto-scroll</button>
1316 <div class="scroll-speed">
1317 <label for="speedRange">Speed:</label>
1318 <input type="range" id="speedRange" min="10" max="120" value="60"
1319 oninput="updateSpeed(this.value)">
1320 <span id="speedLabel">60s</span>
1321 </div>
1322 </div>
1323 </div>
1324 <div class="code-container">
1325 <div class="code-scroll" id="codeScroll">
1326 <pre>${highlightedSource}</pre>
1327 </div>
1328 </div>
1329 <script>
1330 let isScrolling = true; // Start with auto-scroll enabled
1331 const scrollContainer = document.getElementById('codeScroll');
1332 const scrollBtn = document.getElementById('scrollBtn');
1333 const speedLabel = document.getElementById('speedLabel');
1334
1335 // Initialize auto-scroll on load
1336 window.addEventListener('load', () => {
1337 scrollContainer.classList.add('auto-scrolling');
1338 scrollBtn.classList.add('active');
1339 scrollBtn.textContent = '⏸ Pause';
1340 });
1341
1342 function toggleScroll() {
1343 isScrolling = !isScrolling;
1344 if (isScrolling) {
1345 scrollContainer.classList.add('auto-scrolling');
1346 scrollBtn.classList.add('active');
1347 scrollBtn.textContent = '⏸ Pause';
1348 } else {
1349 scrollContainer.classList.remove('auto-scrolling');
1350 scrollBtn.classList.remove('active');
1351 scrollBtn.textContent = '▶ Auto-scroll';
1352 }
1353 }
1354
1355 function updateSpeed(seconds) {
1356 document.documentElement.style.setProperty('--scroll-duration', seconds + 's');
1357 speedLabel.textContent = seconds + 's';
1358 }
1359
1360 // Pause on hover
1361 scrollContainer.addEventListener('mouseenter', () => {
1362 if (isScrolling) {
1363 scrollContainer.style.animationPlayState = 'paused';
1364 }
1365 });
1366
1367 scrollContainer.addEventListener('mouseleave', () => {
1368 if (isScrolling) {
1369 scrollContainer.style.animationPlayState = 'running';
1370 }
1371 });
1372
1373 // Manual scroll disables auto-scroll
1374 scrollContainer.addEventListener('wheel', () => {
1375 if (isScrolling) {
1376 toggleScroll();
1377 }
1378 });
1379 <\/script>
1380 </body>
1381 </html>
1382 `;
1383
1384 // Create blob URL and open in modal
1385 const blob = new Blob([htmlContent], { type: 'text/html' });
1386 const blobUrl = URL.createObjectURL(blob);
1387 openModal(blobUrl);
1388
1389 // Clean up blob URL after modal opens
1390 setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
1391 }
1392
1393 // Helper function to open source modal from DOM element
1394 async function openSourceModalFromElement(element) {
1395 const sourceId = element.dataset.sourceId;
1396 const uri = element.dataset.uri;
1397 const slug = element.dataset.slug;
1398 const language = element.dataset.language || 'mjs';
1399
1400 const sourceHolder = document.getElementById(sourceId);
1401 if (sourceHolder) {
1402 const source = sourceHolder.textContent;
1403 // Add language extension to slug for proper detection
1404 const slugWithExtension = slug.includes('.') ? slug : `${slug}.${language}`;
1405 await openSourceModal(uri, source, slugWithExtension);
1406 } else {
1407 console.error('Source not found for ID:', sourceId);
1408 }
1409 }
1410
1411 // Render a painting record
1412 function renderPainting(record) {
1413 const { value, uri } = record;
1414 const rkey = getRkey(uri);
1415 const lexicon = getLexicon(uri);
1416 const typeName = getLexiconTypeName(lexicon);
1417
1418 const card = document.createElement('div');
1419 card.className = 'record-card';
1420
1421 let imageHtml = '';
1422 if (value.thumbnail?.ref) {
1423 const did = uri.split('/')[2];
1424 const thumbnailUrl = `${PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.thumbnail.ref.$link}`;
1425 imageHtml = `<img src="${thumbnailUrl}" alt="Painting thumbnail" class="record-image">`;
1426 }
1427
1428 card.innerHTML = `
1429 <div class="record-header">
1430 <span class="record-date">${formatDate(value.createdAt || value.when)}</span>
1431 <span class="lexicon-badge" onclick="openLexiconModal('${lexicon}')">${typeName}</span>
1432 </div>
1433 <div class="record-content">
1434 ${imageHtml}
1435 <div style="margin-top: 0.5em; display: flex; gap: 0.5em; flex-wrap: wrap;">
1436 ${value.code ? `<a href="${value.acUrl || `https://aesthetic.computer/#${value.code}`}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(205, 92, 155, 0.1); border: 1px solid rgb(205, 92, 155); border-radius: 3px; font-size: 0.85em;">█ prompt.ac/<strong>#</strong><strong>${value.code}</strong></a>` : ''}
1437 <a href="https://pdsls.dev/at://${uri.split('//')[1]}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(100, 150, 200, 0.1); border: 1px solid rgb(100, 150, 200); border-radius: 3px; font-size: 0.85em; color: rgb(100, 150, 200);">${rkey}</a>
1438 </div>
1439 </div>
1440 `;
1441
1442 return card;
1443 }
1444
1445 // Render a mood record
1446 function renderMood(record) {
1447 const { value, uri } = record;
1448 const rkey = getRkey(uri);
1449 const lexicon = getLexicon(uri);
1450 const typeName = getLexiconTypeName(lexicon);
1451
1452 const card = document.createElement('div');
1453 card.className = 'record-card';
1454
1455 card.innerHTML = `
1456 <div class="record-header">
1457 <span class="record-date">${formatDate(value.when || value.createdAt)}</span>
1458 <span class="lexicon-badge" onclick="openLexiconModal('${lexicon}')">${typeName}</span>
1459 </div>
1460 <div class="record-content">
1461 <div class="record-text">${value.mood || value.text || ''}</div>
1462 </div>
1463 `;
1464
1465 return card;
1466 }
1467
1468 // Render a tape record
1469 function renderTape(record) {
1470 const { value, uri } = record;
1471 const rkey = getRkey(uri);
1472 const lexicon = getLexicon(uri);
1473 const typeName = getLexiconTypeName(lexicon);
1474
1475 const card = document.createElement('div');
1476 card.className = 'record-card';
1477
1478 let videoHtml = '';
1479 if (value.video?.ref) {
1480 const did = uri.split('/')[2];
1481 const videoUrl = `${PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.video.ref.$link}`;
1482 videoHtml = `<video controls loop autoplay muted playsinline webkit-playsinline class="record-image" style="max-width: 100%; height: auto; border: none;">
1483 <source src="${videoUrl}" type="video/mp4">
1484 Your browser does not support the video tag.
1485 </video>`;
1486 }
1487
1488 card.innerHTML = `
1489 <div class="record-header">
1490 <span class="record-date">${formatDate(value.when || value.createdAt)}</span>
1491 <span class="lexicon-badge" onclick="openLexiconModal('${lexicon}')">${typeName}</span>
1492 </div>
1493 <div class="record-content">
1494 ${videoHtml}
1495 <div style="margin-top: 0.5em; display: flex; gap: 0.5em; flex-wrap: wrap;">
1496 ${value.code ? `<a href="${value.acUrl || `https://aesthetic.computer/!${value.code}`}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(205, 92, 155, 0.1); border: 1px solid rgb(205, 92, 155); border-radius: 3px; font-size: 0.85em;">█ prompt.ac/<strong>!</strong><strong>${value.code}</strong></a>` : ''}
1497 <a href="https://pdsls.dev/at://${uri.split('//')[1]}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(100, 150, 200, 0.1); border: 1px solid rgb(100, 150, 200); border-radius: 3px; font-size: 0.85em; color: rgb(100, 150, 200);">${rkey}</a>
1498 </div>
1499 </div>
1500 `;
1501
1502 return card;
1503 }
1504
1505 // Render a kidlisp record
1506 async function renderKidlisp(record) {
1507 const { value, uri } = record;
1508 const rkey = getRkey(uri);
1509 const lexicon = getLexicon(uri);
1510 const typeName = getLexiconTypeName(lexicon);
1511
1512 const card = document.createElement('div');
1513 card.className = 'record-card';
1514
1515 // Create initial card HTML with preview container
1516 const previewCode = value.code ? value.code.replace(/[^a-zA-Z0-9_-]/g, '') : '';
1517 const kidlispPreviewHtml = previewCode
1518 ? `<a href="https://aesthetic.computer/$${previewCode}" target="_blank" rel="noreferrer" class="kidlisp-preview-card">
1519 <img class="kidlisp-webp" src="https://oven.aesthetic.computer/grab/webp/200/200/$${previewCode}?duration=4000&fps=8&quality=80&density=1&nowait=true" alt="KidLisp preview" loading="lazy" />
1520 <div class="kidlisp-preview-overlay" data-kidlisp-code="${previewCode}">
1521 <div class="kidlisp-preview-code">${value.source ? value.source.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') : ''}</div>
1522 <div class="kidlisp-preview-qr">
1523 <div class="kidlisp-preview-label">$${previewCode}</div>
1524 <img alt="KidLisp QR" />
1525 </div>
1526 </div>
1527 </a>`
1528 : '';
1529
1530 card.innerHTML = `
1531 <div class="record-header">
1532 <span class="record-date">${formatDate(value.when || value.createdAt)}</span>
1533 <span class="lexicon-badge" onclick="openLexiconModal('${lexicon}')">${typeName}</span>
1534 </div>
1535 <div class="record-content">
1536 ${kidlispPreviewHtml}
1537 <div style="margin-top: 0.5em; display: flex; gap: 0.5em; flex-wrap: wrap;">
1538 ${value.code ? `<a href="${value.acUrl || `https://aesthetic.computer/$${value.code}`}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(205, 92, 155, 0.1); border: 1px solid rgb(205, 92, 155); border-radius: 3px; font-size: 0.85em;">█ prompt.ac/<strong>$</strong><strong>${value.code}</strong></a>` : ''}
1539 <a href="https://pdsls.dev/at://${uri.split('//')[1]}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(100, 150, 200, 0.1); border: 1px solid rgb(100, 150, 200); border-radius: 3px; font-size: 0.85em; color: rgb(100, 150, 200);">${rkey}</a>
1540 </div>
1541 </div>
1542 `;
1543
1544 // Highlight and animate the source code asynchronously
1545 if (value.source) {
1546 highlightKidlisp(value.source).then(highlightedSource => {
1547 // Store source for modal
1548 const sourceId = `kidlisp-${rkey}`;
1549 sourceMap = window.sourceMap || (window.sourceMap = sourceMap || {});
1550 sourceMap[sourceId] = value.source;
1551
1552 // Update overlay code in WebP preview
1553 const overlay = card.querySelector('.kidlisp-preview-overlay');
1554 if (overlay) {
1555 const overlayCode = overlay.querySelector('.kidlisp-preview-code');
1556 if (overlayCode) {
1557 overlayCode.innerHTML = highlightedSource;
1558 }
1559 const qrImg = overlay.querySelector('.kidlisp-preview-qr img');
1560 if (qrImg && value.code) {
1561 const qrUrl = buildQrDataUrl(`https://aesthetic.computer/$${value.code}`, 3, 0);
1562 if (qrUrl) qrImg.src = qrUrl;
1563 }
1564 }
1565 }).catch(error => {
1566 console.error('Highlighting error:', error);
1567 });
1568 }
1569
1570 if (value.code) {
1571 const overlay = card.querySelector('.kidlisp-preview-overlay');
1572 if (overlay) {
1573 const qrImg = overlay.querySelector('.kidlisp-preview-qr img');
1574 if (qrImg) {
1575 const qrUrl = buildQrDataUrl(`https://aesthetic.computer/$${value.code}`, 3, 0);
1576 if (qrUrl) qrImg.src = qrUrl;
1577 }
1578 }
1579 }
1580
1581 return card;
1582 }
1583
1584 // Render a piece record
1585 async function renderPiece(record, handle) {
1586 const { value, uri } = record;
1587 const rkey = getRkey(uri);
1588 const lexicon = getLexicon(uri);
1589 const typeName = getLexiconTypeName(lexicon);
1590
1591 const card = document.createElement('div');
1592 card.className = 'record-card';
1593
1594 // Create initial card HTML
1595 card.innerHTML = `
1596 <div class="record-header">
1597 <span class="record-date">${formatDate(value.when || value.createdAt)}</span>
1598 <span class="lexicon-badge" onclick="openLexiconModal('${lexicon}')">${typeName}</span>
1599 </div>
1600 <div class="record-content">
1601 <div class="source-preview-container" style="margin: 0.5em 0;">
1602 <div style="font-size: 0.75em; color: rgb(150, 150, 150);">loading source...</div>
1603 </div>
1604 <div style="margin-top: 0.5em; display: flex; gap: 0.5em; flex-wrap: wrap;">
1605 ${value.slug ? `<a href="https://prompt.ac/@${handle ? handle.split('.at.aesthetic.computer')[0] : 'art'}/${value.slug}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(205, 92, 155, 0.1); border: 1px solid rgb(205, 92, 155); border-radius: 3px; font-size: 0.85em;">█ prompt.ac/<strong>@${handle ? handle.split('.at.aesthetic.computer')[0] : 'art'}</strong>/<strong>${value.slug}</strong></a>` : ''}
1606 <a href="https://pdsls.dev/at://${uri.split('//')[1]}" class="record-link" style="display: inline-block; padding: 0.4em 0.8em; background: rgba(100, 150, 200, 0.1); border: 1px solid rgb(100, 150, 200); border-radius: 3px; font-size: 0.85em; color: rgb(100, 150, 200);">${rkey}</a>
1607 </div>
1608 </div>
1609 `;
1610
1611 // Fetch source code from record or media URL if available
1612 if (value.slug && handle) {
1613 try {
1614 let source = null;
1615 let language = 'mjs'; // Default to JavaScript
1616
1617 // Use embedded source if present
1618 if (typeof value.source === 'string' && value.source.trim()) {
1619 source = value.source;
1620 language = value.language || (value.source.trim().startsWith('(') ? 'lisp' : 'mjs');
1621 } else if (typeof value.sourceCode === 'string' && value.sourceCode.trim()) {
1622 source = value.sourceCode;
1623 language = value.language || (value.sourceCode.trim().startsWith('(') ? 'lisp' : 'mjs');
1624 }
1625
1626 if (!source) {
1627 // Prefer same-origin media proxy to avoid CORS issues
1628 const baseHandle = handle.replace(/^@/, '');
1629 const shortHandle = baseHandle.split('.at.aesthetic.computer')[0];
1630 const mediaHandles = Array.from(new Set([baseHandle, shortHandle].filter(Boolean)));
1631 const rawSlug = value.slug || '';
1632 const slug = rawSlug.replace(/\.(mjs|js|lisp)$/i, '');
1633
1634 async function tryFetchPieceSource(mediaHandle) {
1635 const bases = [
1636 `/media/@${mediaHandle}/piece/${slug}`,
1637 `/media/@${mediaHandle}/code/${slug}`
1638 ];
1639 const extensions = ['.lisp', '.mjs', '.js', ''];
1640
1641 for (const base of bases) {
1642 for (const ext of extensions) {
1643 const url = `${base}${ext}`;
1644 const response = await fetch(url);
1645 if (response.ok) {
1646 const text = await response.text();
1647 const isLisp = ext === '.lisp' || text.trim().startsWith('(');
1648 return { source: text, language: isLisp ? 'lisp' : 'mjs' };
1649 }
1650 }
1651 }
1652
1653 return null;
1654 }
1655
1656 for (const mediaHandle of mediaHandles) {
1657 const result = await tryFetchPieceSource(mediaHandle);
1658 if (result) {
1659 source = result.source;
1660 language = result.language;
1661 break;
1662 }
1663 }
1664
1665 // Fallback for art handle legacy URLs
1666 if (!source && handle === 'art.at.aesthetic.computer') {
1667 const legacyLispUrl = `https://art.aesthetic.computer/${slug}.lisp`;
1668 let response = await fetch(legacyLispUrl);
1669 if (response.ok) {
1670 source = await response.text();
1671 language = 'lisp';
1672 } else {
1673 const legacyMjsUrl = `https://art.aesthetic.computer/${slug}.mjs`;
1674 response = await fetch(legacyMjsUrl);
1675 if (response.ok) {
1676 source = await response.text();
1677 language = 'mjs';
1678 }
1679 }
1680 }
1681 }
1682
1683 // Render source if found
1684 if (source) {
1685 const previewContainer = card.querySelector('.source-preview-container');
1686 if (previewContainer) {
1687 // Create a unique ID for this source
1688 const sourceId = `source-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1689
1690 // Store source in a hidden element to avoid escaping issues
1691 const sourceHolder = document.createElement('div');
1692 sourceHolder.id = sourceId;
1693 sourceHolder.style.display = 'none';
1694 sourceHolder.textContent = source;
1695 sourceHolder.dataset.language = language;
1696 document.body.appendChild(sourceHolder);
1697
1698 // Apply syntax highlighting to full source (not just preview)
1699 let highlightedSource;
1700 if (language === 'lisp') {
1701 // Use async highlighter for KidLisp
1702 highlightKidlisp(source).then(highlighted => {
1703 if (highlighted && highlighted.trim()) {
1704 // Update the preview with highlighted code (duplicated for seamless loop)
1705 const previewContent = previewContainer.querySelector('.preview-content');
1706 if (previewContent) {
1707 previewContent.innerHTML = `
1708 ${highlighted}
1709 <div style="margin: 2em 0; border-top: 1px dashed rgba(100,100,100,0.3); padding-top: 2em;"></div>
1710 ${highlighted}
1711 `;
1712 }
1713 }
1714 });
1715 // Start with plain text while loading
1716 highlightedSource = source.replace(/</g, '<').replace(/>/g, '>');
1717 } else if (language === 'mjs') {
1718 highlightedSource = highlightJavaScript(source);
1719 } else {
1720 highlightedSource = source.replace(/</g, '<').replace(/>/g, '>');
1721 }
1722
1723 // Language badge colors
1724 const languageBadge = language === 'lisp'
1725 ? '<span style="display: inline-block; padding: 0.15em 0.4em; background: rgba(147, 51, 234, 0.1); color: rgb(147, 51, 234); border-radius: 3px; font-size: 0.85em; font-weight: 600; margin-left: 0.5em;">KidLisp</span>'
1726 : '<span style="display: inline-block; padding: 0.15em 0.4em; background: rgba(234, 179, 8, 0.1); color: rgb(234, 179, 8); border-radius: 3px; font-size: 0.85em; font-weight: 600; margin-left: 0.5em;">JavaScript</span>';
1727
1728 // Calculate dynamic font size and animation speed based on content
1729 const lineCount = source.split('\n').length;
1730 const fontSize = lineCount > 50 ? '0.55em' : lineCount > 30 ? '0.6em' : lineCount > 15 ? '0.7em' : '0.8em';
1731
1732 // Shorter programs scroll faster - scale duration based on line count
1733 const animationDuration = Math.max(15, Math.min(60, lineCount * 0.8));
1734
1735 // Generate line numbers (doubled for the duplicated content)
1736 const lineNumbers = Array.from({length: lineCount * 2}, (_, i) =>
1737 `<div>${(i % lineCount) + 1}</div>`
1738 ).join('');
1739
1740 previewContainer.innerHTML = `
1741 <div class="source-preview"
1742 data-source-id="${sourceId}"
1743 data-uri="${uri.replace(/"/g, '"')}"
1744 data-slug="${value.slug}"
1745 data-language="${language}"
1746 onclick="openSourceModalFromElement(this)"
1747 style="padding: 0.5em; border-radius: 2px; cursor: pointer; font-family: monospace; font-size: ${fontSize}; color: rgb(100, 100, 100);">
1748 <div class="preview-scroll-container" style="animation: previewScrollLoop ${animationDuration}s linear infinite;">
1749 <div class="line-numbers">${lineNumbers}</div>
1750 <div class="preview-content" style="white-space: pre; overflow-wrap: normal;">
1751 ${highlightedSource}
1752 <div style="margin: 2em 0; border-top: 1px dashed rgba(100,100,100,0.3); padding-top: 2em;"></div>
1753 ${highlightedSource}
1754 </div>
1755 </div>
1756 </div>
1757 `;
1758 }
1759 } else {
1760 const previewContainer = card.querySelector('.source-preview-container');
1761 if (previewContainer) previewContainer.innerHTML = '';
1762 }
1763 } catch (error) {
1764 console.error('Failed to fetch piece source:', error);
1765 const previewContainer = card.querySelector('.source-preview-container');
1766 if (previewContainer) previewContainer.innerHTML = '';
1767 }
1768 } else {
1769 const previewContainer = card.querySelector('.source-preview-container');
1770 if (previewContainer) previewContainer.innerHTML = '';
1771 }
1772
1773 return card;
1774 }
1775
1776 // Render a generic record
1777 function renderGenericRecord(record, typeName) {
1778 const { value, uri } = record;
1779 const rkey = getRkey(uri);
1780
1781 const card = document.createElement('div');
1782 card.className = 'record-card';
1783
1784 card.innerHTML = `
1785 <div class="record-header">
1786 <span class="record-type">${typeName}</span>
1787 <span class="record-date">${formatDate(value.createdAt || value.when || new Date().toISOString())}</span>
1788 </div>
1789 <div class="record-content">
1790 <pre class="record-text" style="font-size: 0.85em; overflow-x: auto;">${JSON.stringify(value, null, 2)}</pre>
1791 </div>
1792 <div class="record-meta">
1793 <span>rkey: ${rkey}</span>
1794 </div>
1795 <a href="https://pdsls.dev/at://${uri.split('//')[1]}" target="_blank" class="record-link">
1796 View on pdsls.dev →
1797 </a>
1798 `;
1799
1800 return card;
1801 }
1802
1803 // Global state for lazy loading
1804 let currentRecords = [];
1805 let currentCollection = '';
1806 let currentHandle = '';
1807 let currentIndex = 0;
1808 const BATCH_SIZE = 50; // Load 50 records at a time
1809 let isLoading = false;
1810 let loadingSentinel = null;
1811
1812 // Render a batch of records
1813 async function renderBatch(startIndex, endIndex) {
1814 if (isLoading || startIndex >= currentRecords.length) return;
1815
1816 isLoading = true;
1817 const grid = document.querySelector('.records-grid');
1818 if (!grid) return;
1819
1820 const batch = currentRecords.slice(startIndex, endIndex);
1821
1822 for (const record of batch) {
1823 let card;
1824 const collectionKey = currentCollection === 'all'
1825 ? (record._collection || '')
1826 : currentCollection;
1827
1828 if (collectionKey.includes('painting')) {
1829 card = renderPainting(record);
1830 } else if (collectionKey.includes('mood')) {
1831 card = renderMood(record);
1832 } else if (collectionKey.includes('tape')) {
1833 card = renderTape(record);
1834 } else if (collectionKey.includes('kidlisp')) {
1835 card = await renderKidlisp(record);
1836 } else if (collectionKey.includes('piece')) {
1837 card = await renderPiece(record, currentHandle);
1838 } else {
1839 card = renderGenericRecord(record, collectionKey.split('.').pop());
1840 }
1841
1842 // Insert before the sentinel
1843 if (loadingSentinel && loadingSentinel.parentNode) {
1844 grid.insertBefore(card, loadingSentinel);
1845 } else {
1846 grid.appendChild(card);
1847 }
1848 }
1849
1850 currentIndex = endIndex;
1851 isLoading = false;
1852
1853 // Update sentinel text
1854 if (loadingSentinel) {
1855 if (currentIndex >= currentRecords.length) {
1856 loadingSentinel.textContent = `✓ All ${currentRecords.length} records loaded`;
1857 loadingSentinel.style.opacity = '0.5';
1858 } else {
1859 loadingSentinel.textContent = `Loaded ${currentIndex} of ${currentRecords.length} records... (scroll to load more)`;
1860 }
1861 }
1862 }
1863
1864 // Render records for a collection with lazy loading
1865 async function renderRecords(records, collection, handle) {
1866 const container = document.getElementById('records-container');
1867 container.innerHTML = '';
1868
1869 if (records.length === 0) {
1870 container.innerHTML = '<div class="empty-state">No records found</div>';
1871 return;
1872 }
1873
1874 // Sort by date (newest first)
1875 const sorted = [...records].sort((a, b) => {
1876 const dateA = new Date(a.value.createdAt || a.value.when || 0);
1877 const dateB = new Date(b.value.createdAt || b.value.when || 0);
1878 return dateB - dateA;
1879 });
1880
1881 // Update global state
1882 currentRecords = sorted;
1883 currentCollection = collection;
1884 currentHandle = handle;
1885 currentIndex = 0;
1886 isLoading = false;
1887
1888 // Create grid
1889 const grid = document.createElement('div');
1890 grid.className = 'records-grid';
1891
1892 // Create loading sentinel
1893 loadingSentinel = document.createElement('div');
1894 loadingSentinel.className = 'loading-sentinel';
1895 loadingSentinel.style.cssText = 'text-align: center; padding: 2em 1em; opacity: 0.6; font-size: 0.9em; grid-column: 1 / -1;';
1896 loadingSentinel.textContent = `Loading records...`;
1897 grid.appendChild(loadingSentinel);
1898
1899 container.appendChild(grid);
1900
1901 // Set up Intersection Observer for lazy loading
1902 const observer = new IntersectionObserver(
1903 async (entries) => {
1904 const entry = entries[0];
1905 if (entry.isIntersecting && !isLoading && currentIndex < currentRecords.length) {
1906 await renderBatch(currentIndex, currentIndex + BATCH_SIZE);
1907 }
1908 },
1909 {
1910 rootMargin: '400px', // Start loading 400px before sentinel is visible
1911 threshold: 0.1
1912 }
1913 );
1914
1915 observer.observe(loadingSentinel);
1916
1917 // Load initial batch
1918 await renderBatch(0, BATCH_SIZE);
1919 }
1920
1921 // Initialize the page
1922 async function init() {
1923 try {
1924 const handle = getHandleFromSubdomain();
1925 if (!handle) {
1926 throw new Error('Invalid subdomain format. Expected: handle.at.aesthetic.computer');
1927 }
1928
1929 document.getElementById('handle-display').textContent = `@${handle}`;
1930 document.title = `@${handle} · ATProto`;
1931
1932 // Add subtitle for art.at.aesthetic.computer
1933 const didDisplay = document.getElementById('did-display');
1934 const handleDisplay = document.getElementById('handle-display');
1935
1936 if (handle === 'art.at.aesthetic.computer') {
1937 didDisplay.innerHTML = '<div style="font-size: 0.9em; margin-top: 0.5em; opacity: 0.8; line-height: 1.5; max-width: 600px; margin-left: auto; margin-right: auto;">Anonymously recorded tapes and other media on <a href="https://aesthetic.computer" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">Aesthetic Computer</a>, synced to <a href="https://atproto.com" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">ATProto</a> for open syndication via the <a href="https://at.aesthetic.computer" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">at.aesthetic.computer</a> PDS instance.</div>';
1938
1939 // Make handle clickable and add color cycling to "art"
1940 handleDisplay.innerHTML = '<a href="https://at.aesthetic.computer" style="color: inherit; text-decoration: none;"><span class="color-cycle-a">a</span><span class="color-cycle-r">r</span><span class="color-cycle-t">t</span>.at.aesthetic.computer</a>';
1941 handleDisplay.style.cursor = 'pointer';
1942 } else {
1943 // For user pages, extract username part (before .at.aesthetic.computer)
1944 const username = handle.split('.at.aesthetic.computer')[0];
1945
1946 // Add color cycling only to username part
1947 const usernameParts = username.split('');
1948 const colorCycledUsername = usernameParts.map((char, index) => {
1949 const delay = (index * 0.3).toFixed(1);
1950 return `<span class="color-cycle" style="animation-delay: -${delay}s">${char}</span>`;
1951 }).join('');
1952
1953 handleDisplay.innerHTML = `${colorCycledUsername}.at.aesthetic.computer`;
1954
1955 // Simple subtitle with username and Aesthetic Computer link
1956 didDisplay.innerHTML = `<div style="font-size: 0.9em; margin-top: 0.5em; opacity: 0.8; line-height: 1.5; max-width: 600px; margin-left: auto; margin-right: auto;">All media by <span style="font-weight: bold;">@${username}</span> on <a href="https://aesthetic.computer" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">Aesthetic Computer</a>, synced to <a href="https://atproto.com" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">ATProto</a> for open syndication via the <a href="https://at.aesthetic.computer" target="_blank" style="color: rgb(205, 92, 155); text-decoration: none;">at.aesthetic.computer</a> PDS instance.</div>`;
1957 }
1958
1959 // Resolve DID (but don't display it - we're showing subtitles instead)
1960 const did = await resolveDID(handle);
1961
1962 // Fetch all collections (tapes first)
1963 const collections = [
1964 'computer.aesthetic.tape',
1965 'computer.aesthetic.painting',
1966 'computer.aesthetic.mood',
1967 'computer.aesthetic.piece',
1968 'computer.aesthetic.kidlisp'
1969 ];
1970
1971 const recordsByCollection = {};
1972 const allRecords = [];
1973 let totalRecords = 0;
1974 let firstTab = null;
1975 let firstRecords = null;
1976 let firstCollection = null;
1977 let loadingHidden = false;
1978
1979 function hideLoading() {
1980 if (loadingHidden) return;
1981 document.getElementById('loading').style.display = 'none';
1982 document.getElementById('content').style.display = 'block';
1983 loadingHidden = true;
1984 }
1985
1986 // Stats section removed - counts now in tabs
1987 document.getElementById('stats').innerHTML = '';
1988
1989 // Create tabs
1990 const tabsContainer = document.getElementById('tabs');
1991
1992 // Helper to get display name for collection
1993 function getDisplayName(collection) {
1994 const name = collection.split('.').pop();
1995 if (name === 'kidlisp') return 'KidLisp';
1996 if (name === 'painting') return 'Paintings';
1997 if (name === 'mood') return 'Moods';
1998 if (name === 'piece') return 'Pieces';
1999 if (name === 'tape') return 'Tapes';
2000 return name.charAt(0).toUpperCase() + name.slice(1);
2001 }
2002
2003 const tabs = new Map();
2004
2005 function setTabLabel(tab, label, count, loading) {
2006 tab.innerHTML = `${label} (${count}${loading ? '…' : ''})`;
2007 }
2008
2009 function activateTab(tab, records, collection) {
2010 document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
2011 tab.classList.add('active');
2012 renderRecords(records, collection, handle);
2013 }
2014
2015 const allTab = document.createElement('button');
2016 allTab.className = 'tab disabled';
2017 allTab.disabled = true;
2018 setTabLabel(allTab, 'All Media', 0, true);
2019 allTab.onclick = () => activateTab(allTab, allRecords, 'all');
2020 tabsContainer.appendChild(allTab);
2021 tabs.set('all', allTab);
2022
2023 // Collection tabs (initialize with loading state)
2024 collections.forEach(collection => {
2025 const displayName = getDisplayName(collection);
2026 const tab = document.createElement('button');
2027 tab.className = 'tab disabled';
2028 tab.disabled = true;
2029 setTabLabel(tab, displayName, 0, true);
2030 tab.onclick = () => activateTab(tab, recordsByCollection[collection] || [], collection);
2031 tabsContainer.appendChild(tab);
2032 tabs.set(collection, tab);
2033 });
2034
2035 const refreshButton = document.createElement('button');
2036 refreshButton.className = 'tab';
2037 refreshButton.style.opacity = '0.7';
2038 refreshButton.innerHTML = '↻ Refresh';
2039 refreshButton.onclick = () => {
2040 clearRecordsCache(handle);
2041 window.location.search = window.location.search.includes('refresh')
2042 ? window.location.search
2043 : window.location.search + (window.location.search ? '&refresh=1' : '?refresh=1');
2044 };
2045 tabsContainer.appendChild(refreshButton);
2046
2047 // Fetch collections concurrently and update tabs as they resolve
2048 const pending = new Set(collections);
2049 await Promise.all(collections.map(async (collection) => {
2050 const records = await listRecords(did, collection, handle);
2051 recordsByCollection[collection] = records;
2052 totalRecords += records.length;
2053
2054 const withCollection = records.map(record => ({ ...record, _collection: collection }));
2055 allRecords.push(...withCollection);
2056 allRecords.sort((a, b) => {
2057 const dateA = new Date(a.value.createdAt || a.value.when || 0);
2058 const dateB = new Date(b.value.createdAt || b.value.when || 0);
2059 return dateB - dateA;
2060 });
2061
2062 const tab = tabs.get(collection);
2063 if (tab) {
2064 setTabLabel(tab, getDisplayName(collection), records.length, false);
2065 if (records.length > 0) {
2066 tab.disabled = false;
2067 tab.classList.remove('disabled');
2068 }
2069 }
2070
2071 pending.delete(collection);
2072
2073 const allTabRef = tabs.get('all');
2074 if (allTabRef) {
2075 setTabLabel(allTabRef, 'All Media', totalRecords, pending.size > 0);
2076 if (totalRecords > 0) {
2077 allTabRef.disabled = false;
2078 allTabRef.classList.remove('disabled');
2079 }
2080 }
2081
2082 if (!firstTab && allRecords.length > 0) {
2083 firstTab = allTabRef;
2084 firstRecords = allRecords;
2085 firstCollection = 'all';
2086 hideLoading();
2087 activateTab(firstTab, firstRecords, firstCollection);
2088 } else if (currentCollection === 'all') {
2089 // Update active All Media view when new records arrive
2090 renderRecords(allRecords, 'all', handle);
2091 }
2092 }));
2093
2094 if (!loadingHidden) {
2095 hideLoading();
2096 }
2097
2098 if (!firstTab) {
2099 document.getElementById('records-container').innerHTML = '<div class="empty-state">No records found</div>';
2100 }
2101
2102 } catch (error) {
2103 console.error('Error loading page:', error);
2104 document.getElementById('loading').style.display = 'none';
2105 document.getElementById('error').style.display = 'block';
2106 document.getElementById('error').textContent = `❌ Error: ${error.message}`;
2107 }
2108 }
2109
2110 // Auto-refresh placeholder images (baking indicator)
2111 document.addEventListener('load', (e) => {
2112 if (e.target.tagName === 'IMG' && e.target.src.includes('nowait=true')) {
2113 const img = e.target;
2114 if (!img.dataset.retried) {
2115 // Schedule a retry after 10-15 seconds to fetch the real baked image
2116 const retryDelay = 10000 + Math.random() * 5000;
2117 setTimeout(() => {
2118 img.dataset.retried = 'true';
2119 const url = new URL(img.src);
2120 url.searchParams.set('t', Date.now());
2121 img.src = url.toString();
2122 }, retryDelay);
2123 }
2124 }
2125 }, true);
2126
2127 // Start
2128 init();
2129 </script>
2130</body>
2131</html>