Atom feed for our EEG site
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Atomic EEG</title>
7 <link rel="preconnect" href="https://fonts.googleapis.com">
8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9 <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
10 <style>
11 :root {
12 --bg-color: #0a170f;
13 --bg-alt-color: #132018;
14 --text-color: #e0ece5;
15 --text-muted: #8aa99a;
16 --accent-color: #4dfa7b;
17 --accent-shadow: rgba(77, 250, 123, 0.3);
18 --accent-alt: #4db380;
19 --border-color: #2c4035;
20 --card-bg: #152720;
21 --header-height: 50px;
22 --sidebar-width: 80px;
23 --tab-height: 40px;
24 --ripple-color: rgba(77, 250, 123, 0.04);
25 --ripple-color-strong: rgba(77, 250, 123, 0.06);
26 --matrix-color: rgba(77, 250, 123, 0.2);
27 --matrix-glow: rgba(77, 250, 123, 0.1);
28 --hover-glow: rgba(77, 250, 123, 0.15);
29 }
30
31 * {
32 margin: 0;
33 padding: 0;
34 box-sizing: border-box;
35 }
36
37 body {
38 font-family: 'Roboto', sans-serif;
39 background-color: var(--bg-color);
40 color: var(--text-color);
41 line-height: 1.5;
42 overflow-x: hidden;
43 position: relative;
44 }
45
46 body::before {
47 content: '';
48 position: fixed;
49 top: 0;
50 left: 0;
51 width: 100%;
52 height: 100%;
53 background: linear-gradient(rgba(10, 23, 15, 0.82), rgba(10, 23, 15, 0.92));
54 z-index: -1;
55 pointer-events: none;
56 }
57
58 #matrix-background {
59 position: fixed;
60 top: 0;
61 left: 0;
62 width: 100%;
63 height: 100%;
64 z-index: -2;
65 opacity: 0.6;
66 pointer-events: none;
67 }
68
69 header {
70 position: fixed;
71 top: 0;
72 width: 100%;
73 height: var(--header-height);
74 background-color: var(--bg-alt-color);
75 border-bottom: 1px solid var(--border-color);
76 display: flex;
77 align-items: center;
78 padding: 0 20px;
79 z-index: 100;
80 }
81
82 .header-container {
83 display: flex;
84 justify-content: space-between;
85 align-items: center;
86 width: 100%;
87 max-width: 1200px;
88 margin: 0 auto;
89 }
90
91 .header-left {
92 display: flex;
93 align-items: baseline;
94 gap: 15px;
95 }
96
97 .tagline {
98 font-size: 0.75rem;
99 color: var(--text-muted);
100 font-family: 'JetBrains Mono', monospace;
101 white-space: nowrap;
102 }
103
104
105
106 .tabs {
107 display: flex;
108 align-items: center;
109 gap: 8px;
110 }
111
112 .tab-button {
113 font-family: 'JetBrains Mono', monospace;
114 font-size: 0.9rem;
115 background-color: transparent;
116 border: none;
117 color: var(--text-muted);
118 padding: 8px 16px;
119 cursor: pointer;
120 border-radius: 4px;
121 transition: all 0.2s ease;
122 }
123
124 .tab-button:hover {
125 color: var(--text-color);
126 background-color: rgba(77, 250, 123, 0.05);
127 }
128
129 .tab-button.active {
130 color: var(--accent-color);
131 background-color: rgba(77, 250, 123, 0.1);
132 font-weight: 600;
133 }
134
135 .tab-content {
136 display: none;
137 }
138
139 .tab-content.active {
140 display: block;
141 }
142
143 .logo {
144 font-family: 'JetBrains Mono', monospace;
145 font-weight: 600;
146 font-size: 1.3rem;
147 color: var(--accent-color);
148 text-shadow: 0 0 10px var(--accent-shadow);
149 }
150
151 .logo span {
152 color: var(--accent-alt);
153 }
154
155 .info-panel {
156 font-family: 'JetBrains Mono', monospace;
157 font-size: 0.8rem;
158 color: var(--text-muted);
159 }
160
161 main {
162 margin-top: var(--header-height);
163 min-height: calc(100vh - var(--header-height));
164 display: flex;
165 position: relative;
166 padding: 15px 20px;
167 }
168
169 .content {
170 width: 100%;
171 max-width: 1200px;
172 margin: 0 auto;
173 padding-left: var(--sidebar-width);
174 }
175
176 .timeline-sidebar {
177 position: fixed;
178 top: var(--header-height);
179 left: 0;
180 width: var(--sidebar-width);
181 height: calc(100vh - var(--header-height));
182 background-color: var(--bg-alt-color);
183 border-right: 1px solid var(--border-color);
184 display: flex;
185 flex-direction: column;
186 overflow-y: auto;
187 padding: 15px 0;
188 z-index: 50;
189 scrollbar-width: none; /* For Firefox */
190 cursor: pointer; /* Show pointer cursor for the entire sidebar */
191 }
192
193 .timeline-sidebar::-webkit-scrollbar {
194 display: none; /* For Chrome/Safari/Edge */
195 }
196
197 .timeline-year {
198 padding: 5px 0;
199 text-align: center;
200 color: var(--text-muted);
201 font-size: 0.8rem;
202 font-family: 'JetBrains Mono', monospace;
203 position: relative;
204 transition: all 0.2s ease;
205 }
206
207 .timeline-month {
208 padding: 3px 0;
209 text-align: center;
210 color: var(--text-muted);
211 font-size: 0.7rem;
212 opacity: 0.8;
213 position: relative;
214 transition: all 0.2s ease;
215 }
216
217 .timeline-year:hover, .timeline-month:hover {
218 color: var(--accent-color);
219 transform: scale(1.05);
220 }
221
222 .timeline-year::before,
223 .timeline-month::before {
224 content: '';
225 position: absolute;
226 right: 20px;
227 top: 50%;
228 width: 7px;
229 height: 1px;
230 background-color: var(--border-color);
231 }
232
233 .timeline-year::after {
234 content: '';
235 position: absolute;
236 right: 15px;
237 top: 50%;
238 transform: translateY(-50%);
239 width: 4px;
240 height: 4px;
241 border-radius: 50%;
242 background-color: var(--accent-color);
243 }
244
245 .timeline-month::after {
246 content: '';
247 position: absolute;
248 right: 16px;
249 top: 50%;
250 transform: translateY(-50%);
251 width: 2px;
252 height: 2px;
253 border-radius: 50%;
254 background-color: var(--accent-alt);
255 }
256
257 .timeline-year.active {
258 color: var(--accent-color);
259 font-weight: 600;
260 background-color: rgba(77, 250, 123, 0.1);
261 border-radius: 4px;
262 }
263
264 .timeline-month.active {
265 color: var(--accent-alt);
266 font-weight: 600;
267 background-color: rgba(77, 250, 123, 0.05);
268 border-radius: 4px;
269 }
270
271 .timeline-year.active::after {
272 width: 8px;
273 height: 8px;
274 right: 13px;
275 box-shadow: 0 0 8px var(--accent-shadow);
276 }
277
278 .timeline-month.active::after {
279 width: 4px;
280 height: 4px;
281 right: 15px;
282 }
283
284 .feed-item {
285 background-color: var(--card-bg);
286 border: 1px solid var(--border-color);
287 border-radius: 4px;
288 margin-bottom: 8px;
289 overflow: hidden;
290 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
291 transition: background-color 0.2s ease;
292 }
293
294 .feed-item:hover {
295 background-color: #1a3028;
296 }
297
298 .feed-item-row {
299 display: flex;
300 align-items: center;
301 padding: 8px 15px;
302 width: 100%;
303 overflow: hidden;
304 position: relative;
305 }
306
307 .feed-item-left {
308 display: flex;
309 align-items: center;
310 margin-right: 10px;
311 }
312
313 .feed-item-date {
314 font-family: 'JetBrains Mono', monospace;
315 font-size: 0.75rem;
316 color: var(--text-muted);
317 margin-right: 10px;
318 min-width: 120px;
319 white-space: nowrap;
320 }
321
322 /* .date-column removed as part of sidebar simplification */
323
324 .month-year-header {
325 padding: 8px 0;
326 width: 100%;
327 margin-bottom: 5px;
328 margin-top: 10px;
329 }
330
331 .month-year-label {
332 font-weight: 600;
333 color: var(--accent-alt);
334 }
335
336 .feed-container {
337 position: relative;
338 padding-left: 15px; /* Reduced from 110px since we don't need space for date column anymore */
339 }
340
341 .feed-item-author {
342 font-family: 'JetBrains Mono', monospace;
343 color: var(--accent-alt);
344 font-size: 0.85rem;
345 min-width: 70px;
346 margin-right: 15px;
347 white-space: nowrap;
348 }
349
350 .feed-item-title {
351 font-size: 0.95rem;
352 font-weight: 400;
353 display: inline;
354 word-break: break-word;
355 }
356
357 .feed-item-title a {
358 color: var(--text-color);
359 text-decoration: none;
360 transition: color 0.2s ease;
361 }
362
363 .feed-item-title a:hover {
364 color: var(--accent-color);
365 }
366
367 .feed-item-content-wrapper {
368 flex: 1;
369 overflow: hidden;
370 white-space: nowrap;
371 text-overflow: ellipsis;
372 padding-right: 10px;
373 }
374
375 .feed-item-preview {
376 color: var(--text-muted);
377 font-size: 0.85rem;
378 overflow: hidden;
379 text-overflow: ellipsis;
380 white-space: nowrap;
381 transition: all 0.3s ease;
382 display: inline;
383 margin-left: 8px;
384 }
385
386 .feed-item-preview a {
387 color: var(--accent-alt);
388 text-decoration: underline;
389 }
390
391 .feed-item-actions {
392 display: flex;
393 align-items: center;
394 gap: 10px;
395 margin-left: auto;
396 }
397
398 .feed-item {
399 border-left: 3px solid transparent;
400 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
401 position: relative;
402 overflow: hidden;
403 z-index: 1;
404 }
405
406 .feed-item:hover {
407 border-left-color: var(--accent-color);
408 background-color: rgba(21, 39, 32, 0.95);
409 }
410
411 .feed-item::before {
412 content: '';
413 position: absolute;
414 top: 0;
415 left: 0;
416 right: 0;
417 bottom: 0;
418 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%),
419 rgba(77, 250, 123, 0.06) 0%,
420 rgba(77, 250, 123, 0.04) 30%,
421 rgba(77, 250, 123, 0) 70%);
422 opacity: 0;
423 z-index: 0;
424 transform: scale(0);
425 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1);
426 pointer-events: none;
427 }
428
429 .references-container {
430 padding: 5px 15px;
431 border-top: 1px dashed var(--border-color);
432 background-color: rgba(77, 250, 123, 0.02);
433 }
434
435 .reference-item {
436 display: flex;
437 align-items: center;
438 padding: 4px 0;
439 line-height: 1.3;
440 }
441
442 .reference-indicator {
443 color: var(--accent-color);
444 margin-right: 5px;
445 font-size: 0.85rem;
446 }
447
448
449 .feed-item:hover .feed-item-content-wrapper {
450 white-space: normal;
451 }
452
453 .feed-item:hover .feed-item-preview {
454 white-space: normal;
455 line-height: 1.4;
456 max-height: none;
457 display: inline;
458 margin-left: 8px;
459 opacity: 1;
460 }
461
462 .feed-item:hover::before {
463 opacity: 0.6;
464 transform: scale(1.5);
465 }
466
467 .preview-links,
468 .preview-references {
469 font-size: 0.8rem;
470 display: none;
471 flex-wrap: wrap;
472 align-items: center;
473 gap: 8px;
474 margin-top: 3px;
475 }
476
477 /* Common styles for all platform links */
478 .external-link-item[data-link-type] {
479 background-color: rgba(77, 180, 128, 0.08);
480 color: var(--accent-alt);
481 display: inline-flex;
482 align-items: center;
483 }
484
485 /* Platform-specific styling can be added here in the future if needed */
486
487 .external-link-item img {
488 display: inline-block;
489 vertical-align: middle;
490 filter: invert(1);
491 }
492
493 .feed-item:hover .preview-links,
494 .feed-item:hover .preview-references {
495 display: flex;
496 }
497
498 .reference-header {
499 font-family: 'JetBrains Mono', monospace;
500 color: var(--text-muted);
501 font-size: 0.9rem;
502 margin-bottom: 5px;
503 }
504
505 .reference-link {
506 color: var(--text-color);
507 text-decoration: none;
508 transition: color 0.2s ease;
509 }
510
511 .reference-link:hover {
512 color: var(--accent-color);
513 }
514
515 .reference-author {
516 color: var(--text-muted);
517 font-size: 0.85rem;
518 margin-left: 5px;
519 }
520
521 .external-links-label {
522 color: var(--text-muted);
523 font-family: 'JetBrains Mono', monospace;
524 margin-right: 10px;
525 }
526
527 .external-link-item {
528 display: inline-block;
529 color: var(--accent-alt);
530 text-decoration: none;
531 background-color: rgba(77, 180, 128, 0.08);
532 padding: 2px 6px;
533 border-radius: 3px;
534 transition: all 0.2s ease;
535 }
536
537 .external-link-item:hover {
538 background-color: rgba(77, 180, 128, 0.15);
539 text-decoration: underline;
540 }
541
542 .external-links-toggle {
543 background: transparent;
544 border: none;
545 color: var(--text-muted);
546 font-family: 'JetBrains Mono', monospace;
547 font-size: 0.75rem;
548 padding: 2px 5px;
549 cursor: pointer;
550 display: inline-flex;
551 align-items: center;
552 border-radius: 3px;
553 margin-left: 10px;
554 }
555
556 .external-links-toggle:hover {
557 background-color: rgba(77, 180, 128, 0.05);
558 color: var(--accent-alt);
559 }
560
561 .feed-item-content {
562 padding: 15px;
563 line-height: 1.6;
564 display: none;
565 border-top: 1px solid var(--border-color);
566 background-color: #1a2e24;
567 }
568
569 .feed-item-content img {
570 max-width: 100%;
571 height: auto;
572 border-radius: 4px;
573 margin: 10px 0;
574 }
575
576 .feed-item-content pre, .feed-item-content code {
577 font-family: 'JetBrains Mono', monospace;
578 background-color: #183025;
579 border-radius: 4px;
580 padding: 0.2em 0.4em;
581 font-size: 0.9em;
582 }
583
584 .feed-item-content pre {
585 padding: 12px;
586 overflow-x: auto;
587 margin: 12px 0;
588 }
589
590 .feed-item-content blockquote {
591 border-left: 3px solid var(--accent-color);
592 padding-left: 12px;
593 margin-left: 0;
594 color: var(--text-muted);
595 }
596
597 .read-more-btn,
598 .external-links-toggle,
599 .references-toggle {
600 background-color: transparent;
601 border: none;
602 color: var(--accent-color);
603 cursor: pointer;
604 font-size: 1rem;
605 padding: 2px 8px;
606 border-radius: 3px;
607 transition: all 0.2s ease;
608 display: inline-block;
609 }
610
611 .read-more-btn:hover,
612 .external-links-toggle:hover,
613 .references-toggle:hover {
614 background-color: rgba(77, 250, 123, 0.1);
615 transform: scale(1.1);
616 }
617
618 .external-link {
619 color: var(--text-muted);
620 font-size: 1rem;
621 display: inline-block;
622 text-decoration: none;
623 padding: 2px 8px;
624 border-radius: 3px;
625 transition: all 0.2s ease;
626 }
627
628 .external-link:hover {
629 color: var(--accent-alt);
630 transform: scale(1.1);
631 }
632
633 #loading {
634 display: flex;
635 flex-direction: column;
636 align-items: center;
637 justify-content: center;
638 min-height: 200px;
639 }
640
641 .loading-spinner {
642 border: 3px solid rgba(77, 250, 123, 0.1);
643 border-top: 3px solid var(--accent-color);
644 border-radius: 50%;
645 width: 30px;
646 height: 30px;
647 animation: spin 1s linear infinite;
648 margin-bottom: 12px;
649 }
650
651 @keyframes spin {
652 0% { transform: rotate(0deg); }
653 100% { transform: rotate(360deg); }
654 }
655
656 .loading-text {
657 font-family: 'JetBrains Mono', monospace;
658 color: var(--accent-color);
659 font-size: 0.9rem;
660 }
661
662 .error-message {
663 color: #ff4d4d;
664 text-align: center;
665 padding: 20px;
666 font-family: 'JetBrains Mono', monospace;
667 }
668
669 /* Link item styling */
670 .link-item {
671 background-color: var(--card-bg);
672 border: 1px solid var(--border-color);
673 border-radius: 4px;
674 margin-bottom: 8px;
675 overflow: hidden;
676 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
677 display: flex;
678 align-items: center;
679 padding: 10px 15px;
680 position: relative;
681 }
682
683 .link-item:hover {
684 background-color: rgba(21, 39, 32, 0.95);
685 border-left-color: var(--accent-color);
686 }
687
688 .link-item::before {
689 content: '';
690 position: absolute;
691 top: 0;
692 left: 0;
693 right: 0;
694 bottom: 0;
695 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%),
696 rgba(77, 250, 123, 0.06) 0%,
697 rgba(77, 250, 123, 0.04) 30%,
698 rgba(77, 250, 123, 0) 70%);
699 opacity: 0;
700 z-index: 0;
701 transform: scale(0);
702 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1);
703 pointer-events: none;
704 }
705
706 .link-item:hover::before {
707 opacity: 0.6;
708 transform: scale(1.5);
709 }
710
711 .link-item-date {
712 font-family: 'JetBrains Mono', monospace;
713 font-size: 0.75rem;
714 color: var(--text-muted);
715 min-width: 120px;
716 margin-right: 15px;
717 white-space: nowrap;
718 }
719
720 .link-item-source {
721 font-family: 'JetBrains Mono', monospace;
722 font-size: 0.85rem;
723 color: var(--accent-alt);
724 min-width: 100px;
725 margin-right: 15px;
726 white-space: nowrap;
727 overflow: hidden;
728 text-overflow: ellipsis;
729 }
730
731 .link-item-content {
732 flex: 1;
733 }
734
735 .link-item-url-container {
736 display: flex;
737 align-items: center;
738 justify-content: space-between;
739 width: 100%;
740 }
741
742 .link-item-url {
743 color: var(--text-color);
744 text-decoration: none;
745 font-size: 0.9rem;
746 display: flex;
747 align-items: center;
748 flex: 1;
749 min-width: 0;
750 margin-right: 10px;
751 }
752
753 .link-item-url:hover {
754 color: var(--accent-color);
755 }
756
757 .link-item-path {
758 color: var(--text-muted);
759 margin-left: 8px;
760 font-size: 0.8rem;
761 white-space: nowrap;
762 overflow: hidden;
763 text-overflow: ellipsis;
764 }
765
766 .link-source-reference {
767 color: var(--text-muted);
768 text-decoration: none;
769 transition: color 0.2s ease;
770 font-size: 0.75rem;
771 white-space: nowrap;
772 max-width: 180px;
773 overflow: hidden;
774 text-overflow: ellipsis;
775 display: inline-block;
776 }
777
778 .link-source-reference:hover {
779 color: var(--accent-alt);
780 }
781
782 .link-source-icon {
783 display: inline-block;
784 font-size: 0.7rem;
785 margin-right: 2px;
786 color: var(--accent-alt);
787 }
788
789 .link-item-icon {
790 display: inline-block;
791 margin-right: 8px;
792 filter: invert(1);
793 width: 16px;
794 height: 16px;
795 vertical-align: middle;
796 }
797
798 @media (max-width: 900px) {
799 .feed-item-preview {
800 display: none;
801 }
802
803 .link-item {
804 flex-direction: column;
805 align-items: flex-start;
806 }
807
808 .link-item-date {
809 margin-bottom: 6px;
810 min-width: auto;
811 width: 100%;
812 }
813
814 .link-item-source {
815 margin-bottom: 6px;
816 }
817
818 .link-item-url-container {
819 flex-direction: column;
820 align-items: flex-start;
821 }
822
823 .link-source-reference {
824 margin-top: 4px;
825 max-width: none;
826 }
827
828 .header-container {
829 flex-direction: column;
830 align-items: flex-start;
831 padding: 10px 0;
832 }
833
834 .header-left {
835 flex-direction: column;
836 align-items: flex-start;
837 gap: 5px;
838 }
839
840 .tagline {
841 white-space: normal;
842 font-size: 0.7rem;
843 }
844
845 header {
846 height: auto;
847 }
848
849 main {
850 margin-top: 140px;
851 }
852
853 .timeline-sidebar {
854 top: 140px;
855 height: calc(100vh - 140px);
856 width: 60px; /* Make the sidebar a bit narrower on mobile */
857 }
858
859 .tabs {
860 margin: 10px 0;
861 }
862 }
863
864 /* People tab styling */
865 .people-header {
866 font-family: 'JetBrains Mono', monospace;
867 color: var(--accent-color);
868 margin-bottom: 20px;
869 font-size: 1.4rem;
870 font-weight: 600;
871 }
872
873 .people-container {
874 display: grid;
875 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
876 gap: 20px;
877 }
878
879 .person-card {
880 background-color: var(--card-bg);
881 border: 1px solid var(--border-color);
882 border-radius: 4px;
883 overflow: hidden;
884 transition: all 0.2s ease;
885 padding: 15px;
886 }
887
888 .person-card:hover {
889 border-left-color: var(--accent-color);
890 background-color: rgba(77, 250, 123, 0.03);
891 }
892
893 .person-name {
894 font-family: 'JetBrains Mono', monospace;
895 color: var(--accent-alt);
896 font-size: 1.1rem;
897 margin-bottom: 8px;
898 font-weight: 600;
899 }
900
901 .person-site {
902 font-size: 0.9rem;
903 color: var(--text-muted);
904 margin-bottom: 12px;
905 }
906
907 .person-site a {
908 color: var(--text-muted);
909 text-decoration: none;
910 transition: color 0.2s ease;
911 }
912
913 .person-site a:hover {
914 color: var(--accent-color);
915 }
916
917 .person-stats {
918 display: flex;
919 gap: 15px;
920 margin-bottom: 15px;
921 font-family: 'JetBrains Mono', monospace;
922 font-size: 0.85rem;
923 }
924
925 .person-stat {
926 display: flex;
927 flex-direction: column;
928 align-items: center;
929 }
930
931 .stat-value {
932 color: var(--accent-color);
933 font-size: 1.1rem;
934 font-weight: 600;
935 }
936
937 .stat-label {
938 color: var(--text-muted);
939 font-size: 0.75rem;
940 }
941
942 .person-recent {
943 margin-top: 12px;
944 }
945
946 .recent-title {
947 font-family: 'JetBrains Mono', monospace;
948 color: var(--text-muted);
949 font-size: 0.85rem;
950 margin-bottom: 8px;
951 }
952
953 .recent-posts {
954 display: flex;
955 flex-direction: column;
956 gap: 8px;
957 }
958
959 .recent-post {
960 padding: 8px;
961 background-color: rgba(77, 250, 123, 0.03);
962 border-radius: 3px;
963 font-size: 0.9rem;
964 }
965
966 .recent-post a {
967 color: var(--text-color);
968 text-decoration: none;
969 transition: color 0.2s ease;
970 }
971
972 .recent-post a:hover {
973 color: var(--accent-color);
974 }
975
976 .recent-post-date {
977 font-family: 'JetBrains Mono', monospace;
978 color: var(--text-muted);
979 font-size: 0.75rem;
980 margin-top: 3px;
981 }
982
983 /* Styles for filter container */
984 .filter-container {
985 display: flex;
986 align-items: center;
987 margin-bottom: 10px;
988 padding: 5px 10px;
989 background-color: var(--card-bg);
990 border: 1px solid var(--border-color);
991 border-radius: 4px;
992 align-self: flex-end;
993 width: auto;
994 margin-left: auto;
995 }
996
997 .filter-title {
998 font-family: 'JetBrains Mono', monospace;
999 color: var(--accent-color);
1000 font-size: 0.8rem;
1001 margin-right: 10px;
1002 }
1003
1004 .filter-options {
1005 display: flex;
1006 gap: 10px;
1007 }
1008
1009 .filter-option {
1010 position: relative;
1011 display: flex;
1012 align-items: center;
1013 cursor: pointer;
1014 user-select: none;
1015 }
1016
1017 .filter-checkbox {
1018 position: absolute;
1019 opacity: 0;
1020 height: 0;
1021 width: 0;
1022 }
1023
1024 .checkbox-custom {
1025 position: relative;
1026 display: inline-block;
1027 width: 14px;
1028 height: 14px;
1029 background-color: rgba(77, 250, 123, 0.05);
1030 border: 1px solid var(--accent-alt);
1031 border-radius: 3px;
1032 margin-right: 6px;
1033 transition: all 0.2s ease;
1034 }
1035
1036 .filter-checkbox:checked + .checkbox-custom::after {
1037 content: '';
1038 position: absolute;
1039 top: 1px;
1040 left: 4px;
1041 width: 4px;
1042 height: 7px;
1043 border: solid var(--accent-color);
1044 border-width: 0 2px 2px 0;
1045 transform: rotate(45deg);
1046 }
1047
1048 .filter-checkbox:checked + .checkbox-custom {
1049 background-color: rgba(77, 250, 123, 0.15);
1050 border-color: var(--accent-color);
1051 }
1052
1053 .filter-label {
1054 font-size: 0.8rem;
1055 color: var(--text-color);
1056 }
1057
1058 .filter-option:hover .checkbox-custom {
1059 background-color: rgba(77, 250, 123, 0.1);
1060 border-color: var(--accent-color);
1061 }
1062
1063 .links-header {
1064 display: flex;
1065 justify-content: flex-end;
1066 margin-bottom: 10px;
1067 width: 100%;
1068 }
1069
1070 @media (max-width: 600px) {
1071 .feed-item-author {
1072 min-width: 50px;
1073 margin-right: 10px;
1074 }
1075
1076 .feed-item-date {
1077 min-width: auto;
1078 width: 100%;
1079 margin-bottom: 5px;
1080 }
1081
1082 .feed-item-row {
1083 flex-direction: column;
1084 align-items: flex-start;
1085 }
1086
1087 .tabs {
1088 gap: 2px;
1089 width: 100%;
1090 justify-content: space-between;
1091 }
1092
1093 .tab-button {
1094 padding: 6px 8px;
1095 font-size: 0.75rem;
1096 flex-grow: 1;
1097 text-align: center;
1098 }
1099
1100 .people-container {
1101 grid-template-columns: 1fr;
1102 }
1103
1104
1105 main {
1106 margin-top: 150px;
1107 }
1108
1109 .timeline-sidebar {
1110 top: 150px;
1111 height: calc(100vh - 150px);
1112 width: 50px; /* Even narrower on very small screens */
1113 }
1114
1115 .content {
1116 padding-left: 50px; /* Match the sidebar width on small screens */
1117 }
1118 }
1119 </style>
1120</head>
1121<body>
1122 <canvas id="matrix-background"></canvas>
1123 <header>
1124 <div class="header-container">
1125 <div class="header-left">
1126 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;">
1127 <div class="logo">Atomic<span>EEG</span></div>
1128 </a>
1129 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div>
1130 </div>
1131 <div class="tabs">
1132 <button class="tab-button active" data-tab="posts">Posts</button>
1133 <button class="tab-button" data-tab="links">Links</button>
1134 <button class="tab-button" data-tab="people">Vibes</button>
1135 </div>
1136 </div>
1137 </header>
1138
1139 <main>
1140 <section class="content">
1141 <div id="loading">
1142 <div class="loading-spinner"></div>
1143 <p class="loading-text">Growing Content...</p>
1144 </div>
1145 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div>
1146 <div class="tab-content" data-tab="links">
1147 <div class="links-header">
1148 <div class="filter-container">
1149 <div class="filter-title">Filter:</div>
1150 <div class="filter-options">
1151 <label class="filter-option">
1152 <input type="checkbox" id="filter-papers" class="filter-checkbox" data-filter="academic">
1153 <span class="checkbox-custom"></span>
1154 <span class="filter-label">Papers</span>
1155 </label>
1156 <label class="filter-option">
1157 <input type="checkbox" id="filter-videos" class="filter-checkbox" data-filter="youtube">
1158 <span class="checkbox-custom"></span>
1159 <span class="filter-label">Videos</span>
1160 </label>
1161 </div>
1162 </div>
1163 </div>
1164 <div id="link-items" class="feed-container"></div>
1165 </div>
1166 <div id="people-items" class="tab-content" data-tab="people">
1167 <h2 class="people-header">EEG Sources</h2>
1168 <div class="people-container"></div>
1169 </div>
1170 </section>
1171 <aside class="timeline-sidebar" id="timeline-sidebar">
1172 <!-- Timeline will be populated via JavaScript -->
1173 </aside>
1174 </main>
1175
1176 <script>
1177 document.addEventListener('DOMContentLoaded', async () => {
1178 // Matrix background effect
1179 const canvas = document.getElementById('matrix-background');
1180 const ctx = canvas.getContext('2d');
1181
1182 // Set canvas size to match window
1183 function resizeCanvas() {
1184 canvas.width = window.innerWidth;
1185 canvas.height = window.innerHeight;
1186 }
1187 resizeCanvas();
1188 window.addEventListener('resize', resizeCanvas);
1189
1190 // Vine/plant-related characters and elements
1191 const vineChars = '┃┃│┋┇┊┆╽╿┴┬╵╷└┕┖┗┘┙┚┛╘╙╚╛╯╰╱╲⌠⌡╎▏▕⏐▌▐░▒▓◥◤◢◣⎸⎹│';
1192 const leafChars = '☘❀✿❁❃❇❈❉❊❋✣✤✥✦✧✩✪✫✬✭✮✾✿❀❁❂❃❄⚘♠♣⚜⚘☘';
1193 const branchChars = '┌┐┘└├┬┴┤┼─┄┈┉┊┋╱╲╳☂⚢⌒~∞≈≋⋆✧✦✫';
1194 const fontSize = 14;
1195 const columns = Math.floor(canvas.width / fontSize * 0.7); // Fewer columns for sparser vines
1196
1197 // Drop positions for each column
1198 const drops = [];
1199
1200 // Initialize drops at random positions
1201 for (let i = 0; i < columns; i++) {
1202 // Random starting position
1203 drops[i] = Math.random() * -canvas.height;
1204 }
1205
1206 // Set up column types - some will be vines, some will have leaves
1207 const columnTypes = [];
1208 for (let i = 0; i < columns; i++) {
1209 // 70% of columns are vines, 25% are leaves, 5% are cross-connections
1210 const rand = Math.random();
1211 if (rand < 0.7) {
1212 columnTypes[i] = 'vine';
1213 } else if (rand < 0.95) {
1214 columnTypes[i] = 'leaf';
1215 } else {
1216 columnTypes[i] = 'branch';
1217 }
1218 }
1219
1220 // Store connections between vines
1221 const connections = [];
1222
1223 // Helper function to find nearby columns
1224 function findNearbyColumns(columnIndex, maxDistance = 3) {
1225 const nearby = [];
1226 for (let i = 0; i < columns; i++) {
1227 if (i !== columnIndex && Math.abs(i - columnIndex) <= maxDistance) {
1228 nearby.push(i);
1229 }
1230 }
1231 return nearby;
1232 }
1233
1234 // Last time random chars were changed
1235 const lastCharChangeTime = [];
1236 // The current characters displayed
1237 const currentChars = [];
1238 // Width/thickness of vines
1239 const vineThickness = [];
1240
1241 for (let i = 0; i < columns; i++) {
1242 lastCharChangeTime[i] = [];
1243 currentChars[i] = [];
1244
1245 // Random vine thickness between 1-3
1246 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1247
1248 for (let j = 0; j < canvas.height / fontSize; j++) {
1249 lastCharChangeTime[i][j] = 0;
1250
1251 if (columnTypes[i] === 'vine') {
1252 // Choose vine characters based on position and thickness
1253 if (j === 0) {
1254 // Top of vine - might be a leaf or flower
1255 currentChars[i][j] = Math.random() < 0.6 ?
1256 leafChars.charAt(Math.floor(Math.random() * leafChars.length)) :
1257 vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1258 } else {
1259 // Main vine character
1260 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1261 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1262 }
1263 } else if (columnTypes[i] === 'leaf') {
1264 // Leaf character - only at top or occasional spots along the vine
1265 if (j === 0 || Math.random() < 0.2) {
1266 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1267 } else {
1268 // Connecting vine
1269 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * 5)); // Thin vine characters
1270 }
1271 } else if (columnTypes[i] === 'branch') {
1272 // This is a branching column - will form connections between vines
1273 if (j === 0) {
1274 // Top of branch might be a leaf or flower
1275 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1276 } else {
1277 // Branch characters - horizontal or diagonal connectors
1278 currentChars[i][j] = branchChars.charAt(Math.floor(Math.random() * branchChars.length));
1279 }
1280 }
1281 }
1282 }
1283
1284 // Time when animation started
1285 const startTime = Date.now();
1286
1287 // Track connections between vines
1288 const crossConnections = [];
1289
1290 // Draw the rainforest vine effect
1291 function drawVineEffect() {
1292 // Semi-transparent background to create fade effect
1293 ctx.fillStyle = 'rgba(10, 23, 15, 0.05)';
1294 ctx.fillRect(0, 0, canvas.width, canvas.height);
1295
1296 const now = Date.now();
1297
1298 // Set font
1299 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`;
1300 ctx.textAlign = 'center';
1301
1302 // First, create cross-connections
1303 // Create new cross-connections occasionally
1304 if (Math.random() < 0.01) {
1305 // Find a source vine that's grown enough
1306 const sourceIndex = Math.floor(Math.random() * columns);
1307 if (drops[sourceIndex] > 100 && columnTypes[sourceIndex] === 'vine') {
1308 // Find a nearby column to connect to
1309 const nearby = findNearbyColumns(sourceIndex, 3);
1310 if (nearby.length > 0) {
1311 const targetIndex = nearby[Math.floor(Math.random() * nearby.length)];
1312 if (drops[targetIndex] > 80) {
1313 // The height should be somewhere between the two vines
1314 const sourceHeight = drops[sourceIndex];
1315 const targetHeight = drops[targetIndex];
1316 const connectionHeight = Math.min(sourceHeight, targetHeight) * 0.8;
1317
1318 // Create the connection
1319 crossConnections.push({
1320 source: sourceIndex,
1321 target: targetIndex,
1322 height: connectionHeight,
1323 character: branchChars.charAt(Math.floor(Math.random() * branchChars.length)),
1324 created: now
1325 });
1326 }
1327 }
1328 }
1329 }
1330
1331 // For each column
1332 for (let i = 0; i < columns; i++) {
1333 // Calculate current position of this vine
1334 const x = i * fontSize * 1.5; // Space vines further apart
1335
1336 // For each character in this column
1337 for (let j = 0; j < Math.ceil(drops[i] / fontSize); j++) {
1338 const y = j * fontSize;
1339
1340 // Skip rendering some characters to create gaps in vines
1341 if (Math.random() < 0.05 && j > 3) continue;
1342
1343 // Calculate age of this character
1344 const charAge = now - lastCharChangeTime[i][j];
1345
1346 // Randomly change some characters over time - slower rate for natural movement
1347 if (j === 0 && (Math.random() < 0.005 || charAge > 8000)) {
1348 // Top character might change between leaves/flowers
1349 if (columnTypes[i] === 'leaf' || Math.random() < 0.6) {
1350 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1351 } else {
1352 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1353 }
1354 lastCharChangeTime[i][j] = now;
1355 } else if (j > 0 && Math.random() < 0.001) {
1356 // Occasionally grow new leaves along the vine
1357 if (Math.random() < 0.2) {
1358 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1359 } else {
1360 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1361 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1362 }
1363 lastCharChangeTime[i][j] = now;
1364 }
1365
1366 // Calculate distance from head of the vine
1367 const distanceFromHead = (drops[i] - y);
1368
1369 // Determine color based on position and type
1370 if (j === 0 && (currentChars[i][j] === '❀' || currentChars[i][j] === '✿' ||
1371 currentChars[i][j] === '❁' || currentChars[i][j] === '✾')) {
1372 // Flowers are more colorful - pinkish
1373 ctx.fillStyle = 'rgba(255, 180, 220, 0.9)';
1374 ctx.shadowColor = 'rgba(255, 150, 200, 0.6)';
1375 ctx.shadowBlur = 5;
1376 } else if (currentChars[i][j] === '☘' || leafChars.includes(currentChars[i][j])) {
1377 // Leaf characters are brightest with different green
1378 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1379 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1380 ctx.shadowBlur = 3;
1381 } else if (distanceFromHead < fontSize) {
1382 // Growing tip of vine is brightest
1383 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1384 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1385 ctx.shadowBlur = 5;
1386 } else if (distanceFromHead < fontSize * 8) {
1387 // Newer part of vine is brighter
1388 const opacity = 0.8 - (distanceFromHead / (fontSize * 10));
1389 ctx.fillStyle = `rgba(77, 180, 100, ${opacity.toFixed(2)})`;
1390 ctx.shadowColor = 'transparent';
1391 ctx.shadowBlur = 0;
1392 } else {
1393 // Older parts of vine are darker
1394 const opacity = Math.max(0, 0.4 - (distanceFromHead / (canvas.height * 2)));
1395 // Darker green for older vines
1396 ctx.fillStyle = `rgba(40, 120, 60, ${opacity.toFixed(2)})`;
1397 ctx.shadowColor = 'transparent';
1398 ctx.shadowBlur = 0;
1399 }
1400
1401 // Add slight random swaying to vines
1402 const swayAmount = Math.sin((now / 2000) + i) * 2; // Gentle swaying effect
1403 const adjustedX = x + swayAmount;
1404
1405 // Draw the character
1406 if (y < canvas.height) {
1407 // Adjust size for special characters
1408 if (leafChars.includes(currentChars[i][j])) {
1409 ctx.font = `${fontSize * 1.2}px 'JetBrains Mono', monospace`;
1410 ctx.fillText(currentChars[i][j], adjustedX, y);
1411 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Reset font
1412 } else {
1413 ctx.fillText(currentChars[i][j], adjustedX, y);
1414 }
1415 }
1416 }
1417
1418 // Move the vine down - slower for natural growth
1419 drops[i] += fontSize * (0.02 + Math.random() * 0.03);
1420
1421 // Reset vine when it reaches bottom or randomly (much less frequent)
1422 if (drops[i] > canvas.height * 2 || (Math.random() < 0.0005 && drops[i] > canvas.height * 0.6)) {
1423 drops[i] = Math.random() * -30;
1424 // Maybe change vine type
1425 if (Math.random() < 0.3) {
1426 columnTypes[i] = Math.random() < 0.7 ? 'vine' : 'leaf';
1427 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1428 }
1429 }
1430 }
1431
1432 // Draw cross connections between vines
1433 crossConnections.forEach((connection, index) => {
1434 const sourceX = connection.source * fontSize * 1.5;
1435 const targetX = connection.target * fontSize * 1.5;
1436 const y = connection.height;
1437 const heightIndex = Math.floor(y / fontSize);
1438
1439 // Calculate a safe display Y - make sure it's within the grown vines
1440 const safeY = Math.min(
1441 Math.min(drops[connection.source], drops[connection.target]),
1442 connection.height
1443 );
1444
1445 // Convert to display coords
1446 const displayY = Math.floor(safeY / fontSize) * fontSize;
1447
1448 // Only draw if connection is within visible area
1449 if (displayY < 0 || displayY > canvas.height) return;
1450
1451 // Connection age effect
1452 const age = now - connection.created;
1453 const maxAge = 20000; // 20 seconds lifetime for connections
1454
1455 // Remove old connections
1456 if (age > maxAge) {
1457 crossConnections.splice(index, 1);
1458 return;
1459 }
1460
1461 // Fade in/out effect
1462 let opacity = 1.0;
1463 if (age < 1000) {
1464 // Fade in
1465 opacity = age / 1000;
1466 } else if (age > maxAge - 2000) {
1467 // Fade out
1468 opacity = (maxAge - age) / 2000;
1469 }
1470
1471 // Draw connection
1472 const connectionWidth = Math.abs(targetX - sourceX);
1473 const steps = Math.ceil(connectionWidth / (fontSize * 0.8));
1474
1475 // Lighter green for branches
1476 ctx.fillStyle = `rgba(120, 255, 150, ${opacity.toFixed(2)})`;
1477 ctx.shadowColor = 'rgba(77, 250, 123, 0.4)';
1478 ctx.shadowBlur = 2;
1479
1480 // Draw branch character at each step
1481 let branchChar;
1482
1483 if (sourceX < targetX) {
1484 // Left to right
1485 branchChar = '─';
1486 } else {
1487 // Right to left
1488 branchChar = '─';
1489 }
1490
1491 for (let s = 0; s <= steps; s++) {
1492 // Calculate position
1493 const progress = s / steps;
1494 const stepX = sourceX + (targetX - sourceX) * progress;
1495 const wiggle = Math.sin(progress * Math.PI) * 5;
1496
1497 // Choose appropriate connection character
1498 let connChar = branchChar;
1499
1500 // Special characters for start, middle and end
1501 if (s === 0) {
1502 connChar = '├';
1503 } else if (s === steps) {
1504 connChar = '┤';
1505 } else if (s === Math.floor(steps/2)) {
1506 // Add a leaf or flower in the middle sometimes
1507 if (Math.random() < 0.3) {
1508 connChar = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1509 } else {
1510 connChar = s % 2 === 0 ? '┼' : '┴';
1511 }
1512 } else {
1513 // Occasional decorative elements
1514 if (Math.random() < 0.1) {
1515 connChar = '·';
1516 }
1517 }
1518
1519 ctx.fillText(connChar, stepX, displayY + wiggle);
1520 }
1521 });
1522
1523 // Schedule next frame
1524 requestAnimationFrame(drawVineEffect);
1525 }
1526
1527 // Start the animation
1528 drawVineEffect();
1529 // Add hover event listeners after DOM content is loaded
1530 function setupHoverEffects() {
1531 // Keep track of the currently active item
1532 let currentHoveredItem = null;
1533
1534 document.querySelectorAll('.feed-item').forEach(item => {
1535 item.addEventListener('mouseenter', () => {
1536 // Set this as current hovered item
1537 currentHoveredItem = item;
1538 });
1539
1540 // Track mouse position for the ripple effect
1541 item.addEventListener('mousemove', (e) => {
1542 // Get position relative to the element
1543 const rect = item.getBoundingClientRect();
1544 const x = ((e.clientX - rect.left) / rect.width) * 100;
1545 const y = ((e.clientY - rect.top) / rect.height) * 100;
1546
1547 // Set custom properties for the radial gradient
1548 item.style.setProperty('--mouse-x', `${x}%`);
1549 item.style.setProperty('--mouse-y', `${y}%`);
1550 });
1551 });
1552 }
1553
1554 // Tab switching functionality
1555 // Create global variables to store state
1556 let globalFeedObserver = null;
1557 let lastActiveYear = null;
1558 let lastActiveMonth = null;
1559
1560 function setupObserver(options) {
1561 // Create a new intersection observer for handling timeline scrolling
1562 return new IntersectionObserver((entries) => {
1563 entries.forEach(entry => {
1564 if (entry.isIntersecting) {
1565 const year = entry.target.getAttribute('data-year');
1566 const month = entry.target.getAttribute('data-month');
1567
1568 // Get the active tab
1569 const activeTab = document.querySelector('.tab-content.active');
1570 const activeTabId = activeTab.getAttribute('data-tab');
1571
1572 // Only process if we're on posts or links tab
1573 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) {
1574 // Clear all active classes
1575 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1576 el.classList.remove('active');
1577 });
1578
1579 // Set active classes
1580 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
1581 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
1582
1583 if (yearEl) {
1584 yearEl.classList.add('active');
1585 // Store the last active year globally
1586 lastActiveYear = year;
1587 }
1588 if (monthEl) {
1589 monthEl.classList.add('active');
1590 // Store the last active month globally
1591 lastActiveMonth = month;
1592 }
1593
1594 // Month headers are now simple inline elements, no need to toggle visibility
1595 }
1596 }
1597 });
1598 }, options);
1599 }
1600
1601 function setupTabs() {
1602 const tabButtons = document.querySelectorAll('.tab-button');
1603 const tabContents = document.querySelectorAll('.tab-content');
1604 const timeline = document.getElementById('timeline-sidebar');
1605
1606 tabButtons.forEach(button => {
1607 button.addEventListener('click', () => {
1608 const tabName = button.getAttribute('data-tab');
1609
1610 // Deactivate all tabs
1611 tabButtons.forEach(btn => btn.classList.remove('active'));
1612 tabContents.forEach(content => content.classList.remove('active'));
1613
1614 // Activate selected tab
1615 button.classList.add('active');
1616 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`);
1617 tabContent.classList.add('active');
1618
1619 // Month headers are now simple inline elements, no need to toggle visibility
1620
1621 // Show or hide timeline sidebar based on active tab
1622 if (tabName === 'people') {
1623 timeline.style.display = 'none';
1624 document.querySelector('.content').style.paddingLeft = '0';
1625 } else {
1626 timeline.style.display = 'flex';
1627 document.querySelector('.content').style.paddingLeft = 'var(--sidebar-width)';
1628
1629 // Reset timeline highlighting
1630 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1631 el.classList.remove('active');
1632 });
1633
1634 // Disconnect and recreate the observer to ensure proper tracking
1635 if (globalFeedObserver) {
1636 globalFeedObserver.disconnect();
1637 }
1638
1639 // Setup a new observer
1640 globalFeedObserver = setupObserver({
1641 root: null,
1642 rootMargin: '-80px 0px',
1643 threshold: 0.1
1644 });
1645
1646 // Observe all items in the active tab
1647 observeAllDateItems();
1648
1649 // Always scroll to top when switching tabs
1650 window.scrollTo({ top: 0, behavior: 'smooth' });
1651 }
1652 });
1653 });
1654 }
1655 const feedItemsContainer = document.getElementById('feed-items');
1656 const loadingContainer = document.getElementById('loading');
1657
1658 // Function to format date (only date, no time)
1659 function formatDate(dateString) {
1660 const date = new Date(dateString);
1661 return date.toLocaleDateString('en-US', {
1662 year: 'numeric',
1663 month: 'short',
1664 day: 'numeric'
1665 });
1666 }
1667
1668 // We no longer need preview processing functions
1669 // since we're displaying content as-is with HTML tags
1670
1671 // Function removed - we no longer toggle full content
1672
1673 // Removed the external links toggle function as it's no longer needed
1674
1675 // Reference toggle function removed - references are now shown with CSS on hover
1676
1677 try {
1678 // Fetch the Atom feed and threads data in parallel
1679 const [feedResponse, threadsResponse] = await Promise.all([
1680 fetch('eeg.xml'),
1681 fetch('threads.json')
1682 ]);
1683
1684 if (!feedResponse.ok) {
1685 throw new Error('Failed to fetch feed');
1686 }
1687
1688 if (!threadsResponse.ok) {
1689 throw new Error('Failed to fetch threads data');
1690 }
1691
1692 const xmlText = await feedResponse.text();
1693 const threadsData = await threadsResponse.json();
1694
1695 const parser = new DOMParser();
1696 const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
1697
1698 // Process feed entries
1699 const entries = xmlDoc.getElementsByTagName('entry');
1700 const sources = new Set();
1701
1702 // No longer updating the entry count element since it's been removed
1703
1704 // Map to store entries by ID for easy lookup
1705 const entriesById = {};
1706
1707 // First pass: extract all entries and build the ID map
1708 for (let i = 0; i < entries.length; i++) {
1709 const entry = entries[i];
1710
1711 // Extract entry data
1712 const id = entry.getElementsByTagName('id')[0]?.textContent || '';
1713 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title';
1714 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#';
1715 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0];
1716 const contentText = contentElement?.textContent || '';
1717 const contentType = contentElement?.getAttribute('type') || 'text';
1718 const published = entry.getElementsByTagName('published')[0]?.textContent ||
1719 entry.getElementsByTagName('updated')[0]?.textContent || '';
1720 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown';
1721 const categories = entry.getElementsByTagName('category');
1722
1723 // Extract source from category (we're using category to store source name)
1724 let source = 'Unknown Source';
1725 if (categories.length > 0) {
1726 source = categories[0].getAttribute('term');
1727 sources.add(source);
1728 }
1729
1730 // Properly handle the content based on content type
1731 let contentHtml;
1732 if (contentType === 'html' || contentType === 'text/html') {
1733 // For HTML content, create a div and set innerHTML
1734 contentHtml = contentText;
1735 } else {
1736 // For text content, escape it and preserve newlines
1737 contentHtml = contentText
1738 .replace(/&/g, '&')
1739 .replace(/</g, '<')
1740 .replace(/>/g, '>')
1741 .replace(/\n/g, '<br>');
1742 }
1743
1744 // Store the entry data
1745 entriesById[id] = {
1746 id,
1747 articleId: `article-${i}`,
1748 title,
1749 link,
1750 contentHtml, // Use the content as-is with HTML tags
1751 published,
1752 author,
1753 source,
1754 threadGroup: null,
1755 isThreadParent: false,
1756 threadParentId: null,
1757 inThread: false,
1758 threadPosition: 0,
1759 externalLinks: [],
1760 };
1761 }
1762
1763 // Process reference relationships and external links
1764 for (const entryId in entriesById) {
1765 if (threadsData[entryId]) {
1766 const threadInfo = threadsData[entryId];
1767 const entry = entriesById[entryId];
1768
1769 // Track external links for this entry
1770 entry.externalLinks = [];
1771 if (threadInfo.external_links && threadInfo.external_links.length > 0) {
1772 entry.externalLinks = threadInfo.external_links.map(link => ({
1773 url: link.url,
1774 normalized_url: link.normalized_url
1775 }));
1776 }
1777
1778 // Track references to other posts (outgoing links)
1779 entry.referencesTo = [];
1780 if (threadInfo.references && threadInfo.references.length > 0) {
1781 // Filter for only in-feed references
1782 threadInfo.references.forEach(ref => {
1783 if (ref.in_feed === true && entriesById[ref.id]) {
1784 entry.referencesTo.push({
1785 id: ref.id,
1786 title: ref.title,
1787 link: ref.link,
1788 author: entriesById[ref.id].author
1789 });
1790 }
1791 });
1792 }
1793
1794 // Track posts that reference this one (incoming links)
1795 entry.referencedBy = [];
1796 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) {
1797 // Filter for only in-feed references
1798 threadInfo.referenced_by.forEach(ref => {
1799 if (ref.in_feed === true && entriesById[ref.id]) {
1800 entry.referencedBy.push({
1801 id: ref.id,
1802 title: ref.title,
1803 link: ref.link,
1804 author: entriesById[ref.id].author
1805 });
1806 }
1807 });
1808 }
1809 }
1810 }
1811
1812 // Sort by date and create HTML
1813 const entriesArray = Object.values(entriesById);
1814 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published));
1815
1816 // Create a timeline structure by year/month
1817 const timeline = new Map();
1818 const monthNames = [
1819 'January', 'February', 'March', 'April', 'May', 'June',
1820 'July', 'August', 'September', 'October', 'November', 'December'
1821 ];
1822 const shortMonthNames = [
1823 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1824 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
1825 ];
1826
1827 // Group entries by year and month for the timeline
1828 entriesArray.forEach(entry => {
1829 const date = new Date(entry.published);
1830 const year = date.getFullYear();
1831 const month = date.getMonth();
1832
1833 if (!timeline.has(year)) {
1834 timeline.set(year, new Map());
1835 }
1836
1837 const yearMap = timeline.get(year);
1838 if (!yearMap.has(month)) {
1839 yearMap.set(month, []);
1840 }
1841
1842 yearMap.get(month).push(entry);
1843 });
1844
1845 // Process all entries in strict date order
1846 let entriesHTML = '';
1847 const processedArticleIds = new Set();
1848
1849 // Create a copy of entriesArray to process strictly by date
1850 const entriesByDate = [...entriesArray];
1851
1852 // Track current month/year for date headers
1853 let currentYear = null;
1854 let currentMonth = null;
1855
1856 // Process each entry in date order
1857 for (const entry of entriesByDate) {
1858 // Skip entries already processed
1859 if (processedArticleIds.has(entry.articleId)) continue;
1860
1861 const date = new Date(entry.published);
1862 const year = date.getFullYear();
1863 const month = date.getMonth();
1864 const dateAttr = `data-year="${year}" data-month="${month}"`;
1865
1866 // Check if we need to add a new month/year header
1867 if (currentYear !== year || currentMonth !== month) {
1868 currentYear = year;
1869 currentMonth = month;
1870
1871 entriesHTML += `
1872 <div class="month-year-header" ${dateAttr}>
1873 <div class="month-year-label">${monthNames[month]} ${year}</div>
1874 </div>`;
1875 }
1876
1877 // Function to get day with ordinal suffix
1878 function getDayWithOrdinal(date) {
1879 const day = date.getDate();
1880 let suffix = "th";
1881 if (day % 10 === 1 && day !== 11) {
1882 suffix = "st";
1883 } else if (day % 10 === 2 && day !== 12) {
1884 suffix = "nd";
1885 } else if (day % 10 === 3 && day !== 13) {
1886 suffix = "rd";
1887 }
1888 return day + suffix;
1889 }
1890
1891 // Add entry
1892 entriesHTML += `
1893 <article id="${entry.articleId}" class="feed-item" ${dateAttr}>
1894 <div class="feed-item-row">
1895 <div class="feed-item-date">${getDayWithOrdinal(date)} ${shortMonthNames[month]} ${year}</div>
1896 <div class="feed-item-author">${entry.author}</div>
1897 <div class="feed-item-content-wrapper">
1898 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div>
1899
1900 ${entry.externalLinks && entry.externalLinks.length > 0 ? `
1901 <div class="preview-links">
1902 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => {
1903 // Find the first link object with this URL
1904 const link = entry.externalLinks.find(l => l.url === uniqueUrl);
1905 const url = new URL(link.url);
1906 let displayText = url.hostname.replace('www.', '');
1907
1908 // Special handling for GitHub links
1909 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
1910 // Extract the parts from pathname (remove leading slash)
1911 const parts = url.pathname.substring(1).split('/').filter(part => part);
1912 if (parts.length >= 2) {
1913 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`;
1914 }
1915 }
1916
1917 // Special handling for Wikipedia links
1918 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
1919 const titlePart = url.pathname.split('/').pop();
1920 if (titlePart) {
1921 const title = decodeURIComponent(titlePart).replace(/_/g, ' ');
1922 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`;
1923 }
1924 }
1925
1926 // Special handling for Twitter/X links
1927 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
1928 const parts = url.pathname.substring(1).split('/').filter(part => part);
1929 if (parts.length >= 1) {
1930 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1931 }
1932 }
1933
1934 // Special handling for LinkedIn links
1935 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
1936 const parts = url.pathname.substring(1).split('/').filter(part => part);
1937 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`;
1938 if (parts.length >= 2 && parts[0] === 'in') {
1939 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`;
1940 }
1941 }
1942
1943 // Special handling for YouTube links
1944 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
1945 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`;
1946 // Try to get video title from URL parameters
1947 const videoId = url.searchParams.get('v');
1948 if (url.pathname.includes('watch') && videoId) {
1949 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`;
1950 }
1951 }
1952
1953 // Special handling for OCaml package links
1954 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
1955 const parts = url.pathname.substring(1).split('/').filter(part => part);
1956 if (parts.length >= 2) {
1957 const packageName = parts[1];
1958 displayText = `${packageName} (OCaml)`;
1959 }
1960 }
1961
1962 // Special handling for Medium links
1963 else if (url.hostname.includes('medium.com')) {
1964 const parts = url.pathname.substring(1).split('/').filter(part => part);
1965 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`;
1966 if (parts.length >= 1) {
1967 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1968 }
1969 }
1970
1971 // Special handling for Stack Overflow links
1972 else if (url.hostname.includes('stackoverflow.com')) {
1973 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`;
1974 if (url.pathname.includes('questions')) {
1975 const parts = url.pathname.split('/');
1976 const questionId = parts.find(part => /^\d+$/.test(part));
1977 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`;
1978 }
1979 }
1980
1981 // Special handling for Dev.to links
1982 else if (url.hostname === 'dev.to') {
1983 const parts = url.pathname.substring(1).split('/').filter(part => part);
1984 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`;
1985 if (parts.length >= 1) {
1986 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1987 }
1988 }
1989
1990 // Special handling for Reddit links
1991 else if (url.hostname.includes('reddit.com')) {
1992 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`;
1993 if (url.pathname.includes('/r/')) {
1994 const parts = url.pathname.split('/');
1995 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
1996 if (subreddit) {
1997 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`;
1998 }
1999 }
2000 }
2001
2002 // Special handling for Hacker News links
2003 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
2004 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`;
2005 if (url.pathname.includes('item')) {
2006 const itemId = url.searchParams.get('id');
2007 if (itemId) {
2008 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`;
2009 }
2010 }
2011 }
2012
2013 // Special handling for Bluesky links
2014 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
2015 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`;
2016 // Try to extract handle or post info
2017 const parts = url.pathname.substring(1).split('/').filter(part => part);
2018 if (parts.length >= 1) {
2019 if (parts[0] === 'profile') {
2020 // This is a profile link
2021 if (parts.length >= 2) {
2022 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`;
2023 }
2024 } else if (parts[0] === 'post') {
2025 // This is a post link
2026 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`;
2027 } else {
2028 // Assume it's a handle
2029 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
2030 }
2031 }
2032 }
2033
2034 // Academic paper detection - PDF files and academic domains
2035 else if (
2036 link.url.toLowerCase().endsWith('.pdf') ||
2037 url.hostname.includes('arxiv.org') ||
2038 url.hostname.includes('nature.com') ||
2039 url.hostname.includes('science.org') ||
2040 url.hostname.includes('mdpi.com') ||
2041 url.hostname.includes('doi.org')
2042 ) {
2043 // Set display text based on source
2044 if (url.hostname.includes('arxiv.org')) {
2045 // Try to extract arXiv ID
2046 const arxivIdMatch = url.pathname.match(/\d+\.\d+/);
2047 if (arxivIdMatch) {
2048 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${arxivIdMatch[0]}`;
2049 } else {
2050 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2051 }
2052 } else if (url.hostname.includes('nature.com')) {
2053 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2054 } else if (url.hostname.includes('science.org')) {
2055 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2056 } else if (url.hostname.includes('mdpi.com')) {
2057 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2058 } else if (link.url.toLowerCase().endsWith('.pdf')) {
2059 // For direct PDF links, try to get a meaningful filename
2060 const pathParts = url.pathname.split('/');
2061 const filename = pathParts[pathParts.length - 1];
2062 if (filename) {
2063 displayText = `<img src="solid-book-open.svg" width="13https://www.blogger.com/feeds/19062127/posts/default" height="14" style="vertical-align: middle; margin-right: 4px;"> ${decodeURIComponent(filename)}`;
2064 } else {
2065 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Document`;
2066 }
2067 }
2068 }
2069
2070 // Determine link type for styling and future reference
2071 let linkType = '';
2072 if (url.hostname.includes('github')) linkType = 'github';
2073 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia';
2074 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter';
2075 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin';
2076 else if (
2077 url.hostname.includes('youtube.com') ||
2078 url.hostname === 'youtu.be' ||
2079 url.hostname === 'watch.eeg.cl.cam.ac.uk' ||
2080 url.hostname === 'crank.recoil.org' ||
2081 url.hostname === 'watch.ocaml.org'
2082 ) linkType = 'youtube';
2083 else if (url.hostname.includes('medium.com')) linkType = 'medium';
2084 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow';
2085 else if (url.hostname === 'dev.to') linkType = 'dev';
2086 else if (url.hostname.includes('reddit.com')) linkType = 'reddit';
2087 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews';
2088 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky';
2089 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml';
2090 else if (
2091 link.url.toLowerCase().endsWith('.pdf') ||
2092 url.hostname.includes('arxiv.org') ||
2093 url.hostname.includes('nature.com') ||
2094 url.hostname.includes('science.org') ||
2095 url.hostname.includes('mdpi.com')
2096 ) linkType = 'academic';
2097
2098 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`;
2099 }).join(' ')}
2100 </div>
2101 ` : ''}
2102
2103 ${entry.referencesTo && entry.referencesTo.length > 0 ? `
2104 <div class="preview-references">
2105 ${entry.referencesTo.map(ref => `
2106 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a>
2107 `).join(' ')}
2108 </div>
2109 ` : ''}
2110
2111 ${entry.referencedBy && entry.referencedBy.length > 0 ? `
2112 <div class="preview-references">
2113 ${entry.referencedBy.map(ref => `
2114 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a>
2115 `).join(' ')}
2116 </div>
2117 ` : ''}
2118 </div>
2119 </div>
2120 </article>
2121 `;
2122
2123 processedArticleIds.add(entry.articleId);
2124 }
2125
2126 // All articles have been processed in the main loop above
2127
2128 // No longer updating the source count element since it's been removed
2129
2130 // No toggle functions needed anymore
2131
2132 // Build timeline sidebar
2133 const timelineSidebar = document.getElementById('timeline-sidebar');
2134 let timelineHTML = '';
2135
2136 // Sort years in descending order
2137 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a);
2138
2139 sortedYears.forEach(year => {
2140 const yearMap = timeline.get(year);
2141 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`;
2142
2143 // Sort months in descending order (Dec to Jan)
2144 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a);
2145
2146 sortedMonths.forEach(month => {
2147 const entries = yearMap.get(month);
2148 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`;
2149 });
2150 });
2151
2152 timelineSidebar.innerHTML = timelineHTML;
2153
2154 // Set up scroll observer to highlight timeline items
2155 const observerOptions = {
2156 root: null,
2157 rootMargin: '-80px 0px',
2158 threshold: 0.1
2159 };
2160
2161 // Skip adding data attributes - we've already done this during HTML generation
2162
2163 // Create observer to track which period is in view
2164 globalFeedObserver = setupObserver(observerOptions);
2165
2166 // Hide loading, show content
2167 loadingContainer.style.display = 'none';
2168 feedItemsContainer.innerHTML = entriesHTML;
2169
2170 // Month headers are now all visible
2171
2172 // If we have entries, set the most recent (first) entry's date as active in the timeline
2173 if (entriesArray.length > 0) {
2174 const mostRecentEntry = entriesArray[0];
2175 const date = new Date(mostRecentEntry.published);
2176 const year = date.getFullYear();
2177 const month = date.getMonth();
2178
2179 // Set most recent date as the active period in the timeline
2180 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2181 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
2182
2183 // Clear all active classes first
2184 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2185 el.classList.remove('active');
2186 });
2187
2188 // Add active classes to the appropriate year/month
2189 if (yearEl) {
2190 yearEl.classList.add('active');
2191 lastActiveYear = year;
2192 }
2193 if (monthEl) {
2194 monthEl.classList.add('active');
2195 lastActiveMonth = month;
2196 }
2197
2198 // Month headers are now simple inline elements, no need to toggle visibility
2199 }
2200
2201 // Helper function to observe all items with date attributes
2202 function observeAllDateItems() {
2203 // Observe all feed items for scroll tracking
2204 document.querySelectorAll('.feed-item').forEach(item => {
2205 globalFeedObserver.observe(item);
2206 });
2207
2208 // Also observe link items for timeline highlighting
2209 document.querySelectorAll('.link-item').forEach(item => {
2210 globalFeedObserver.observe(item);
2211 });
2212 }
2213
2214 // Initial observation of all items
2215 observeAllDateItems();
2216
2217 // Set initial display state for timeline based on initial active tab
2218 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab');
2219 if (initialActiveTab === 'people') {
2220 document.getElementById('timeline-sidebar').style.display = 'none';
2221 document.querySelector('.content').style.paddingLeft = '0';
2222 } else {
2223 // Initialize the last active date from the first visible item
2224 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item';
2225 const visibleItems = Array.from(document.querySelectorAll(selector))
2226 .filter(item => {
2227 const rect = item.getBoundingClientRect();
2228 return rect.top >= 0 && rect.bottom <= window.innerHeight;
2229 });
2230
2231 if (visibleItems.length > 0) {
2232 lastActiveYear = visibleItems[0].getAttribute('data-year');
2233 lastActiveMonth = visibleItems[0].getAttribute('data-month');
2234 }
2235 }
2236
2237 // Set up hover effects and ripple animations
2238 setupHoverEffects();
2239
2240 // Create a ripple effect that travels across the content area
2241 const feedContainer = document.querySelector('.feed-container');
2242 feedContainer.addEventListener('mousemove', (e) => {
2243 // Ripple between items as mouse moves
2244 const items = document.querySelectorAll('.feed-item, .link-item');
2245 items.forEach(item => {
2246 const rect = item.getBoundingClientRect();
2247 const centerX = rect.left + rect.width / 2;
2248 const centerY = rect.top + rect.height / 2;
2249
2250 // Calculate distance from mouse to center of item
2251 const dx = e.clientX - centerX;
2252 const dy = e.clientY - centerY;
2253 const distance = Math.sqrt(dx * dx + dy * dy);
2254
2255 // Calculate fade based on distance
2256 const maxDistance = 400; // max distance for effect
2257 const intensity = Math.max(0, 1 - (distance / maxDistance));
2258
2259 if (intensity > 0.05) {
2260 // Extremely subtle glow - minimized for optimal text readability
2261 item.style.boxShadow = `0 0 ${intensity * 8}px var(--hover-glow)`;
2262 item.style.transform = `scale(${1 + intensity * 0.005})`;
2263 item.style.transition = 'box-shadow 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
2264 } else {
2265 item.style.boxShadow = 'none';
2266 item.style.transform = 'scale(1)';
2267 }
2268 });
2269 });
2270
2271 // Add hover tracking for link items too
2272 document.querySelectorAll('.link-item').forEach(item => {
2273 item.addEventListener('mousemove', (e) => {
2274 // Get position relative to the element
2275 const rect = item.getBoundingClientRect();
2276 const x = ((e.clientX - rect.left) / rect.width) * 100;
2277 const y = ((e.clientY - rect.top) / rect.height) * 100;
2278
2279 // Set custom properties for the radial gradient
2280 item.style.setProperty('--mouse-x', `${x}%`);
2281 item.style.setProperty('--mouse-y', `${y}%`);
2282 });
2283 });
2284
2285 // Process all external links from entries
2286 const linksContainer = document.getElementById('link-items');
2287 const allExternalLinks = [];
2288
2289 // Collect all external links from all entries with metadata
2290 Object.values(entriesById).forEach(entry => {
2291 if (entry.externalLinks && entry.externalLinks.length > 0) {
2292 entry.externalLinks.forEach(link => {
2293 // Only process if it's a valid URL
2294 if (link.url) {
2295 try {
2296 const url = new URL(link.url);
2297
2298 // Create a link object with metadata
2299 allExternalLinks.push({
2300 url: link.url,
2301 normalized_url: link.normalized_url,
2302 source: entry.author,
2303 date: new Date(entry.published),
2304 sourceFeed: entry.source,
2305 sourceTitle: entry.title,
2306 sourceLink: entry.link
2307 });
2308 } catch (e) {
2309 // Skip invalid URLs
2310 console.warn("Invalid URL:", link.url);
2311 }
2312 }
2313 });
2314 }
2315 });
2316
2317 // Sort links by date (newest first)
2318 allExternalLinks.sort((a, b) => b.date - a.date);
2319
2320 // Deduplicate links (keeping most recent occurrence)
2321 const dedupedLinks = [];
2322 const seenUrls = new Set();
2323
2324 allExternalLinks.forEach(link => {
2325 // Deduplicate based on normalized URL
2326 if (!seenUrls.has(link.normalized_url)) {
2327 seenUrls.add(link.normalized_url);
2328 dedupedLinks.push(link);
2329 }
2330 });
2331
2332 // Generate HTML for links view
2333 let linksHTML = '';
2334
2335 // Track current month/year for date headers in links view
2336 let currentLinkYear = null;
2337 let currentLinkMonth = null;
2338
2339 dedupedLinks.forEach(link => {
2340 const date = link.date;
2341 const year = date.getFullYear();
2342 const month = date.getMonth();
2343 const dateFormatted = formatDate(date);
2344 const url = new URL(link.url);
2345 let displayText = url.hostname.replace('www.', '');
2346 let iconPath = '';
2347
2348 // Check if we need to add a new month/year header
2349 if (currentLinkYear !== year || currentLinkMonth !== month) {
2350 currentLinkYear = year;
2351 currentLinkMonth = month;
2352
2353 linksHTML += `
2354 <div class="month-year-header" data-year="${year}" data-month="${month}">
2355 <div class="month-year-label">${monthNames[month]} ${year}</div>
2356 </div>`;
2357 }
2358
2359 // Platform-specific display logic (same as in the main feed)
2360 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
2361 const parts = url.pathname.substring(1).split('/').filter(part => part);
2362 if (parts.length >= 2) {
2363 displayText = `${parts[0]}/${parts[1]}`;
2364 iconPath = 'brands-github.svg';
2365 }
2366 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
2367 const titlePart = url.pathname.split('/').pop();
2368 if (titlePart) {
2369 displayText = decodeURIComponent(titlePart).replace(/_/g, ' ');
2370 iconPath = 'brands-wikipedia-w.svg';
2371 }
2372 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
2373 const parts = url.pathname.substring(1).split('/').filter(part => part);
2374 if (parts.length >= 1) {
2375 displayText = `@${parts[0]}`;
2376 iconPath = 'brands-x-twitter.svg';
2377 }
2378 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
2379 iconPath = 'brands-linkedin.svg';
2380 displayText = 'LinkedIn';
2381 const parts = url.pathname.substring(1).split('/').filter(part => part);
2382 if (parts.length >= 2 && parts[0] === 'in') {
2383 displayText = parts[1];
2384 }
2385 } else if (
2386 url.hostname.includes('youtube.com') ||
2387 url.hostname === 'youtu.be' ||
2388 url.hostname === 'watch.eeg.cl.cam.ac.uk' ||
2389 url.hostname === 'crank.recoil.org' ||
2390 url.hostname === 'watch.ocaml.org'
2391 ) {
2392 iconPath = 'brands-youtube.svg';
2393
2394 // Custom display text for specific video platforms
2395 if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
2396 displayText = 'YouTube Video';
2397 } else if (url.hostname === 'watch.eeg.cl.cam.ac.uk') {
2398 displayText = 'EEG Video';
2399 } else if (url.hostname === 'crank.recoil.org') {
2400 displayText = 'Crank Video';
2401 } else if (url.hostname === 'watch.ocaml.org') {
2402 displayText = 'OCaml Video';
2403 } else {
2404 displayText = 'Video';
2405 }
2406 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
2407 const parts = url.pathname.substring(1).split('/').filter(part => part);
2408 if (parts.length >= 2) {
2409 const packageName = parts[1];
2410 displayText = `${packageName} (OCaml)`;
2411 }
2412 } else if (url.hostname.includes('medium.com')) {
2413 iconPath = 'brands-medium.svg';
2414 displayText = 'Medium';
2415 const parts = url.pathname.substring(1).split('/').filter(part => part);
2416 if (parts.length >= 1) {
2417 displayText = parts[0];
2418 }
2419 } else if (url.hostname.includes('stackoverflow.com')) {
2420 iconPath = 'brands-stack-overflow.svg';
2421 displayText = 'Stack Overflow';
2422 if (url.pathname.includes('questions')) {
2423 const parts = url.pathname.split('/');
2424 const questionId = parts.find(part => /^\d+$/.test(part));
2425 if (questionId) {
2426 displayText = `Q${questionId}`;
2427 }
2428 }
2429 } else if (url.hostname === 'dev.to') {
2430 iconPath = 'brands-dev.svg';
2431 displayText = 'DEV';
2432 const parts = url.pathname.substring(1).split('/').filter(part => part);
2433 if (parts.length >= 1) {
2434 displayText = parts[0];
2435 }
2436 } else if (url.hostname.includes('reddit.com')) {
2437 iconPath = 'brands-reddit.svg';
2438 displayText = 'Reddit';
2439 if (url.pathname.includes('/r/')) {
2440 const parts = url.pathname.split('/');
2441 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
2442 if (subreddit) {
2443 displayText = `r/${subreddit}`;
2444 }
2445 }
2446 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
2447 iconPath = 'brands-hacker-news.svg';
2448 displayText = 'Hacker News';
2449 if (url.pathname.includes('item')) {
2450 const itemId = url.searchParams.get('id');
2451 if (itemId) {
2452 displayText = `HN:${itemId}`;
2453 }
2454 }
2455 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
2456 iconPath = 'brands-bluesky.svg';
2457 displayText = 'Bluesky';
2458 const parts = url.pathname.substring(1).split('/').filter(part => part);
2459 if (parts.length >= 1) {
2460 if (parts[0] === 'profile' && parts.length >= 2) {
2461 displayText = `@${parts[1]}`;
2462 } else if (parts[0] === 'post') {
2463 displayText = 'Post';
2464 } else {
2465 displayText = `@${parts[0]}`;
2466 }
2467 }
2468 }
2469
2470 // Academic paper detection - PDF files and academic domains
2471 else if (
2472 link.url.toLowerCase().endsWith('.pdf') ||
2473 url.hostname.includes('arxiv.org') ||
2474 url.hostname.includes('nature.com') ||
2475 url.hostname.includes('science.org') ||
2476 url.hostname.includes('mdpi.com') ||
2477 url.hostname.includes('doi.org')
2478 ) {
2479 iconPath = 'solid-book-open.svg';
2480
2481 // Set display text based on source
2482 if (url.hostname.includes('arxiv.org')) {
2483 // Try to extract arXiv ID
2484 const arxivIdMatch = url.pathname.match(/\d+\.\d+/);
2485 if (arxivIdMatch) {
2486 displayText = arxivIdMatch[0];
2487 } else {
2488 displayText = 'Paper';
2489 }
2490 } else if (url.hostname.includes('nature.com')) {
2491 displayText = 'Paper';
2492 } else if (url.hostname.includes('science.org')) {
2493 displayText = 'Paper';
2494 } else if (url.hostname.includes('mdpi.com')) {
2495 displayText = 'Paper';
2496 } else if (link.url.toLowerCase().endsWith('.pdf')) {
2497 // For direct PDF links, try to get a meaningful filename
2498 const pathParts = url.pathname.split('/');
2499 const filename = pathParts[pathParts.length - 1];
2500 if (filename) {
2501 displayText = decodeURIComponent(filename);
2502 } else {
2503 displayText = 'Document';
2504 }
2505 }
2506 }
2507
2508 // Function to get day with ordinal suffix (reused)
2509 function getLinkDayWithOrdinal(date) {
2510 const day = date.getDate();
2511 let suffix = "th";
2512 if (day % 10 === 1 && day !== 11) {
2513 suffix = "st";
2514 } else if (day % 10 === 2 && day !== 12) {
2515 suffix = "nd";
2516 } else if (day % 10 === 3 && day !== 13) {
2517 suffix = "rd";
2518 }
2519 return day + suffix;
2520 }
2521
2522 // Create link item HTML
2523 linksHTML += `
2524 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}">
2525 <div class="link-item-date">${getLinkDayWithOrdinal(date)} ${shortMonthNames[date.getMonth()]} ${date.getFullYear()}</div>
2526 <div class="link-item-source" title="From: ${link.sourceTitle}">
2527 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;">
2528 ${link.source}
2529 </a>
2530 </div>
2531 <div class="link-item-content">
2532 <div class="link-item-url-container">
2533 <a href="${link.url}" class="link-item-url" target="_blank">
2534 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''}
2535 ${displayText}
2536 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span>
2537 </a>
2538 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank">
2539 <span class="link-source-icon">↗</span> ${link.sourceTitle}
2540 </a>
2541 </div>
2542 </div>
2543 </div>
2544 `;
2545 });
2546
2547 // Update the links container
2548 linksContainer.innerHTML = linksHTML;
2549
2550 // Month headers in links view are now all visible
2551
2552 // If we have links, set the most recent link's date as active in the timeline for the links tab
2553 if (dedupedLinks.length > 0) {
2554 const mostRecentLink = dedupedLinks[0];
2555 const linkDate = mostRecentLink.date;
2556 const linkYear = linkDate.getFullYear();
2557 const linkMonth = linkDate.getMonth();
2558
2559 // Add a flag to remember we've set a most recent link
2560 window.mostRecentLinkSet = {
2561 year: linkYear,
2562 month: linkMonth
2563 };
2564 }
2565
2566 // Process people data
2567 const peopleContainer = document.querySelector('.people-container');
2568 const peopleMap = new Map(); // Map to store people data
2569
2570 // Fetch the mapping.json file to get author information
2571 const mappingResponse = await fetch('mapping.json');
2572 if (!mappingResponse.ok) {
2573 throw new Error('Failed to fetch mapping data');
2574 }
2575 const mappingData = await mappingResponse.json();
2576
2577 // Process author information from mapping data
2578 Object.entries(mappingData).forEach(([feedUrl, info]) => {
2579 const { name, site } = info;
2580 if (!peopleMap.has(name)) {
2581 peopleMap.set(name, {
2582 name: name,
2583 site: site,
2584 feedUrl: feedUrl,
2585 posts: [],
2586 postCount: 0,
2587 mostRecent: null
2588 });
2589 }
2590 });
2591
2592 // Associate entries with authors
2593 entriesArray.forEach(entry => {
2594 // Find the person who matches this entry's author
2595 // (taking into account potential differences in formatting)
2596 const person = Array.from(peopleMap.values()).find(p =>
2597 p.name === entry.author ||
2598 entry.author.includes(p.name) ||
2599 p.name.includes(entry.author)
2600 );
2601
2602 if (person) {
2603 person.posts.push(entry);
2604 person.postCount++;
2605
2606 // Track most recent post date
2607 const entryDate = new Date(entry.published);
2608 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) {
2609 person.mostRecent = entry;
2610 }
2611 }
2612 });
2613
2614 // Generate HTML for people cards
2615 let peopleHTML = '';
2616 Array.from(peopleMap.values())
2617 .sort((a, b) => b.postCount - a.postCount) // Sort by post count
2618 .forEach(person => {
2619 const recentPosts = person.posts
2620 .sort((a, b) => new Date(b.published) - new Date(a.published))
2621 .slice(0, 3); // Get top 3 most recent posts
2622
2623 peopleHTML += `
2624 <div class="person-card">
2625 <div class="person-name">${person.name}</div>
2626 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div>
2627
2628 <div class="person-stats">
2629 <div class="person-stat">
2630 <div class="stat-value">${person.postCount}</div>
2631 <div class="stat-label">Posts</div>
2632 </div>
2633 <div class="person-stat">
2634 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div>
2635 <div class="stat-label">Latest</div>
2636 </div>
2637 </div>
2638
2639 ${recentPosts.length > 0 ? `
2640 <div class="person-recent">
2641 <div class="recent-title">RECENT POSTS</div>
2642 <div class="recent-posts">
2643 ${recentPosts.map(post => `
2644 <div class="recent-post">
2645 <a href="${post.link}" target="_blank">${post.title}</a>
2646 <div class="recent-post-date">${formatDate(post.published)}</div>
2647 </div>
2648 `).join('')}
2649 </div>
2650 </div>
2651 ` : ''}
2652 </div>
2653 `;
2654 });
2655
2656 peopleContainer.innerHTML = peopleHTML;
2657
2658 // Initialize tabs
2659 setupTabs();
2660
2661 // Setup link filtering based on checkboxes
2662 setupLinkFilters();
2663
2664 // Function to handle link filtering
2665 function setupLinkFilters() {
2666 const filterCheckboxes = document.querySelectorAll('.filter-checkbox');
2667
2668 filterCheckboxes.forEach(checkbox => {
2669 checkbox.addEventListener('change', updateFilter);
2670 });
2671
2672 function updateFilter() {
2673 // Get all checked filters
2674 const activeFilters = Array.from(document.querySelectorAll('.filter-checkbox:checked'))
2675 .map(checkbox => checkbox.getAttribute('data-filter'));
2676
2677 // Get all link items
2678 const allLinkItems = document.querySelectorAll('.link-item');
2679 const monthYearHeaders = document.querySelectorAll('.month-year-header');
2680
2681 // Show all items if no filters are selected
2682 if (activeFilters.length === 0) {
2683 allLinkItems.forEach(item => {
2684 item.style.display = '';
2685 });
2686 monthYearHeaders.forEach(header => {
2687 header.style.display = '';
2688 });
2689 return;
2690 }
2691
2692 // Track visible items per month/year
2693 const visibleByMonthYear = new Map();
2694
2695 // Process all items
2696 allLinkItems.forEach(item => {
2697 const linkUrl = item.querySelector('.link-item-url');
2698 let shouldShow = false;
2699
2700 // Papers filter - check for PDF icon
2701 if (activeFilters.includes('academic')) {
2702 const hasPdfIcon = linkUrl && (
2703 linkUrl.innerHTML.includes('solid-book-open.svg') ||
2704 (linkUrl.getAttribute('data-link-type') === 'academic')
2705 );
2706 if (hasPdfIcon) shouldShow = true;
2707 }
2708
2709 // Videos filter - check for YouTube icon or video domains
2710 if (activeFilters.includes('youtube')) {
2711 const hasYoutubeIcon = linkUrl && (
2712 linkUrl.innerHTML.includes('brands-youtube.svg') ||
2713 (linkUrl.getAttribute('data-link-type') === 'youtube')
2714 );
2715
2716 // Also check for specific video site URLs
2717 const url = linkUrl?.getAttribute('href');
2718 const isVideoSite = url && (
2719 url.includes('youtube.com') ||
2720 url.includes('youtu.be') ||
2721 url.includes('watch.eeg.cl.cam.ac.uk') ||
2722 url.includes('crank.recoil.org') ||
2723 url.includes('watch.ocaml.org')
2724 );
2725
2726 if (hasYoutubeIcon || isVideoSite) shouldShow = true;
2727 }
2728
2729 // Set visibility
2730 item.style.display = shouldShow ? '' : 'none';
2731
2732 // Track visible items by month/year
2733 if (shouldShow) {
2734 const year = item.getAttribute('data-year');
2735 const month = item.getAttribute('data-month');
2736 const key = `${year}-${month}`;
2737 visibleByMonthYear.set(key, (visibleByMonthYear.get(key) || 0) + 1);
2738 }
2739 });
2740
2741 // Hide month-year headers with no visible items
2742 monthYearHeaders.forEach(header => {
2743 const year = header.getAttribute('data-year');
2744 const month = header.getAttribute('data-month');
2745 const key = `${year}-${month}`;
2746
2747 if (visibleByMonthYear.has(key)) {
2748 header.style.display = ''; // Show if has visible items
2749 } else {
2750 header.style.display = 'none'; // Hide if no visible items
2751 }
2752 });
2753
2754 // Update timeline sidebar to match visible items
2755 const timelineYears = document.querySelectorAll('.timeline-year');
2756 const timelineMonths = document.querySelectorAll('.timeline-month');
2757
2758 // First get all years that have visible items
2759 const visibleYears = new Set();
2760 visibleByMonthYear.forEach((count, key) => {
2761 const [year] = key.split('-');
2762 visibleYears.add(year);
2763 });
2764
2765 // Hide years without visible items
2766 if (activeFilters.length > 0) {
2767 timelineYears.forEach(yearEl => {
2768 const year = yearEl.getAttribute('data-year');
2769 yearEl.style.display = visibleYears.has(year) ? '' : 'none';
2770 });
2771
2772 // Hide months without visible items
2773 timelineMonths.forEach(monthEl => {
2774 const year = monthEl.getAttribute('data-year');
2775 const month = monthEl.getAttribute('data-month');
2776 const key = `${year}-${month}`;
2777 monthEl.style.display = visibleByMonthYear.has(key) ? '' : 'none';
2778 });
2779 } else {
2780 // Show all timeline elements when no filters
2781 timelineYears.forEach(yearEl => yearEl.style.display = '');
2782 timelineMonths.forEach(monthEl => monthEl.style.display = '');
2783 }
2784 }
2785 }
2786
2787 // Make timeline items clickable to scroll to relevant posts or links
2788 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => {
2789 item.addEventListener('click', () => {
2790 const year = item.getAttribute('data-year');
2791 const month = item.getAttribute('data-month');
2792
2793 // Store the selected date globally
2794 lastActiveYear = year;
2795 if (month !== null && month !== undefined) {
2796 lastActiveMonth = month;
2797 }
2798
2799
2800 // Find the first element with this date
2801 let selector = `[data-year="${year}"]`;
2802 if (month !== null && month !== undefined) {
2803 selector += `[data-month="${month}"]`;
2804 }
2805
2806 // Get the active tab
2807 const activeTab = document.querySelector('.tab-content.active');
2808 const activeTabId = activeTab.getAttribute('data-tab');
2809
2810 // Look for the target within the active tab
2811 const targetItem = activeTab.querySelector(selector);
2812
2813 // If no matching items in this tab or people tab is active, do nothing
2814 if (targetItem && activeTabId !== 'people') {
2815 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
2816
2817 // Highlight the selected timeline period
2818 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2819 el.classList.remove('active');
2820 });
2821
2822 // Set active classes
2823 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2824 const monthEl = month !== null && month !== undefined ?
2825 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null;
2826
2827 if (yearEl) yearEl.classList.add('active');
2828 if (monthEl) monthEl.classList.add('active');
2829
2830 // Month headers are now simple inline elements, no need to toggle visibility
2831 }
2832 });
2833 });
2834
2835 } catch (error) {
2836 console.error('Error loading feed:', error);
2837 loadingContainer.style.display = 'none';
2838 feedItemsContainer.innerHTML = `
2839 <div class="error-message">
2840 <h3>Error Loading Feed</h3>
2841 <p>${error.message}</p>
2842 </div>
2843 `;
2844 }
2845 });
2846 </script>
2847</body>
2848</html>