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 /* Hide green dot indicators on mobile to prevent text overlap */
1120 .timeline-year::after,
1121 .timeline-month::after {
1122 display: none;
1123 }
1124
1125 /* Also hide the connecting line on mobile */
1126 .timeline-year::before,
1127 .timeline-month::before {
1128 display: none;
1129 }
1130 }
1131 </style>
1132</head>
1133<body>
1134 <canvas id="matrix-background"></canvas>
1135 <header>
1136 <div class="header-container">
1137 <div class="header-left">
1138 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;">
1139 <div class="logo">Atomic<span>EEG</span></div>
1140 </a>
1141 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div>
1142 </div>
1143 <div class="tabs">
1144 <button class="tab-button active" data-tab="posts">Posts</button>
1145 <button class="tab-button" data-tab="links">Links</button>
1146 <button class="tab-button" data-tab="people">Vibes</button>
1147 </div>
1148 </div>
1149 </header>
1150
1151 <main>
1152 <section class="content">
1153 <div id="loading">
1154 <div class="loading-spinner"></div>
1155 <p class="loading-text">Growing Content...</p>
1156 </div>
1157 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div>
1158 <div class="tab-content" data-tab="links">
1159 <div class="links-header">
1160 <div class="filter-container">
1161 <div class="filter-title">Filter:</div>
1162 <div class="filter-options">
1163 <label class="filter-option">
1164 <input type="checkbox" id="filter-papers" class="filter-checkbox" data-filter="academic">
1165 <span class="checkbox-custom"></span>
1166 <span class="filter-label">Papers</span>
1167 </label>
1168 <label class="filter-option">
1169 <input type="checkbox" id="filter-videos" class="filter-checkbox" data-filter="youtube">
1170 <span class="checkbox-custom"></span>
1171 <span class="filter-label">Videos</span>
1172 </label>
1173 </div>
1174 </div>
1175 </div>
1176 <div id="link-items" class="feed-container"></div>
1177 </div>
1178 <div id="people-items" class="tab-content" data-tab="people">
1179 <h2 class="people-header">EEG Sources</h2>
1180 <div class="people-container"></div>
1181 </div>
1182 </section>
1183 <aside class="timeline-sidebar" id="timeline-sidebar">
1184 <!-- Timeline will be populated via JavaScript -->
1185 </aside>
1186 </main>
1187
1188 <script>
1189 document.addEventListener('DOMContentLoaded', async () => {
1190 // Matrix background effect
1191 const canvas = document.getElementById('matrix-background');
1192 const ctx = canvas.getContext('2d');
1193
1194 // Set canvas size to match window
1195 function resizeCanvas() {
1196 canvas.width = window.innerWidth;
1197 canvas.height = window.innerHeight;
1198 }
1199 resizeCanvas();
1200 window.addEventListener('resize', resizeCanvas);
1201
1202 // Vine/plant-related characters and elements
1203 const vineChars = '┃┃│┋┇┊┆╽╿┴┬╵╷└┕┖┗┘┙┚┛╘╙╚╛╯╰╱╲⌠⌡╎▏▕⏐▌▐░▒▓◥◤◢◣⎸⎹│';
1204 const leafChars = '☘❀✿❁❃❇❈❉❊❋✣✤✥✦✧✩✪✫✬✭✮✾✿❀❁❂❃❄⚘♠♣⚜⚘☘';
1205 const branchChars = '┌┐┘└├┬┴┤┼─┄┈┉┊┋╱╲╳☂⚢⌒~∞≈≋⋆✧✦✫';
1206 const fontSize = 14;
1207 const columns = Math.floor(canvas.width / fontSize * 0.7); // Fewer columns for sparser vines
1208
1209 // Drop positions for each column
1210 const drops = [];
1211
1212 // Initialize drops at random positions
1213 for (let i = 0; i < columns; i++) {
1214 // Random starting position
1215 drops[i] = Math.random() * -canvas.height;
1216 }
1217
1218 // Set up column types - some will be vines, some will have leaves
1219 const columnTypes = [];
1220 for (let i = 0; i < columns; i++) {
1221 // 70% of columns are vines, 25% are leaves, 5% are cross-connections
1222 const rand = Math.random();
1223 if (rand < 0.7) {
1224 columnTypes[i] = 'vine';
1225 } else if (rand < 0.95) {
1226 columnTypes[i] = 'leaf';
1227 } else {
1228 columnTypes[i] = 'branch';
1229 }
1230 }
1231
1232 // Store connections between vines
1233 const connections = [];
1234
1235 // Helper function to find nearby columns
1236 function findNearbyColumns(columnIndex, maxDistance = 3) {
1237 const nearby = [];
1238 for (let i = 0; i < columns; i++) {
1239 if (i !== columnIndex && Math.abs(i - columnIndex) <= maxDistance) {
1240 nearby.push(i);
1241 }
1242 }
1243 return nearby;
1244 }
1245
1246 // Last time random chars were changed
1247 const lastCharChangeTime = [];
1248 // The current characters displayed
1249 const currentChars = [];
1250 // Width/thickness of vines
1251 const vineThickness = [];
1252
1253 for (let i = 0; i < columns; i++) {
1254 lastCharChangeTime[i] = [];
1255 currentChars[i] = [];
1256
1257 // Random vine thickness between 1-3
1258 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1259
1260 for (let j = 0; j < canvas.height / fontSize; j++) {
1261 lastCharChangeTime[i][j] = 0;
1262
1263 if (columnTypes[i] === 'vine') {
1264 // Choose vine characters based on position and thickness
1265 if (j === 0) {
1266 // Top of vine - might be a leaf or flower
1267 currentChars[i][j] = Math.random() < 0.6 ?
1268 leafChars.charAt(Math.floor(Math.random() * leafChars.length)) :
1269 vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1270 } else {
1271 // Main vine character
1272 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1273 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1274 }
1275 } else if (columnTypes[i] === 'leaf') {
1276 // Leaf character - only at top or occasional spots along the vine
1277 if (j === 0 || Math.random() < 0.2) {
1278 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1279 } else {
1280 // Connecting vine
1281 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * 5)); // Thin vine characters
1282 }
1283 } else if (columnTypes[i] === 'branch') {
1284 // This is a branching column - will form connections between vines
1285 if (j === 0) {
1286 // Top of branch might be a leaf or flower
1287 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1288 } else {
1289 // Branch characters - horizontal or diagonal connectors
1290 currentChars[i][j] = branchChars.charAt(Math.floor(Math.random() * branchChars.length));
1291 }
1292 }
1293 }
1294 }
1295
1296 // Time when animation started
1297 const startTime = Date.now();
1298
1299 // Track connections between vines
1300 const crossConnections = [];
1301
1302 // Draw the rainforest vine effect
1303 function drawVineEffect() {
1304 // Semi-transparent background to create fade effect
1305 ctx.fillStyle = 'rgba(10, 23, 15, 0.05)';
1306 ctx.fillRect(0, 0, canvas.width, canvas.height);
1307
1308 const now = Date.now();
1309
1310 // Set font
1311 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`;
1312 ctx.textAlign = 'center';
1313
1314 // First, create cross-connections
1315 // Create new cross-connections occasionally
1316 if (Math.random() < 0.01) {
1317 // Find a source vine that's grown enough
1318 const sourceIndex = Math.floor(Math.random() * columns);
1319 if (drops[sourceIndex] > 100 && columnTypes[sourceIndex] === 'vine') {
1320 // Find a nearby column to connect to
1321 const nearby = findNearbyColumns(sourceIndex, 3);
1322 if (nearby.length > 0) {
1323 const targetIndex = nearby[Math.floor(Math.random() * nearby.length)];
1324 if (drops[targetIndex] > 80) {
1325 // The height should be somewhere between the two vines
1326 const sourceHeight = drops[sourceIndex];
1327 const targetHeight = drops[targetIndex];
1328 const connectionHeight = Math.min(sourceHeight, targetHeight) * 0.8;
1329
1330 // Create the connection
1331 crossConnections.push({
1332 source: sourceIndex,
1333 target: targetIndex,
1334 height: connectionHeight,
1335 character: branchChars.charAt(Math.floor(Math.random() * branchChars.length)),
1336 created: now
1337 });
1338 }
1339 }
1340 }
1341 }
1342
1343 // For each column
1344 for (let i = 0; i < columns; i++) {
1345 // Calculate current position of this vine
1346 const x = i * fontSize * 1.5; // Space vines further apart
1347
1348 // For each character in this column
1349 for (let j = 0; j < Math.ceil(drops[i] / fontSize); j++) {
1350 const y = j * fontSize;
1351
1352 // Skip rendering some characters to create gaps in vines
1353 if (Math.random() < 0.05 && j > 3) continue;
1354
1355 // Calculate age of this character
1356 const charAge = now - lastCharChangeTime[i][j];
1357
1358 // Randomly change some characters over time - slower rate for natural movement
1359 if (j === 0 && (Math.random() < 0.005 || charAge > 8000)) {
1360 // Top character might change between leaves/flowers
1361 if (columnTypes[i] === 'leaf' || Math.random() < 0.6) {
1362 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1363 } else {
1364 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1365 }
1366 lastCharChangeTime[i][j] = now;
1367 } else if (j > 0 && Math.random() < 0.001) {
1368 // Occasionally grow new leaves along the vine
1369 if (Math.random() < 0.2) {
1370 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1371 } else {
1372 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1373 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1374 }
1375 lastCharChangeTime[i][j] = now;
1376 }
1377
1378 // Calculate distance from head of the vine
1379 const distanceFromHead = (drops[i] - y);
1380
1381 // Determine color based on position and type
1382 if (j === 0 && (currentChars[i][j] === '❀' || currentChars[i][j] === '✿' ||
1383 currentChars[i][j] === '❁' || currentChars[i][j] === '✾')) {
1384 // Flowers are more colorful - pinkish
1385 ctx.fillStyle = 'rgba(255, 180, 220, 0.9)';
1386 ctx.shadowColor = 'rgba(255, 150, 200, 0.6)';
1387 ctx.shadowBlur = 5;
1388 } else if (currentChars[i][j] === '☘' || leafChars.includes(currentChars[i][j])) {
1389 // Leaf characters are brightest with different green
1390 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1391 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1392 ctx.shadowBlur = 3;
1393 } else if (distanceFromHead < fontSize) {
1394 // Growing tip of vine is brightest
1395 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1396 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1397 ctx.shadowBlur = 5;
1398 } else if (distanceFromHead < fontSize * 8) {
1399 // Newer part of vine is brighter
1400 const opacity = 0.8 - (distanceFromHead / (fontSize * 10));
1401 ctx.fillStyle = `rgba(77, 180, 100, ${opacity.toFixed(2)})`;
1402 ctx.shadowColor = 'transparent';
1403 ctx.shadowBlur = 0;
1404 } else {
1405 // Older parts of vine are darker
1406 const opacity = Math.max(0, 0.4 - (distanceFromHead / (canvas.height * 2)));
1407 // Darker green for older vines
1408 ctx.fillStyle = `rgba(40, 120, 60, ${opacity.toFixed(2)})`;
1409 ctx.shadowColor = 'transparent';
1410 ctx.shadowBlur = 0;
1411 }
1412
1413 // Add slight random swaying to vines
1414 const swayAmount = Math.sin((now / 2000) + i) * 2; // Gentle swaying effect
1415 const adjustedX = x + swayAmount;
1416
1417 // Draw the character
1418 if (y < canvas.height) {
1419 // Adjust size for special characters
1420 if (leafChars.includes(currentChars[i][j])) {
1421 ctx.font = `${fontSize * 1.2}px 'JetBrains Mono', monospace`;
1422 ctx.fillText(currentChars[i][j], adjustedX, y);
1423 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Reset font
1424 } else {
1425 ctx.fillText(currentChars[i][j], adjustedX, y);
1426 }
1427 }
1428 }
1429
1430 // Move the vine down - slower for natural growth
1431 drops[i] += fontSize * (0.02 + Math.random() * 0.03);
1432
1433 // Reset vine when it reaches bottom or randomly (much less frequent)
1434 if (drops[i] > canvas.height * 2 || (Math.random() < 0.0005 && drops[i] > canvas.height * 0.6)) {
1435 drops[i] = Math.random() * -30;
1436 // Maybe change vine type
1437 if (Math.random() < 0.3) {
1438 columnTypes[i] = Math.random() < 0.7 ? 'vine' : 'leaf';
1439 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1440 }
1441 }
1442 }
1443
1444 // Draw cross connections between vines
1445 crossConnections.forEach((connection, index) => {
1446 const sourceX = connection.source * fontSize * 1.5;
1447 const targetX = connection.target * fontSize * 1.5;
1448 const y = connection.height;
1449 const heightIndex = Math.floor(y / fontSize);
1450
1451 // Calculate a safe display Y - make sure it's within the grown vines
1452 const safeY = Math.min(
1453 Math.min(drops[connection.source], drops[connection.target]),
1454 connection.height
1455 );
1456
1457 // Convert to display coords
1458 const displayY = Math.floor(safeY / fontSize) * fontSize;
1459
1460 // Only draw if connection is within visible area
1461 if (displayY < 0 || displayY > canvas.height) return;
1462
1463 // Connection age effect
1464 const age = now - connection.created;
1465 const maxAge = 20000; // 20 seconds lifetime for connections
1466
1467 // Remove old connections
1468 if (age > maxAge) {
1469 crossConnections.splice(index, 1);
1470 return;
1471 }
1472
1473 // Fade in/out effect
1474 let opacity = 1.0;
1475 if (age < 1000) {
1476 // Fade in
1477 opacity = age / 1000;
1478 } else if (age > maxAge - 2000) {
1479 // Fade out
1480 opacity = (maxAge - age) / 2000;
1481 }
1482
1483 // Draw connection
1484 const connectionWidth = Math.abs(targetX - sourceX);
1485 const steps = Math.ceil(connectionWidth / (fontSize * 0.8));
1486
1487 // Lighter green for branches
1488 ctx.fillStyle = `rgba(120, 255, 150, ${opacity.toFixed(2)})`;
1489 ctx.shadowColor = 'rgba(77, 250, 123, 0.4)';
1490 ctx.shadowBlur = 2;
1491
1492 // Draw branch character at each step
1493 let branchChar;
1494
1495 if (sourceX < targetX) {
1496 // Left to right
1497 branchChar = '─';
1498 } else {
1499 // Right to left
1500 branchChar = '─';
1501 }
1502
1503 for (let s = 0; s <= steps; s++) {
1504 // Calculate position
1505 const progress = s / steps;
1506 const stepX = sourceX + (targetX - sourceX) * progress;
1507 const wiggle = Math.sin(progress * Math.PI) * 5;
1508
1509 // Choose appropriate connection character
1510 let connChar = branchChar;
1511
1512 // Special characters for start, middle and end
1513 if (s === 0) {
1514 connChar = '├';
1515 } else if (s === steps) {
1516 connChar = '┤';
1517 } else if (s === Math.floor(steps/2)) {
1518 // Add a leaf or flower in the middle sometimes
1519 if (Math.random() < 0.3) {
1520 connChar = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1521 } else {
1522 connChar = s % 2 === 0 ? '┼' : '┴';
1523 }
1524 } else {
1525 // Occasional decorative elements
1526 if (Math.random() < 0.1) {
1527 connChar = '·';
1528 }
1529 }
1530
1531 ctx.fillText(connChar, stepX, displayY + wiggle);
1532 }
1533 });
1534
1535 // Schedule next frame
1536 requestAnimationFrame(drawVineEffect);
1537 }
1538
1539 // Start the animation
1540 drawVineEffect();
1541 // Add hover event listeners after DOM content is loaded
1542 function setupHoverEffects() {
1543 // Keep track of the currently active item
1544 let currentHoveredItem = null;
1545
1546 document.querySelectorAll('.feed-item').forEach(item => {
1547 item.addEventListener('mouseenter', () => {
1548 // Set this as current hovered item
1549 currentHoveredItem = item;
1550 });
1551
1552 // Track mouse position for the ripple effect
1553 item.addEventListener('mousemove', (e) => {
1554 // Get position relative to the element
1555 const rect = item.getBoundingClientRect();
1556 const x = ((e.clientX - rect.left) / rect.width) * 100;
1557 const y = ((e.clientY - rect.top) / rect.height) * 100;
1558
1559 // Set custom properties for the radial gradient
1560 item.style.setProperty('--mouse-x', `${x}%`);
1561 item.style.setProperty('--mouse-y', `${y}%`);
1562 });
1563 });
1564 }
1565
1566 // Tab switching functionality
1567 // Create global variables to store state
1568 let globalFeedObserver = null;
1569 let lastActiveYear = null;
1570 let lastActiveMonth = null;
1571
1572 function setupObserver(options) {
1573 // Create a new intersection observer for handling timeline scrolling
1574 return new IntersectionObserver((entries) => {
1575 entries.forEach(entry => {
1576 if (entry.isIntersecting) {
1577 const year = entry.target.getAttribute('data-year');
1578 const month = entry.target.getAttribute('data-month');
1579
1580 // Get the active tab
1581 const activeTab = document.querySelector('.tab-content.active');
1582 const activeTabId = activeTab.getAttribute('data-tab');
1583
1584 // Only process if we're on posts or links tab
1585 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) {
1586 // Clear all active classes
1587 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1588 el.classList.remove('active');
1589 });
1590
1591 // Set active classes
1592 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
1593 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
1594
1595 if (yearEl) {
1596 yearEl.classList.add('active');
1597 // Store the last active year globally
1598 lastActiveYear = year;
1599 }
1600 if (monthEl) {
1601 monthEl.classList.add('active');
1602 // Store the last active month globally
1603 lastActiveMonth = month;
1604 }
1605
1606 // Month headers are now simple inline elements, no need to toggle visibility
1607 }
1608 }
1609 });
1610 }, options);
1611 }
1612
1613 function setupTabs() {
1614 const tabButtons = document.querySelectorAll('.tab-button');
1615 const tabContents = document.querySelectorAll('.tab-content');
1616 const timeline = document.getElementById('timeline-sidebar');
1617
1618 tabButtons.forEach(button => {
1619 button.addEventListener('click', () => {
1620 const tabName = button.getAttribute('data-tab');
1621
1622 // Deactivate all tabs
1623 tabButtons.forEach(btn => btn.classList.remove('active'));
1624 tabContents.forEach(content => content.classList.remove('active'));
1625
1626 // Activate selected tab
1627 button.classList.add('active');
1628 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`);
1629 tabContent.classList.add('active');
1630
1631 // Month headers are now simple inline elements, no need to toggle visibility
1632
1633 // Show or hide timeline sidebar based on active tab
1634 if (tabName === 'people') {
1635 timeline.style.display = 'none';
1636 document.querySelector('.content').style.paddingLeft = '0';
1637 } else {
1638 timeline.style.display = 'flex';
1639 document.querySelector('.content').style.paddingLeft = 'var(--sidebar-width)';
1640
1641 // Reset timeline highlighting
1642 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1643 el.classList.remove('active');
1644 });
1645
1646 // Disconnect and recreate the observer to ensure proper tracking
1647 if (globalFeedObserver) {
1648 globalFeedObserver.disconnect();
1649 }
1650
1651 // Setup a new observer
1652 globalFeedObserver = setupObserver({
1653 root: null,
1654 rootMargin: '-80px 0px',
1655 threshold: 0.1
1656 });
1657
1658 // Observe all items in the active tab
1659 observeAllDateItems();
1660
1661 // Always scroll to top when switching tabs
1662 window.scrollTo({ top: 0, behavior: 'smooth' });
1663 }
1664 });
1665 });
1666 }
1667 const feedItemsContainer = document.getElementById('feed-items');
1668 const loadingContainer = document.getElementById('loading');
1669
1670 // Function to format date (only date, no time)
1671 function formatDate(dateString) {
1672 const date = new Date(dateString);
1673 return date.toLocaleDateString('en-US', {
1674 year: 'numeric',
1675 month: 'short',
1676 day: 'numeric'
1677 });
1678 }
1679
1680 // We no longer need preview processing functions
1681 // since we're displaying content as-is with HTML tags
1682
1683 // Function removed - we no longer toggle full content
1684
1685 // Removed the external links toggle function as it's no longer needed
1686
1687 // Reference toggle function removed - references are now shown with CSS on hover
1688
1689 try {
1690 // Fetch the Atom feed and threads data in parallel
1691 const [feedResponse, threadsResponse] = await Promise.all([
1692 fetch('eeg.xml'),
1693 fetch('threads.json')
1694 ]);
1695
1696 if (!feedResponse.ok) {
1697 throw new Error('Failed to fetch feed');
1698 }
1699
1700 if (!threadsResponse.ok) {
1701 throw new Error('Failed to fetch threads data');
1702 }
1703
1704 const xmlText = await feedResponse.text();
1705 const threadsData = await threadsResponse.json();
1706
1707 const parser = new DOMParser();
1708 const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
1709
1710 // Process feed entries
1711 const entries = xmlDoc.getElementsByTagName('entry');
1712 const sources = new Set();
1713
1714 // No longer updating the entry count element since it's been removed
1715
1716 // Map to store entries by ID for easy lookup
1717 const entriesById = {};
1718
1719 // First pass: extract all entries and build the ID map
1720 for (let i = 0; i < entries.length; i++) {
1721 const entry = entries[i];
1722
1723 // Extract entry data
1724 const id = entry.getElementsByTagName('id')[0]?.textContent || '';
1725 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title';
1726 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#';
1727 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0];
1728 const contentText = contentElement?.textContent || '';
1729 const contentType = contentElement?.getAttribute('type') || 'text';
1730 const published = entry.getElementsByTagName('published')[0]?.textContent ||
1731 entry.getElementsByTagName('updated')[0]?.textContent || '';
1732 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown';
1733 const categories = entry.getElementsByTagName('category');
1734
1735 // Extract source from category (we're using category to store source name)
1736 let source = 'Unknown Source';
1737 if (categories.length > 0) {
1738 source = categories[0].getAttribute('term');
1739 sources.add(source);
1740 }
1741
1742 // Properly handle the content based on content type
1743 let contentHtml;
1744 if (contentType === 'html' || contentType === 'text/html') {
1745 // For HTML content, create a div and set innerHTML
1746 contentHtml = contentText;
1747 } else {
1748 // For text content, escape it and preserve newlines
1749 contentHtml = contentText
1750 .replace(/&/g, '&')
1751 .replace(/</g, '<')
1752 .replace(/>/g, '>')
1753 .replace(/\n/g, '<br>');
1754 }
1755
1756 // Store the entry data
1757 entriesById[id] = {
1758 id,
1759 articleId: `article-${i}`,
1760 title,
1761 link,
1762 contentHtml, // Use the content as-is with HTML tags
1763 published,
1764 author,
1765 source,
1766 threadGroup: null,
1767 isThreadParent: false,
1768 threadParentId: null,
1769 inThread: false,
1770 threadPosition: 0,
1771 externalLinks: [],
1772 };
1773 }
1774
1775 // Process reference relationships and external links
1776 for (const entryId in entriesById) {
1777 if (threadsData[entryId]) {
1778 const threadInfo = threadsData[entryId];
1779 const entry = entriesById[entryId];
1780
1781 // Track external links for this entry
1782 entry.externalLinks = [];
1783 if (threadInfo.external_links && threadInfo.external_links.length > 0) {
1784 entry.externalLinks = threadInfo.external_links.map(link => ({
1785 url: link.url,
1786 normalized_url: link.normalized_url
1787 }));
1788 }
1789
1790 // Track references to other posts (outgoing links)
1791 entry.referencesTo = [];
1792 if (threadInfo.references && threadInfo.references.length > 0) {
1793 // Filter for only in-feed references
1794 threadInfo.references.forEach(ref => {
1795 if (ref.in_feed === true && entriesById[ref.id]) {
1796 entry.referencesTo.push({
1797 id: ref.id,
1798 title: ref.title,
1799 link: ref.link,
1800 author: entriesById[ref.id].author
1801 });
1802 }
1803 });
1804 }
1805
1806 // Track posts that reference this one (incoming links)
1807 entry.referencedBy = [];
1808 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) {
1809 // Filter for only in-feed references
1810 threadInfo.referenced_by.forEach(ref => {
1811 if (ref.in_feed === true && entriesById[ref.id]) {
1812 entry.referencedBy.push({
1813 id: ref.id,
1814 title: ref.title,
1815 link: ref.link,
1816 author: entriesById[ref.id].author
1817 });
1818 }
1819 });
1820 }
1821 }
1822 }
1823
1824 // Sort by date and create HTML
1825 const entriesArray = Object.values(entriesById);
1826 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published));
1827
1828 // Create a timeline structure by year/month
1829 const timeline = new Map();
1830 const monthNames = [
1831 'January', 'February', 'March', 'April', 'May', 'June',
1832 'July', 'August', 'September', 'October', 'November', 'December'
1833 ];
1834 const shortMonthNames = [
1835 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1836 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
1837 ];
1838
1839 // Group entries by year and month for the timeline
1840 entriesArray.forEach(entry => {
1841 const date = new Date(entry.published);
1842 const year = date.getFullYear();
1843 const month = date.getMonth();
1844
1845 if (!timeline.has(year)) {
1846 timeline.set(year, new Map());
1847 }
1848
1849 const yearMap = timeline.get(year);
1850 if (!yearMap.has(month)) {
1851 yearMap.set(month, []);
1852 }
1853
1854 yearMap.get(month).push(entry);
1855 });
1856
1857 // Process all entries in strict date order
1858 let entriesHTML = '';
1859 const processedArticleIds = new Set();
1860
1861 // Create a copy of entriesArray to process strictly by date
1862 const entriesByDate = [...entriesArray];
1863
1864 // Track current month/year for date headers
1865 let currentYear = null;
1866 let currentMonth = null;
1867
1868 // Process each entry in date order
1869 for (const entry of entriesByDate) {
1870 // Skip entries already processed
1871 if (processedArticleIds.has(entry.articleId)) continue;
1872
1873 const date = new Date(entry.published);
1874 const year = date.getFullYear();
1875 const month = date.getMonth();
1876 const dateAttr = `data-year="${year}" data-month="${month}"`;
1877
1878 // Check if we need to add a new month/year header
1879 if (currentYear !== year || currentMonth !== month) {
1880 currentYear = year;
1881 currentMonth = month;
1882
1883 entriesHTML += `
1884 <div class="month-year-header" ${dateAttr}>
1885 <div class="month-year-label">${monthNames[month]} ${year}</div>
1886 </div>`;
1887 }
1888
1889 // Function to get day with ordinal suffix
1890 function getDayWithOrdinal(date) {
1891 const day = date.getDate();
1892 let suffix = "th";
1893 if (day % 10 === 1 && day !== 11) {
1894 suffix = "st";
1895 } else if (day % 10 === 2 && day !== 12) {
1896 suffix = "nd";
1897 } else if (day % 10 === 3 && day !== 13) {
1898 suffix = "rd";
1899 }
1900 return day + suffix;
1901 }
1902
1903 // Add entry
1904 entriesHTML += `
1905 <article id="${entry.articleId}" class="feed-item" ${dateAttr}>
1906 <div class="feed-item-row">
1907 <div class="feed-item-date">${getDayWithOrdinal(date)} ${shortMonthNames[month]} ${year}</div>
1908 <div class="feed-item-author">${entry.author}</div>
1909 <div class="feed-item-content-wrapper">
1910 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div>
1911
1912 ${entry.externalLinks && entry.externalLinks.length > 0 ? `
1913 <div class="preview-links">
1914 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => {
1915 // Find the first link object with this URL
1916 const link = entry.externalLinks.find(l => l.url === uniqueUrl);
1917 const url = new URL(link.url);
1918 let displayText = url.hostname.replace('www.', '');
1919
1920 // Special handling for GitHub links
1921 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
1922 // Extract the parts from pathname (remove leading slash)
1923 const parts = url.pathname.substring(1).split('/').filter(part => part);
1924 if (parts.length >= 2) {
1925 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`;
1926 }
1927 }
1928
1929 // Special handling for Wikipedia links
1930 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
1931 const titlePart = url.pathname.split('/').pop();
1932 if (titlePart) {
1933 const title = decodeURIComponent(titlePart).replace(/_/g, ' ');
1934 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`;
1935 }
1936 }
1937
1938 // Special handling for Twitter/X links
1939 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
1940 const parts = url.pathname.substring(1).split('/').filter(part => part);
1941 if (parts.length >= 1) {
1942 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1943 }
1944 }
1945
1946 // Special handling for LinkedIn links
1947 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
1948 const parts = url.pathname.substring(1).split('/').filter(part => part);
1949 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`;
1950 if (parts.length >= 2 && parts[0] === 'in') {
1951 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`;
1952 }
1953 }
1954
1955 // Special handling for YouTube links
1956 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
1957 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`;
1958 // Try to get video title from URL parameters
1959 const videoId = url.searchParams.get('v');
1960 if (url.pathname.includes('watch') && videoId) {
1961 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`;
1962 }
1963 }
1964
1965 // Special handling for OCaml package links
1966 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
1967 const parts = url.pathname.substring(1).split('/').filter(part => part);
1968 if (parts.length >= 2) {
1969 const packageName = parts[1];
1970 displayText = `${packageName} (OCaml)`;
1971 }
1972 }
1973
1974 // Special handling for Medium links
1975 else if (url.hostname.includes('medium.com')) {
1976 const parts = url.pathname.substring(1).split('/').filter(part => part);
1977 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`;
1978 if (parts.length >= 1) {
1979 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1980 }
1981 }
1982
1983 // Special handling for Stack Overflow links
1984 else if (url.hostname.includes('stackoverflow.com')) {
1985 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`;
1986 if (url.pathname.includes('questions')) {
1987 const parts = url.pathname.split('/');
1988 const questionId = parts.find(part => /^\d+$/.test(part));
1989 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`;
1990 }
1991 }
1992
1993 // Special handling for Dev.to links
1994 else if (url.hostname === 'dev.to') {
1995 const parts = url.pathname.substring(1).split('/').filter(part => part);
1996 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`;
1997 if (parts.length >= 1) {
1998 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1999 }
2000 }
2001
2002 // Special handling for Reddit links
2003 else if (url.hostname.includes('reddit.com')) {
2004 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`;
2005 if (url.pathname.includes('/r/')) {
2006 const parts = url.pathname.split('/');
2007 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
2008 if (subreddit) {
2009 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`;
2010 }
2011 }
2012 }
2013
2014 // Special handling for Hacker News links
2015 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
2016 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`;
2017 if (url.pathname.includes('item')) {
2018 const itemId = url.searchParams.get('id');
2019 if (itemId) {
2020 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`;
2021 }
2022 }
2023 }
2024
2025 // Special handling for Bluesky links
2026 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
2027 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`;
2028 // Try to extract handle or post info
2029 const parts = url.pathname.substring(1).split('/').filter(part => part);
2030 if (parts.length >= 1) {
2031 if (parts[0] === 'profile') {
2032 // This is a profile link
2033 if (parts.length >= 2) {
2034 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`;
2035 }
2036 } else if (parts[0] === 'post') {
2037 // This is a post link
2038 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`;
2039 } else {
2040 // Assume it's a handle
2041 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
2042 }
2043 }
2044 }
2045
2046 // Academic paper detection - PDF files and academic domains
2047 else if (
2048 link.url.toLowerCase().endsWith('.pdf') ||
2049 url.hostname.includes('arxiv.org') ||
2050 url.hostname.includes('nature.com') ||
2051 url.hostname.includes('science.org') ||
2052 url.hostname.includes('mdpi.com') ||
2053 url.hostname.includes('doi.org')
2054 ) {
2055 // Set display text based on source
2056 if (url.hostname.includes('arxiv.org')) {
2057 // Try to extract arXiv ID
2058 const arxivIdMatch = url.pathname.match(/\d+\.\d+/);
2059 if (arxivIdMatch) {
2060 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${arxivIdMatch[0]}`;
2061 } else {
2062 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2063 }
2064 } else if (url.hostname.includes('nature.com')) {
2065 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2066 } else if (url.hostname.includes('science.org')) {
2067 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2068 } else if (url.hostname.includes('mdpi.com')) {
2069 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`;
2070 } else if (link.url.toLowerCase().endsWith('.pdf')) {
2071 // For direct PDF links, try to get a meaningful filename
2072 const pathParts = url.pathname.split('/');
2073 const filename = pathParts[pathParts.length - 1];
2074 if (filename) {
2075 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)}`;
2076 } else {
2077 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Document`;
2078 }
2079 }
2080 }
2081
2082 // Determine link type for styling and future reference
2083 let linkType = '';
2084 if (url.hostname.includes('github')) linkType = 'github';
2085 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia';
2086 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter';
2087 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin';
2088 else if (
2089 url.hostname.includes('youtube.com') ||
2090 url.hostname === 'youtu.be' ||
2091 url.hostname === 'watch.eeg.cl.cam.ac.uk' ||
2092 url.hostname === 'crank.recoil.org' ||
2093 url.hostname === 'watch.ocaml.org'
2094 ) linkType = 'youtube';
2095 else if (url.hostname.includes('medium.com')) linkType = 'medium';
2096 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow';
2097 else if (url.hostname === 'dev.to') linkType = 'dev';
2098 else if (url.hostname.includes('reddit.com')) linkType = 'reddit';
2099 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews';
2100 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky';
2101 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml';
2102 else if (
2103 link.url.toLowerCase().endsWith('.pdf') ||
2104 url.hostname.includes('arxiv.org') ||
2105 url.hostname.includes('nature.com') ||
2106 url.hostname.includes('science.org') ||
2107 url.hostname.includes('mdpi.com')
2108 ) linkType = 'academic';
2109
2110 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`;
2111 }).join(' ')}
2112 </div>
2113 ` : ''}
2114
2115 ${entry.referencesTo && entry.referencesTo.length > 0 ? `
2116 <div class="preview-references">
2117 ${entry.referencesTo.map(ref => `
2118 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a>
2119 `).join(' ')}
2120 </div>
2121 ` : ''}
2122
2123 ${entry.referencedBy && entry.referencedBy.length > 0 ? `
2124 <div class="preview-references">
2125 ${entry.referencedBy.map(ref => `
2126 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a>
2127 `).join(' ')}
2128 </div>
2129 ` : ''}
2130 </div>
2131 </div>
2132 </article>
2133 `;
2134
2135 processedArticleIds.add(entry.articleId);
2136 }
2137
2138 // All articles have been processed in the main loop above
2139
2140 // No longer updating the source count element since it's been removed
2141
2142 // No toggle functions needed anymore
2143
2144 // Build timeline sidebar
2145 const timelineSidebar = document.getElementById('timeline-sidebar');
2146 let timelineHTML = '';
2147
2148 // Sort years in descending order
2149 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a);
2150
2151 sortedYears.forEach(year => {
2152 const yearMap = timeline.get(year);
2153 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`;
2154
2155 // Sort months in descending order (Dec to Jan)
2156 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a);
2157
2158 sortedMonths.forEach(month => {
2159 const entries = yearMap.get(month);
2160 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`;
2161 });
2162 });
2163
2164 timelineSidebar.innerHTML = timelineHTML;
2165
2166 // Set up scroll observer to highlight timeline items
2167 const observerOptions = {
2168 root: null,
2169 rootMargin: '-80px 0px',
2170 threshold: 0.1
2171 };
2172
2173 // Skip adding data attributes - we've already done this during HTML generation
2174
2175 // Create observer to track which period is in view
2176 globalFeedObserver = setupObserver(observerOptions);
2177
2178 // Hide loading, show content
2179 loadingContainer.style.display = 'none';
2180 feedItemsContainer.innerHTML = entriesHTML;
2181
2182 // Month headers are now all visible
2183
2184 // If we have entries, set the most recent (first) entry's date as active in the timeline
2185 if (entriesArray.length > 0) {
2186 const mostRecentEntry = entriesArray[0];
2187 const date = new Date(mostRecentEntry.published);
2188 const year = date.getFullYear();
2189 const month = date.getMonth();
2190
2191 // Set most recent date as the active period in the timeline
2192 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2193 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
2194
2195 // Clear all active classes first
2196 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2197 el.classList.remove('active');
2198 });
2199
2200 // Add active classes to the appropriate year/month
2201 if (yearEl) {
2202 yearEl.classList.add('active');
2203 lastActiveYear = year;
2204 }
2205 if (monthEl) {
2206 monthEl.classList.add('active');
2207 lastActiveMonth = month;
2208 }
2209
2210 // Month headers are now simple inline elements, no need to toggle visibility
2211 }
2212
2213 // Helper function to observe all items with date attributes
2214 function observeAllDateItems() {
2215 // Observe all feed items for scroll tracking
2216 document.querySelectorAll('.feed-item').forEach(item => {
2217 globalFeedObserver.observe(item);
2218 });
2219
2220 // Also observe link items for timeline highlighting
2221 document.querySelectorAll('.link-item').forEach(item => {
2222 globalFeedObserver.observe(item);
2223 });
2224 }
2225
2226 // Initial observation of all items
2227 observeAllDateItems();
2228
2229 // Set initial display state for timeline based on initial active tab
2230 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab');
2231 if (initialActiveTab === 'people') {
2232 document.getElementById('timeline-sidebar').style.display = 'none';
2233 document.querySelector('.content').style.paddingLeft = '0';
2234 } else {
2235 // Initialize the last active date from the first visible item
2236 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item';
2237 const visibleItems = Array.from(document.querySelectorAll(selector))
2238 .filter(item => {
2239 const rect = item.getBoundingClientRect();
2240 return rect.top >= 0 && rect.bottom <= window.innerHeight;
2241 });
2242
2243 if (visibleItems.length > 0) {
2244 lastActiveYear = visibleItems[0].getAttribute('data-year');
2245 lastActiveMonth = visibleItems[0].getAttribute('data-month');
2246 }
2247 }
2248
2249 // Set up hover effects and ripple animations
2250 setupHoverEffects();
2251
2252 // Create a ripple effect that travels across the content area
2253 const feedContainer = document.querySelector('.feed-container');
2254 feedContainer.addEventListener('mousemove', (e) => {
2255 // Ripple between items as mouse moves
2256 const items = document.querySelectorAll('.feed-item, .link-item');
2257 items.forEach(item => {
2258 const rect = item.getBoundingClientRect();
2259 const centerX = rect.left + rect.width / 2;
2260 const centerY = rect.top + rect.height / 2;
2261
2262 // Calculate distance from mouse to center of item
2263 const dx = e.clientX - centerX;
2264 const dy = e.clientY - centerY;
2265 const distance = Math.sqrt(dx * dx + dy * dy);
2266
2267 // Calculate fade based on distance
2268 const maxDistance = 400; // max distance for effect
2269 const intensity = Math.max(0, 1 - (distance / maxDistance));
2270
2271 if (intensity > 0.05) {
2272 // Extremely subtle glow - minimized for optimal text readability
2273 item.style.boxShadow = `0 0 ${intensity * 8}px var(--hover-glow)`;
2274 item.style.transform = `scale(${1 + intensity * 0.005})`;
2275 item.style.transition = 'box-shadow 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
2276 } else {
2277 item.style.boxShadow = 'none';
2278 item.style.transform = 'scale(1)';
2279 }
2280 });
2281 });
2282
2283 // Add hover tracking for link items too
2284 document.querySelectorAll('.link-item').forEach(item => {
2285 item.addEventListener('mousemove', (e) => {
2286 // Get position relative to the element
2287 const rect = item.getBoundingClientRect();
2288 const x = ((e.clientX - rect.left) / rect.width) * 100;
2289 const y = ((e.clientY - rect.top) / rect.height) * 100;
2290
2291 // Set custom properties for the radial gradient
2292 item.style.setProperty('--mouse-x', `${x}%`);
2293 item.style.setProperty('--mouse-y', `${y}%`);
2294 });
2295 });
2296
2297 // Process all external links from entries
2298 const linksContainer = document.getElementById('link-items');
2299 const allExternalLinks = [];
2300
2301 // Collect all external links from all entries with metadata
2302 Object.values(entriesById).forEach(entry => {
2303 if (entry.externalLinks && entry.externalLinks.length > 0) {
2304 entry.externalLinks.forEach(link => {
2305 // Only process if it's a valid URL
2306 if (link.url) {
2307 try {
2308 const url = new URL(link.url);
2309
2310 // Create a link object with metadata
2311 allExternalLinks.push({
2312 url: link.url,
2313 normalized_url: link.normalized_url,
2314 source: entry.author,
2315 date: new Date(entry.published),
2316 sourceFeed: entry.source,
2317 sourceTitle: entry.title,
2318 sourceLink: entry.link
2319 });
2320 } catch (e) {
2321 // Skip invalid URLs
2322 console.warn("Invalid URL:", link.url);
2323 }
2324 }
2325 });
2326 }
2327 });
2328
2329 // Sort links by date (newest first)
2330 allExternalLinks.sort((a, b) => b.date - a.date);
2331
2332 // Deduplicate links (keeping most recent occurrence)
2333 const dedupedLinks = [];
2334 const seenUrls = new Set();
2335
2336 allExternalLinks.forEach(link => {
2337 // Deduplicate based on normalized URL
2338 if (!seenUrls.has(link.normalized_url)) {
2339 seenUrls.add(link.normalized_url);
2340 dedupedLinks.push(link);
2341 }
2342 });
2343
2344 // Generate HTML for links view
2345 let linksHTML = '';
2346
2347 // Track current month/year for date headers in links view
2348 let currentLinkYear = null;
2349 let currentLinkMonth = null;
2350
2351 dedupedLinks.forEach(link => {
2352 const date = link.date;
2353 const year = date.getFullYear();
2354 const month = date.getMonth();
2355 const dateFormatted = formatDate(date);
2356 const url = new URL(link.url);
2357 let displayText = url.hostname.replace('www.', '');
2358 let iconPath = '';
2359
2360 // Check if we need to add a new month/year header
2361 if (currentLinkYear !== year || currentLinkMonth !== month) {
2362 currentLinkYear = year;
2363 currentLinkMonth = month;
2364
2365 linksHTML += `
2366 <div class="month-year-header" data-year="${year}" data-month="${month}">
2367 <div class="month-year-label">${monthNames[month]} ${year}</div>
2368 </div>`;
2369 }
2370
2371 // Platform-specific display logic (same as in the main feed)
2372 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
2373 const parts = url.pathname.substring(1).split('/').filter(part => part);
2374 if (parts.length >= 2) {
2375 displayText = `${parts[0]}/${parts[1]}`;
2376 iconPath = 'brands-github.svg';
2377 }
2378 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
2379 const titlePart = url.pathname.split('/').pop();
2380 if (titlePart) {
2381 displayText = decodeURIComponent(titlePart).replace(/_/g, ' ');
2382 iconPath = 'brands-wikipedia-w.svg';
2383 }
2384 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
2385 const parts = url.pathname.substring(1).split('/').filter(part => part);
2386 if (parts.length >= 1) {
2387 displayText = `@${parts[0]}`;
2388 iconPath = 'brands-x-twitter.svg';
2389 }
2390 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
2391 iconPath = 'brands-linkedin.svg';
2392 displayText = 'LinkedIn';
2393 const parts = url.pathname.substring(1).split('/').filter(part => part);
2394 if (parts.length >= 2 && parts[0] === 'in') {
2395 displayText = parts[1];
2396 }
2397 } else if (
2398 url.hostname.includes('youtube.com') ||
2399 url.hostname === 'youtu.be' ||
2400 url.hostname === 'watch.eeg.cl.cam.ac.uk' ||
2401 url.hostname === 'crank.recoil.org' ||
2402 url.hostname === 'watch.ocaml.org'
2403 ) {
2404 iconPath = 'brands-youtube.svg';
2405
2406 // Custom display text for specific video platforms
2407 if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
2408 displayText = 'YouTube Video';
2409 } else if (url.hostname === 'watch.eeg.cl.cam.ac.uk') {
2410 displayText = 'EEG Video';
2411 } else if (url.hostname === 'crank.recoil.org') {
2412 displayText = 'Crank Video';
2413 } else if (url.hostname === 'watch.ocaml.org') {
2414 displayText = 'OCaml Video';
2415 } else {
2416 displayText = 'Video';
2417 }
2418 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
2419 const parts = url.pathname.substring(1).split('/').filter(part => part);
2420 if (parts.length >= 2) {
2421 const packageName = parts[1];
2422 displayText = `${packageName} (OCaml)`;
2423 }
2424 } else if (url.hostname.includes('medium.com')) {
2425 iconPath = 'brands-medium.svg';
2426 displayText = 'Medium';
2427 const parts = url.pathname.substring(1).split('/').filter(part => part);
2428 if (parts.length >= 1) {
2429 displayText = parts[0];
2430 }
2431 } else if (url.hostname.includes('stackoverflow.com')) {
2432 iconPath = 'brands-stack-overflow.svg';
2433 displayText = 'Stack Overflow';
2434 if (url.pathname.includes('questions')) {
2435 const parts = url.pathname.split('/');
2436 const questionId = parts.find(part => /^\d+$/.test(part));
2437 if (questionId) {
2438 displayText = `Q${questionId}`;
2439 }
2440 }
2441 } else if (url.hostname === 'dev.to') {
2442 iconPath = 'brands-dev.svg';
2443 displayText = 'DEV';
2444 const parts = url.pathname.substring(1).split('/').filter(part => part);
2445 if (parts.length >= 1) {
2446 displayText = parts[0];
2447 }
2448 } else if (url.hostname.includes('reddit.com')) {
2449 iconPath = 'brands-reddit.svg';
2450 displayText = 'Reddit';
2451 if (url.pathname.includes('/r/')) {
2452 const parts = url.pathname.split('/');
2453 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
2454 if (subreddit) {
2455 displayText = `r/${subreddit}`;
2456 }
2457 }
2458 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
2459 iconPath = 'brands-hacker-news.svg';
2460 displayText = 'Hacker News';
2461 if (url.pathname.includes('item')) {
2462 const itemId = url.searchParams.get('id');
2463 if (itemId) {
2464 displayText = `HN:${itemId}`;
2465 }
2466 }
2467 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
2468 iconPath = 'brands-bluesky.svg';
2469 displayText = 'Bluesky';
2470 const parts = url.pathname.substring(1).split('/').filter(part => part);
2471 if (parts.length >= 1) {
2472 if (parts[0] === 'profile' && parts.length >= 2) {
2473 displayText = `@${parts[1]}`;
2474 } else if (parts[0] === 'post') {
2475 displayText = 'Post';
2476 } else {
2477 displayText = `@${parts[0]}`;
2478 }
2479 }
2480 }
2481
2482 // Academic paper detection - PDF files and academic domains
2483 else if (
2484 link.url.toLowerCase().endsWith('.pdf') ||
2485 url.hostname.includes('arxiv.org') ||
2486 url.hostname.includes('nature.com') ||
2487 url.hostname.includes('science.org') ||
2488 url.hostname.includes('mdpi.com') ||
2489 url.hostname.includes('doi.org')
2490 ) {
2491 iconPath = 'solid-book-open.svg';
2492
2493 // Set display text based on source
2494 if (url.hostname.includes('arxiv.org')) {
2495 // Try to extract arXiv ID
2496 const arxivIdMatch = url.pathname.match(/\d+\.\d+/);
2497 if (arxivIdMatch) {
2498 displayText = arxivIdMatch[0];
2499 } else {
2500 displayText = 'Paper';
2501 }
2502 } else if (url.hostname.includes('nature.com')) {
2503 displayText = 'Paper';
2504 } else if (url.hostname.includes('science.org')) {
2505 displayText = 'Paper';
2506 } else if (url.hostname.includes('mdpi.com')) {
2507 displayText = 'Paper';
2508 } else if (link.url.toLowerCase().endsWith('.pdf')) {
2509 // For direct PDF links, try to get a meaningful filename
2510 const pathParts = url.pathname.split('/');
2511 const filename = pathParts[pathParts.length - 1];
2512 if (filename) {
2513 displayText = decodeURIComponent(filename);
2514 } else {
2515 displayText = 'Document';
2516 }
2517 }
2518 }
2519
2520 // Function to get day with ordinal suffix (reused)
2521 function getLinkDayWithOrdinal(date) {
2522 const day = date.getDate();
2523 let suffix = "th";
2524 if (day % 10 === 1 && day !== 11) {
2525 suffix = "st";
2526 } else if (day % 10 === 2 && day !== 12) {
2527 suffix = "nd";
2528 } else if (day % 10 === 3 && day !== 13) {
2529 suffix = "rd";
2530 }
2531 return day + suffix;
2532 }
2533
2534 // Create link item HTML
2535 linksHTML += `
2536 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}">
2537 <div class="link-item-date">${getLinkDayWithOrdinal(date)} ${shortMonthNames[date.getMonth()]} ${date.getFullYear()}</div>
2538 <div class="link-item-source" title="From: ${link.sourceTitle}">
2539 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;">
2540 ${link.source}
2541 </a>
2542 </div>
2543 <div class="link-item-content">
2544 <div class="link-item-url-container">
2545 <a href="${link.url}" class="link-item-url" target="_blank">
2546 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''}
2547 ${displayText}
2548 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span>
2549 </a>
2550 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank">
2551 <span class="link-source-icon">↗</span> ${link.sourceTitle}
2552 </a>
2553 </div>
2554 </div>
2555 </div>
2556 `;
2557 });
2558
2559 // Update the links container
2560 linksContainer.innerHTML = linksHTML;
2561
2562 // Month headers in links view are now all visible
2563
2564 // If we have links, set the most recent link's date as active in the timeline for the links tab
2565 if (dedupedLinks.length > 0) {
2566 const mostRecentLink = dedupedLinks[0];
2567 const linkDate = mostRecentLink.date;
2568 const linkYear = linkDate.getFullYear();
2569 const linkMonth = linkDate.getMonth();
2570
2571 // Add a flag to remember we've set a most recent link
2572 window.mostRecentLinkSet = {
2573 year: linkYear,
2574 month: linkMonth
2575 };
2576 }
2577
2578 // Process people data
2579 const peopleContainer = document.querySelector('.people-container');
2580 const peopleMap = new Map(); // Map to store people data
2581
2582 // Fetch the mapping.json file to get author information
2583 const mappingResponse = await fetch('mapping.json');
2584 if (!mappingResponse.ok) {
2585 throw new Error('Failed to fetch mapping data');
2586 }
2587 const mappingData = await mappingResponse.json();
2588
2589 // Process author information from mapping data
2590 Object.entries(mappingData).forEach(([feedUrl, info]) => {
2591 const { name, site } = info;
2592 if (!peopleMap.has(name)) {
2593 peopleMap.set(name, {
2594 name: name,
2595 site: site,
2596 feedUrl: feedUrl,
2597 posts: [],
2598 postCount: 0,
2599 mostRecent: null
2600 });
2601 }
2602 });
2603
2604 // Associate entries with authors
2605 entriesArray.forEach(entry => {
2606 // Find the person who matches this entry's author
2607 // (taking into account potential differences in formatting)
2608 const person = Array.from(peopleMap.values()).find(p =>
2609 p.name === entry.author ||
2610 entry.author.includes(p.name) ||
2611 p.name.includes(entry.author)
2612 );
2613
2614 if (person) {
2615 person.posts.push(entry);
2616 person.postCount++;
2617
2618 // Track most recent post date
2619 const entryDate = new Date(entry.published);
2620 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) {
2621 person.mostRecent = entry;
2622 }
2623 }
2624 });
2625
2626 // Generate HTML for people cards
2627 let peopleHTML = '';
2628 Array.from(peopleMap.values())
2629 .sort((a, b) => b.postCount - a.postCount) // Sort by post count
2630 .forEach(person => {
2631 const recentPosts = person.posts
2632 .sort((a, b) => new Date(b.published) - new Date(a.published))
2633 .slice(0, 3); // Get top 3 most recent posts
2634
2635 peopleHTML += `
2636 <div class="person-card">
2637 <div class="person-name">${person.name}</div>
2638 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div>
2639
2640 <div class="person-stats">
2641 <div class="person-stat">
2642 <div class="stat-value">${person.postCount}</div>
2643 <div class="stat-label">Posts</div>
2644 </div>
2645 <div class="person-stat">
2646 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div>
2647 <div class="stat-label">Latest</div>
2648 </div>
2649 </div>
2650
2651 ${recentPosts.length > 0 ? `
2652 <div class="person-recent">
2653 <div class="recent-title">RECENT POSTS</div>
2654 <div class="recent-posts">
2655 ${recentPosts.map(post => `
2656 <div class="recent-post">
2657 <a href="${post.link}" target="_blank">${post.title}</a>
2658 <div class="recent-post-date">${formatDate(post.published)}</div>
2659 </div>
2660 `).join('')}
2661 </div>
2662 </div>
2663 ` : ''}
2664 </div>
2665 `;
2666 });
2667
2668 peopleContainer.innerHTML = peopleHTML;
2669
2670 // Initialize tabs
2671 setupTabs();
2672
2673 // Setup link filtering based on checkboxes
2674 setupLinkFilters();
2675
2676 // Function to handle link filtering
2677 function setupLinkFilters() {
2678 const filterCheckboxes = document.querySelectorAll('.filter-checkbox');
2679
2680 filterCheckboxes.forEach(checkbox => {
2681 checkbox.addEventListener('change', updateFilter);
2682 });
2683
2684 function updateFilter() {
2685 // Get all checked filters
2686 const activeFilters = Array.from(document.querySelectorAll('.filter-checkbox:checked'))
2687 .map(checkbox => checkbox.getAttribute('data-filter'));
2688
2689 // Get all link items
2690 const allLinkItems = document.querySelectorAll('.link-item');
2691 const monthYearHeaders = document.querySelectorAll('.month-year-header');
2692
2693 // Show all items if no filters are selected
2694 if (activeFilters.length === 0) {
2695 allLinkItems.forEach(item => {
2696 item.style.display = '';
2697 });
2698 monthYearHeaders.forEach(header => {
2699 header.style.display = '';
2700 });
2701 return;
2702 }
2703
2704 // Track visible items per month/year
2705 const visibleByMonthYear = new Map();
2706
2707 // Process all items
2708 allLinkItems.forEach(item => {
2709 const linkUrl = item.querySelector('.link-item-url');
2710 let shouldShow = false;
2711
2712 // Papers filter - check for PDF icon
2713 if (activeFilters.includes('academic')) {
2714 const hasPdfIcon = linkUrl && (
2715 linkUrl.innerHTML.includes('solid-book-open.svg') ||
2716 (linkUrl.getAttribute('data-link-type') === 'academic')
2717 );
2718 if (hasPdfIcon) shouldShow = true;
2719 }
2720
2721 // Videos filter - check for YouTube icon or video domains
2722 if (activeFilters.includes('youtube')) {
2723 const hasYoutubeIcon = linkUrl && (
2724 linkUrl.innerHTML.includes('brands-youtube.svg') ||
2725 (linkUrl.getAttribute('data-link-type') === 'youtube')
2726 );
2727
2728 // Also check for specific video site URLs
2729 const url = linkUrl?.getAttribute('href');
2730 const isVideoSite = url && (
2731 url.includes('youtube.com') ||
2732 url.includes('youtu.be') ||
2733 url.includes('watch.eeg.cl.cam.ac.uk') ||
2734 url.includes('crank.recoil.org') ||
2735 url.includes('watch.ocaml.org')
2736 );
2737
2738 if (hasYoutubeIcon || isVideoSite) shouldShow = true;
2739 }
2740
2741 // Set visibility
2742 item.style.display = shouldShow ? '' : 'none';
2743
2744 // Track visible items by month/year
2745 if (shouldShow) {
2746 const year = item.getAttribute('data-year');
2747 const month = item.getAttribute('data-month');
2748 const key = `${year}-${month}`;
2749 visibleByMonthYear.set(key, (visibleByMonthYear.get(key) || 0) + 1);
2750 }
2751 });
2752
2753 // Hide month-year headers with no visible items
2754 monthYearHeaders.forEach(header => {
2755 const year = header.getAttribute('data-year');
2756 const month = header.getAttribute('data-month');
2757 const key = `${year}-${month}`;
2758
2759 if (visibleByMonthYear.has(key)) {
2760 header.style.display = ''; // Show if has visible items
2761 } else {
2762 header.style.display = 'none'; // Hide if no visible items
2763 }
2764 });
2765
2766 // Update timeline sidebar to match visible items
2767 const timelineYears = document.querySelectorAll('.timeline-year');
2768 const timelineMonths = document.querySelectorAll('.timeline-month');
2769
2770 // First get all years that have visible items
2771 const visibleYears = new Set();
2772 visibleByMonthYear.forEach((count, key) => {
2773 const [year] = key.split('-');
2774 visibleYears.add(year);
2775 });
2776
2777 // Hide years without visible items
2778 if (activeFilters.length > 0) {
2779 timelineYears.forEach(yearEl => {
2780 const year = yearEl.getAttribute('data-year');
2781 yearEl.style.display = visibleYears.has(year) ? '' : 'none';
2782 });
2783
2784 // Hide months without visible items
2785 timelineMonths.forEach(monthEl => {
2786 const year = monthEl.getAttribute('data-year');
2787 const month = monthEl.getAttribute('data-month');
2788 const key = `${year}-${month}`;
2789 monthEl.style.display = visibleByMonthYear.has(key) ? '' : 'none';
2790 });
2791 } else {
2792 // Show all timeline elements when no filters
2793 timelineYears.forEach(yearEl => yearEl.style.display = '');
2794 timelineMonths.forEach(monthEl => monthEl.style.display = '');
2795 }
2796 }
2797 }
2798
2799 // Make timeline items clickable to scroll to relevant posts or links
2800 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => {
2801 item.addEventListener('click', () => {
2802 const year = item.getAttribute('data-year');
2803 const month = item.getAttribute('data-month');
2804
2805 // Store the selected date globally
2806 lastActiveYear = year;
2807 if (month !== null && month !== undefined) {
2808 lastActiveMonth = month;
2809 }
2810
2811
2812 // Find the first element with this date
2813 let selector = `[data-year="${year}"]`;
2814 if (month !== null && month !== undefined) {
2815 selector += `[data-month="${month}"]`;
2816 }
2817
2818 // Get the active tab
2819 const activeTab = document.querySelector('.tab-content.active');
2820 const activeTabId = activeTab.getAttribute('data-tab');
2821
2822 // Look for the target within the active tab
2823 const targetItem = activeTab.querySelector(selector);
2824
2825 // If no matching items in this tab or people tab is active, do nothing
2826 if (targetItem && activeTabId !== 'people') {
2827 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
2828
2829 // Highlight the selected timeline period
2830 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2831 el.classList.remove('active');
2832 });
2833
2834 // Set active classes
2835 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2836 const monthEl = month !== null && month !== undefined ?
2837 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null;
2838
2839 if (yearEl) yearEl.classList.add('active');
2840 if (monthEl) monthEl.classList.add('active');
2841
2842 // Month headers are now simple inline elements, no need to toggle visibility
2843 }
2844 });
2845 });
2846
2847 } catch (error) {
2848 console.error('Error loading feed:', error);
2849 loadingContainer.style.display = 'none';
2850 feedItemsContainer.innerHTML = `
2851 <div class="error-message">
2852 <h3>Error Loading Feed</h3>
2853 <p>${error.message}</p>
2854 </div>
2855 `;
2856 }
2857 });
2858 </script>
2859</body>
2860</html>