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 }
25
26 * {
27 margin: 0;
28 padding: 0;
29 box-sizing: border-box;
30 }
31
32 body {
33 font-family: 'Roboto', sans-serif;
34 background-color: var(--bg-color);
35 color: var(--text-color);
36 line-height: 1.5;
37 overflow-x: hidden;
38 }
39
40 header {
41 position: fixed;
42 top: 0;
43 width: 100%;
44 height: var(--header-height);
45 background-color: var(--bg-alt-color);
46 border-bottom: 1px solid var(--border-color);
47 display: flex;
48 align-items: center;
49 padding: 0 20px;
50 z-index: 100;
51 }
52
53 .header-container {
54 display: flex;
55 justify-content: space-between;
56 align-items: center;
57 width: 100%;
58 max-width: 1200px;
59 margin: 0 auto;
60 }
61
62 .header-left {
63 display: flex;
64 align-items: baseline;
65 gap: 15px;
66 }
67
68 .tagline {
69 font-size: 0.75rem;
70 color: var(--text-muted);
71 font-family: 'JetBrains Mono', monospace;
72 white-space: nowrap;
73 }
74
75
76
77 .tabs {
78 display: flex;
79 align-items: center;
80 gap: 8px;
81 }
82
83 .tab-button {
84 font-family: 'JetBrains Mono', monospace;
85 font-size: 0.9rem;
86 background-color: transparent;
87 border: none;
88 color: var(--text-muted);
89 padding: 8px 16px;
90 cursor: pointer;
91 border-radius: 4px;
92 transition: all 0.2s ease;
93 }
94
95 .tab-button:hover {
96 color: var(--text-color);
97 background-color: rgba(77, 250, 123, 0.05);
98 }
99
100 .tab-button.active {
101 color: var(--accent-color);
102 background-color: rgba(77, 250, 123, 0.1);
103 font-weight: 600;
104 }
105
106 .tab-content {
107 display: none;
108 }
109
110 .tab-content.active {
111 display: block;
112 }
113
114 .logo {
115 font-family: 'JetBrains Mono', monospace;
116 font-weight: 600;
117 font-size: 1.3rem;
118 color: var(--accent-color);
119 text-shadow: 0 0 10px var(--accent-shadow);
120 }
121
122 .logo span {
123 color: var(--accent-alt);
124 }
125
126 .info-panel {
127 font-family: 'JetBrains Mono', monospace;
128 font-size: 0.8rem;
129 color: var(--text-muted);
130 }
131
132 main {
133 margin-top: var(--header-height);
134 min-height: calc(100vh - var(--header-height));
135 display: flex;
136 position: relative;
137 padding: 15px 20px;
138 }
139
140 .content {
141 width: 100%;
142 max-width: 1200px;
143 margin: 0 auto;
144 padding-right: var(--sidebar-width);
145 }
146
147 .timeline-sidebar {
148 position: fixed;
149 top: var(--header-height);
150 right: 0;
151 width: var(--sidebar-width);
152 height: calc(100vh - var(--header-height));
153 background-color: var(--bg-alt-color);
154 border-left: 1px solid var(--border-color);
155 display: flex;
156 flex-direction: column;
157 overflow-y: auto;
158 padding: 15px 0;
159 z-index: 50;
160 scrollbar-width: none; /* For Firefox */
161 cursor: pointer; /* Show pointer cursor for the entire sidebar */
162 }
163
164 .timeline-sidebar::-webkit-scrollbar {
165 display: none; /* For Chrome/Safari/Edge */
166 }
167
168 .timeline-year {
169 padding: 5px 0;
170 text-align: center;
171 color: var(--text-muted);
172 font-size: 0.8rem;
173 font-family: 'JetBrains Mono', monospace;
174 position: relative;
175 transition: all 0.2s ease;
176 }
177
178 .timeline-month {
179 padding: 3px 0;
180 text-align: center;
181 color: var(--text-muted);
182 font-size: 0.7rem;
183 opacity: 0.8;
184 position: relative;
185 transition: all 0.2s ease;
186 }
187
188 .timeline-year:hover, .timeline-month:hover {
189 color: var(--accent-color);
190 transform: scale(1.05);
191 }
192
193 .timeline-year::before,
194 .timeline-month::before {
195 content: '';
196 position: absolute;
197 left: 20px;
198 top: 50%;
199 width: 7px;
200 height: 1px;
201 background-color: var(--border-color);
202 }
203
204 .timeline-year::after {
205 content: '';
206 position: absolute;
207 left: 15px;
208 top: 50%;
209 transform: translateY(-50%);
210 width: 4px;
211 height: 4px;
212 border-radius: 50%;
213 background-color: var(--accent-color);
214 }
215
216 .timeline-month::after {
217 content: '';
218 position: absolute;
219 left: 16px;
220 top: 50%;
221 transform: translateY(-50%);
222 width: 2px;
223 height: 2px;
224 border-radius: 50%;
225 background-color: var(--accent-alt);
226 }
227
228 .timeline-year.active {
229 color: var(--accent-color);
230 font-weight: 600;
231 background-color: rgba(77, 250, 123, 0.1);
232 border-radius: 4px;
233 }
234
235 .timeline-month.active {
236 color: var(--accent-alt);
237 font-weight: 600;
238 background-color: rgba(77, 250, 123, 0.05);
239 border-radius: 4px;
240 }
241
242 .timeline-year.active::after {
243 width: 8px;
244 height: 8px;
245 left: 13px;
246 box-shadow: 0 0 8px var(--accent-shadow);
247 }
248
249 .timeline-month.active::after {
250 width: 4px;
251 height: 4px;
252 left: 15px;
253 }
254
255 .feed-item {
256 background-color: var(--card-bg);
257 border: 1px solid var(--border-color);
258 border-radius: 4px;
259 margin-bottom: 8px;
260 overflow: hidden;
261 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
262 transition: background-color 0.2s ease;
263 }
264
265 .feed-item:hover {
266 background-color: #1a3028;
267 }
268
269 .feed-item-row {
270 display: flex;
271 align-items: center;
272 padding: 8px 15px;
273 width: 100%;
274 overflow: hidden;
275 position: relative;
276 }
277
278 .feed-item-left {
279 display: flex;
280 align-items: center;
281 margin-right: 10px;
282 }
283
284 .feed-item-date {
285 font-family: 'JetBrains Mono', monospace;
286 font-size: 0.75rem;
287 color: var(--text-muted);
288 min-width: 32px;
289 margin-right: 10px;
290 text-align: right;
291 }
292
293 .date-column {
294 position: absolute;
295 left: 0;
296 width: 110px;
297 font-family: 'JetBrains Mono', monospace;
298 color: var(--text-muted);
299 font-size: 0.8rem;
300 text-align: left;
301 padding-left: 15px;
302 }
303
304 .month-year-header {
305 position: sticky;
306 top: var(--header-height);
307 background-color: var(--bg-color);
308 z-index: 10;
309 padding: 8px 0;
310 margin-left: -110px;
311 padding-left: 110px;
312 width: 100%;
313 }
314
315 .month-year-label {
316 font-weight: 600;
317 color: var(--accent-alt);
318 }
319
320 .feed-container {
321 position: relative;
322 padding-left: 110px; /* Make room for date column */
323 }
324
325 .feed-item-author {
326 font-family: 'JetBrains Mono', monospace;
327 color: var(--accent-alt);
328 font-size: 0.85rem;
329 min-width: 70px;
330 margin-right: 15px;
331 white-space: nowrap;
332 }
333
334 .feed-item-title {
335 font-size: 0.95rem;
336 font-weight: 400;
337 display: inline;
338 word-break: break-word;
339 }
340
341 .feed-item-title a {
342 color: var(--text-color);
343 text-decoration: none;
344 transition: color 0.2s ease;
345 }
346
347 .feed-item-title a:hover {
348 color: var(--accent-color);
349 }
350
351 .feed-item-content-wrapper {
352 flex: 1;
353 overflow: hidden;
354 white-space: nowrap;
355 text-overflow: ellipsis;
356 padding-right: 10px;
357 }
358
359 .feed-item-preview {
360 color: var(--text-muted);
361 font-size: 0.85rem;
362 overflow: hidden;
363 text-overflow: ellipsis;
364 white-space: nowrap;
365 transition: all 0.3s ease;
366 display: inline;
367 margin-left: 8px;
368 }
369
370 .feed-item-preview a {
371 color: var(--accent-alt);
372 text-decoration: underline;
373 }
374
375 .feed-item-actions {
376 display: flex;
377 align-items: center;
378 gap: 10px;
379 margin-left: auto;
380 }
381
382 .feed-item {
383 border-left: 3px solid transparent;
384 transition: all 0.3s ease;
385 }
386
387 .feed-item:hover {
388 border-left-color: var(--accent-color);
389 background-color: rgba(77, 250, 123, 0.03);
390 }
391
392 .references-container {
393 padding: 5px 15px;
394 border-top: 1px dashed var(--border-color);
395 background-color: rgba(77, 250, 123, 0.02);
396 }
397
398 .reference-item {
399 display: flex;
400 align-items: center;
401 padding: 4px 0;
402 line-height: 1.3;
403 }
404
405 .reference-indicator {
406 color: var(--accent-color);
407 margin-right: 5px;
408 font-size: 0.85rem;
409 }
410
411
412 .feed-item:hover .feed-item-content-wrapper {
413 white-space: normal;
414 }
415
416 .feed-item:hover .feed-item-preview {
417 white-space: normal;
418 line-height: 1.4;
419 max-height: none;
420 display: inline;
421 margin-left: 8px;
422 opacity: 1;
423 }
424
425 .preview-links,
426 .preview-references {
427 font-size: 0.8rem;
428 display: none;
429 flex-wrap: wrap;
430 align-items: center;
431 gap: 8px;
432 margin-top: 3px;
433 }
434
435 /* Common styles for all platform links */
436 .external-link-item[data-link-type] {
437 background-color: rgba(77, 180, 128, 0.08);
438 color: var(--accent-alt);
439 display: inline-flex;
440 align-items: center;
441 }
442
443 /* Platform-specific styling can be added here in the future if needed */
444
445 .external-link-item img {
446 display: inline-block;
447 vertical-align: middle;
448 filter: invert(1);
449 }
450
451 .feed-item:hover .preview-links,
452 .feed-item:hover .preview-references {
453 display: flex;
454 }
455
456 .reference-header {
457 font-family: 'JetBrains Mono', monospace;
458 color: var(--text-muted);
459 font-size: 0.9rem;
460 margin-bottom: 5px;
461 }
462
463 .reference-link {
464 color: var(--text-color);
465 text-decoration: none;
466 transition: color 0.2s ease;
467 }
468
469 .reference-link:hover {
470 color: var(--accent-color);
471 }
472
473 .reference-author {
474 color: var(--text-muted);
475 font-size: 0.85rem;
476 margin-left: 5px;
477 }
478
479 .external-links-label {
480 color: var(--text-muted);
481 font-family: 'JetBrains Mono', monospace;
482 margin-right: 10px;
483 }
484
485 .external-link-item {
486 display: inline-block;
487 color: var(--accent-alt);
488 text-decoration: none;
489 background-color: rgba(77, 180, 128, 0.08);
490 padding: 2px 6px;
491 border-radius: 3px;
492 transition: all 0.2s ease;
493 }
494
495 .external-link-item:hover {
496 background-color: rgba(77, 180, 128, 0.15);
497 text-decoration: underline;
498 }
499
500 .external-links-toggle {
501 background: transparent;
502 border: none;
503 color: var(--text-muted);
504 font-family: 'JetBrains Mono', monospace;
505 font-size: 0.75rem;
506 padding: 2px 5px;
507 cursor: pointer;
508 display: inline-flex;
509 align-items: center;
510 border-radius: 3px;
511 margin-left: 10px;
512 }
513
514 .external-links-toggle:hover {
515 background-color: rgba(77, 180, 128, 0.05);
516 color: var(--accent-alt);
517 }
518
519 .feed-item-content {
520 padding: 15px;
521 line-height: 1.6;
522 display: none;
523 border-top: 1px solid var(--border-color);
524 background-color: #1a2e24;
525 }
526
527 .feed-item-content img {
528 max-width: 100%;
529 height: auto;
530 border-radius: 4px;
531 margin: 10px 0;
532 }
533
534 .feed-item-content pre, .feed-item-content code {
535 font-family: 'JetBrains Mono', monospace;
536 background-color: #183025;
537 border-radius: 4px;
538 padding: 0.2em 0.4em;
539 font-size: 0.9em;
540 }
541
542 .feed-item-content pre {
543 padding: 12px;
544 overflow-x: auto;
545 margin: 12px 0;
546 }
547
548 .feed-item-content blockquote {
549 border-left: 3px solid var(--accent-color);
550 padding-left: 12px;
551 margin-left: 0;
552 color: var(--text-muted);
553 }
554
555 .read-more-btn,
556 .external-links-toggle,
557 .references-toggle {
558 background-color: transparent;
559 border: none;
560 color: var(--accent-color);
561 cursor: pointer;
562 font-size: 1rem;
563 padding: 2px 8px;
564 border-radius: 3px;
565 transition: all 0.2s ease;
566 display: inline-block;
567 }
568
569 .read-more-btn:hover,
570 .external-links-toggle:hover,
571 .references-toggle:hover {
572 background-color: rgba(77, 250, 123, 0.1);
573 transform: scale(1.1);
574 }
575
576 .external-link {
577 color: var(--text-muted);
578 font-size: 1rem;
579 display: inline-block;
580 text-decoration: none;
581 padding: 2px 8px;
582 border-radius: 3px;
583 transition: all 0.2s ease;
584 }
585
586 .external-link:hover {
587 color: var(--accent-alt);
588 transform: scale(1.1);
589 }
590
591 #loading {
592 display: flex;
593 flex-direction: column;
594 align-items: center;
595 justify-content: center;
596 min-height: 200px;
597 }
598
599 .loading-spinner {
600 border: 3px solid rgba(77, 250, 123, 0.1);
601 border-top: 3px solid var(--accent-color);
602 border-radius: 50%;
603 width: 30px;
604 height: 30px;
605 animation: spin 1s linear infinite;
606 margin-bottom: 12px;
607 }
608
609 @keyframes spin {
610 0% { transform: rotate(0deg); }
611 100% { transform: rotate(360deg); }
612 }
613
614 .loading-text {
615 font-family: 'JetBrains Mono', monospace;
616 color: var(--accent-color);
617 font-size: 0.9rem;
618 }
619
620 .error-message {
621 color: #ff4d4d;
622 text-align: center;
623 padding: 20px;
624 font-family: 'JetBrains Mono', monospace;
625 }
626
627 /* Link item styling */
628 .link-item {
629 background-color: var(--card-bg);
630 border: 1px solid var(--border-color);
631 border-radius: 4px;
632 margin-bottom: 8px;
633 overflow: hidden;
634 transition: background-color 0.2s ease;
635 display: flex;
636 align-items: center;
637 padding: 10px 15px;
638 }
639
640 .link-item:hover {
641 background-color: #1a3028;
642 border-left-color: var(--accent-color);
643 }
644
645 .link-item-date {
646 font-family: 'JetBrains Mono', monospace;
647 font-size: 0.75rem;
648 color: var(--text-muted);
649 min-width: 32px;
650 margin-right: 15px;
651 text-align: right;
652 }
653
654 .link-item-source {
655 font-family: 'JetBrains Mono', monospace;
656 font-size: 0.85rem;
657 color: var(--accent-alt);
658 min-width: 100px;
659 margin-right: 15px;
660 white-space: nowrap;
661 overflow: hidden;
662 text-overflow: ellipsis;
663 }
664
665 .link-item-content {
666 flex: 1;
667 }
668
669 .link-item-url-container {
670 display: flex;
671 align-items: center;
672 justify-content: space-between;
673 width: 100%;
674 }
675
676 .link-item-url {
677 color: var(--text-color);
678 text-decoration: none;
679 font-size: 0.9rem;
680 display: flex;
681 align-items: center;
682 flex: 1;
683 min-width: 0;
684 margin-right: 10px;
685 }
686
687 .link-item-url:hover {
688 color: var(--accent-color);
689 }
690
691 .link-item-path {
692 color: var(--text-muted);
693 margin-left: 8px;
694 font-size: 0.8rem;
695 white-space: nowrap;
696 overflow: hidden;
697 text-overflow: ellipsis;
698 }
699
700 .link-source-reference {
701 color: var(--text-muted);
702 text-decoration: none;
703 transition: color 0.2s ease;
704 font-size: 0.75rem;
705 white-space: nowrap;
706 max-width: 180px;
707 overflow: hidden;
708 text-overflow: ellipsis;
709 display: inline-block;
710 }
711
712 .link-source-reference:hover {
713 color: var(--accent-alt);
714 }
715
716 .link-source-icon {
717 display: inline-block;
718 font-size: 0.7rem;
719 margin-right: 2px;
720 color: var(--accent-alt);
721 }
722
723 .link-item-icon {
724 display: inline-block;
725 margin-right: 8px;
726 filter: invert(1);
727 width: 16px;
728 height: 16px;
729 vertical-align: middle;
730 }
731
732 @media (max-width: 900px) {
733 .feed-item-preview {
734 display: none;
735 }
736
737 .link-item {
738 flex-direction: column;
739 align-items: flex-start;
740 }
741
742 .link-item-date, .link-item-source {
743 margin-bottom: 6px;
744 }
745
746 .link-item-url-container {
747 flex-direction: column;
748 align-items: flex-start;
749 }
750
751 .link-source-reference {
752 margin-top: 4px;
753 max-width: none;
754 }
755
756 .header-container {
757 flex-direction: column;
758 align-items: flex-start;
759 padding: 10px 0;
760 }
761
762 .header-left {
763 flex-direction: column;
764 align-items: flex-start;
765 gap: 5px;
766 }
767
768 .tagline {
769 white-space: normal;
770 font-size: 0.7rem;
771 }
772
773 header {
774 height: auto;
775 }
776
777 main {
778 margin-top: 140px;
779 }
780
781 .timeline-sidebar {
782 top: 140px;
783 height: calc(100vh - 140px);
784 }
785
786 .tabs {
787 margin: 10px 0;
788 }
789 }
790
791 /* People tab styling */
792 .people-header {
793 font-family: 'JetBrains Mono', monospace;
794 color: var(--accent-color);
795 margin-bottom: 20px;
796 font-size: 1.4rem;
797 font-weight: 600;
798 }
799
800 .people-container {
801 display: grid;
802 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
803 gap: 20px;
804 }
805
806 .person-card {
807 background-color: var(--card-bg);
808 border: 1px solid var(--border-color);
809 border-radius: 4px;
810 overflow: hidden;
811 transition: all 0.2s ease;
812 padding: 15px;
813 }
814
815 .person-card:hover {
816 border-left-color: var(--accent-color);
817 background-color: rgba(77, 250, 123, 0.03);
818 }
819
820 .person-name {
821 font-family: 'JetBrains Mono', monospace;
822 color: var(--accent-alt);
823 font-size: 1.1rem;
824 margin-bottom: 8px;
825 font-weight: 600;
826 }
827
828 .person-site {
829 font-size: 0.9rem;
830 color: var(--text-muted);
831 margin-bottom: 12px;
832 }
833
834 .person-site a {
835 color: var(--text-muted);
836 text-decoration: none;
837 transition: color 0.2s ease;
838 }
839
840 .person-site a:hover {
841 color: var(--accent-color);
842 }
843
844 .person-stats {
845 display: flex;
846 gap: 15px;
847 margin-bottom: 15px;
848 font-family: 'JetBrains Mono', monospace;
849 font-size: 0.85rem;
850 }
851
852 .person-stat {
853 display: flex;
854 flex-direction: column;
855 align-items: center;
856 }
857
858 .stat-value {
859 color: var(--accent-color);
860 font-size: 1.1rem;
861 font-weight: 600;
862 }
863
864 .stat-label {
865 color: var(--text-muted);
866 font-size: 0.75rem;
867 }
868
869 .person-recent {
870 margin-top: 12px;
871 }
872
873 .recent-title {
874 font-family: 'JetBrains Mono', monospace;
875 color: var(--text-muted);
876 font-size: 0.85rem;
877 margin-bottom: 8px;
878 }
879
880 .recent-posts {
881 display: flex;
882 flex-direction: column;
883 gap: 8px;
884 }
885
886 .recent-post {
887 padding: 8px;
888 background-color: rgba(77, 250, 123, 0.03);
889 border-radius: 3px;
890 font-size: 0.9rem;
891 }
892
893 .recent-post a {
894 color: var(--text-color);
895 text-decoration: none;
896 transition: color 0.2s ease;
897 }
898
899 .recent-post a:hover {
900 color: var(--accent-color);
901 }
902
903 .recent-post-date {
904 font-family: 'JetBrains Mono', monospace;
905 color: var(--text-muted);
906 font-size: 0.75rem;
907 margin-top: 3px;
908 }
909
910 @media (max-width: 600px) {
911 .feed-item-author {
912 min-width: 50px;
913 margin-right: 10px;
914 }
915
916 .feed-item-date {
917 min-width: 60px;
918 margin-right: 10px;
919 }
920
921 .tabs {
922 gap: 2px;
923 width: 100%;
924 justify-content: space-between;
925 }
926
927 .tab-button {
928 padding: 6px 8px;
929 font-size: 0.75rem;
930 flex-grow: 1;
931 text-align: center;
932 }
933
934 .people-container {
935 grid-template-columns: 1fr;
936 }
937
938
939 main {
940 margin-top: 150px;
941 }
942
943 .timeline-sidebar {
944 top: 150px;
945 height: calc(100vh - 150px);
946 }
947 }
948 </style>
949</head>
950<body>
951 <header>
952 <div class="header-container">
953 <div class="header-left">
954 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;">
955 <div class="logo">Atomic<span>EEG</span></div>
956 </a>
957 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div>
958 </div>
959 <div class="tabs">
960 <button class="tab-button active" data-tab="posts">Posts</button>
961 <button class="tab-button" data-tab="links">Links</button>
962 <button class="tab-button" data-tab="people">Vibes</button>
963 </div>
964 </div>
965 </header>
966
967 <main>
968 <section class="content">
969 <div id="loading">
970 <div class="loading-spinner"></div>
971 <p class="loading-text">Growing Content...</p>
972 </div>
973 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div>
974 <div id="link-items" class="tab-content feed-container" data-tab="links"></div>
975 <div id="people-items" class="tab-content" data-tab="people">
976 <h2 class="people-header">EEG Sources</h2>
977 <div class="people-container"></div>
978 </div>
979 </section>
980 <aside class="timeline-sidebar" id="timeline-sidebar">
981 <!-- Timeline will be populated via JavaScript -->
982 </aside>
983 </main>
984
985 <script>
986 document.addEventListener('DOMContentLoaded', async () => {
987 // Add hover event listeners after DOM content is loaded
988 function setupHoverEffects() {
989 // Keep track of the currently active item
990 let currentHoveredItem = null;
991
992 document.querySelectorAll('.feed-item').forEach(item => {
993 item.addEventListener('mouseenter', () => {
994 // Close all sections in previously hovered item
995 if (currentHoveredItem && currentHoveredItem !== item) {
996 // Remove this section - we no longer show the full content
997
998 // No need to close preview content now since it's controlled by CSS hover
999
1000 // References are now controlled by CSS hover
1001 }
1002
1003 // Set this as current hovered item
1004 currentHoveredItem = item;
1005
1006 // Remove this section - we no longer show the full content
1007
1008 // Preview content is shown automatically by CSS on hover
1009 });
1010 });
1011 }
1012
1013 // Tab switching functionality
1014 // Create global variables to store state
1015 let globalFeedObserver = null;
1016 let lastActiveYear = null;
1017 let lastActiveMonth = null;
1018
1019 function setupObserver(options) {
1020 // Create a new intersection observer for handling timeline scrolling
1021 return new IntersectionObserver((entries) => {
1022 entries.forEach(entry => {
1023 if (entry.isIntersecting) {
1024 const year = entry.target.getAttribute('data-year');
1025 const month = entry.target.getAttribute('data-month');
1026
1027 // Get the active tab
1028 const activeTab = document.querySelector('.tab-content.active');
1029 const activeTabId = activeTab.getAttribute('data-tab');
1030
1031 // Only process if we're on posts or links tab
1032 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) {
1033 // Clear all active classes
1034 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1035 el.classList.remove('active');
1036 });
1037
1038 // Set active classes
1039 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
1040 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
1041
1042 if (yearEl) {
1043 yearEl.classList.add('active');
1044 // Store the last active year globally
1045 lastActiveYear = year;
1046 }
1047 if (monthEl) {
1048 monthEl.classList.add('active');
1049 // Store the last active month globally
1050 lastActiveMonth = month;
1051 }
1052
1053 // Fixed month header overlap issue
1054 // First hide all month headers
1055 document.querySelectorAll('.month-year-header').forEach(header => {
1056 header.style.visibility = 'hidden';
1057 });
1058
1059 // Only show the current month header
1060 const currentHeader = document.querySelector(`.month-year-header[data-year="${year}"][data-month="${month}"]`);
1061 if (currentHeader) {
1062 currentHeader.style.visibility = 'visible';
1063 }
1064 }
1065 }
1066 });
1067 }, options);
1068 }
1069
1070 function setupTabs() {
1071 const tabButtons = document.querySelectorAll('.tab-button');
1072 const tabContents = document.querySelectorAll('.tab-content');
1073 const timeline = document.getElementById('timeline-sidebar');
1074
1075 tabButtons.forEach(button => {
1076 button.addEventListener('click', () => {
1077 const tabName = button.getAttribute('data-tab');
1078
1079 // Deactivate all tabs
1080 tabButtons.forEach(btn => btn.classList.remove('active'));
1081 tabContents.forEach(content => content.classList.remove('active'));
1082
1083 // Activate selected tab
1084 button.classList.add('active');
1085 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`);
1086 tabContent.classList.add('active');
1087
1088 // Reset month header visibility when switching tabs
1089 if (tabName === 'posts' || tabName === 'links') {
1090 const monthHeaders = tabContent.querySelectorAll('.month-year-header');
1091 monthHeaders.forEach((header, index) => {
1092 header.style.visibility = index === 0 ? 'visible' : 'hidden';
1093 });
1094 }
1095
1096 // Show or hide timeline sidebar based on active tab
1097 if (tabName === 'people') {
1098 timeline.style.display = 'none';
1099 document.querySelector('.content').style.paddingRight = '0';
1100 } else {
1101 timeline.style.display = 'flex';
1102 document.querySelector('.content').style.paddingRight = 'var(--sidebar-width)';
1103
1104 // Reset timeline highlighting
1105 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1106 el.classList.remove('active');
1107 });
1108
1109 // Disconnect and recreate the observer to ensure proper tracking
1110 if (globalFeedObserver) {
1111 globalFeedObserver.disconnect();
1112 }
1113
1114 // Setup a new observer
1115 globalFeedObserver = setupObserver({
1116 root: null,
1117 rootMargin: '-80px 0px',
1118 threshold: 0.1
1119 });
1120
1121 // Observe all items in the active tab
1122 observeAllDateItems();
1123
1124 // Always scroll to top when switching tabs
1125 window.scrollTo({ top: 0, behavior: 'smooth' });
1126 }
1127 });
1128 });
1129 }
1130 const feedItemsContainer = document.getElementById('feed-items');
1131 const loadingContainer = document.getElementById('loading');
1132
1133 // Function to format date (only date, no time)
1134 function formatDate(dateString) {
1135 const date = new Date(dateString);
1136 return date.toLocaleDateString('en-US', {
1137 year: 'numeric',
1138 month: 'short',
1139 day: 'numeric'
1140 });
1141 }
1142
1143 // We no longer need preview processing functions
1144 // since we're displaying content as-is with HTML tags
1145
1146 // Function removed - we no longer toggle full content
1147
1148 // Removed the external links toggle function as it's no longer needed
1149
1150 // Reference toggle function removed - references are now shown with CSS on hover
1151
1152 try {
1153 // Fetch the Atom feed and threads data in parallel
1154 const [feedResponse, threadsResponse] = await Promise.all([
1155 fetch('eeg.xml'),
1156 fetch('threads.json')
1157 ]);
1158
1159 if (!feedResponse.ok) {
1160 throw new Error('Failed to fetch feed');
1161 }
1162
1163 if (!threadsResponse.ok) {
1164 throw new Error('Failed to fetch threads data');
1165 }
1166
1167 const xmlText = await feedResponse.text();
1168 const threadsData = await threadsResponse.json();
1169
1170 const parser = new DOMParser();
1171 const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
1172
1173 // Process feed entries
1174 const entries = xmlDoc.getElementsByTagName('entry');
1175 const sources = new Set();
1176
1177 // No longer updating the entry count element since it's been removed
1178
1179 // Map to store entries by ID for easy lookup
1180 const entriesById = {};
1181
1182 // First pass: extract all entries and build the ID map
1183 for (let i = 0; i < entries.length; i++) {
1184 const entry = entries[i];
1185
1186 // Extract entry data
1187 const id = entry.getElementsByTagName('id')[0]?.textContent || '';
1188 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title';
1189 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#';
1190 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0];
1191 const contentText = contentElement?.textContent || '';
1192 const contentType = contentElement?.getAttribute('type') || 'text';
1193 const published = entry.getElementsByTagName('published')[0]?.textContent ||
1194 entry.getElementsByTagName('updated')[0]?.textContent || '';
1195 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown';
1196 const categories = entry.getElementsByTagName('category');
1197
1198 // Extract source from category (we're using category to store source name)
1199 let source = 'Unknown Source';
1200 if (categories.length > 0) {
1201 source = categories[0].getAttribute('term');
1202 sources.add(source);
1203 }
1204
1205 // Properly handle the content based on content type
1206 let contentHtml;
1207 if (contentType === 'html' || contentType === 'text/html') {
1208 // For HTML content, create a div and set innerHTML
1209 contentHtml = contentText;
1210 } else {
1211 // For text content, escape it and preserve newlines
1212 contentHtml = contentText
1213 .replace(/&/g, '&')
1214 .replace(/</g, '<')
1215 .replace(/>/g, '>')
1216 .replace(/\n/g, '<br>');
1217 }
1218
1219 // Store the entry data
1220 entriesById[id] = {
1221 id,
1222 articleId: `article-${i}`,
1223 title,
1224 link,
1225 contentHtml, // Use the content as-is with HTML tags
1226 published,
1227 author,
1228 source,
1229 threadGroup: null,
1230 isThreadParent: false,
1231 threadParentId: null,
1232 inThread: false,
1233 threadPosition: 0,
1234 externalLinks: [],
1235 };
1236 }
1237
1238 // Process reference relationships and external links
1239 for (const entryId in entriesById) {
1240 if (threadsData[entryId]) {
1241 const threadInfo = threadsData[entryId];
1242 const entry = entriesById[entryId];
1243
1244 // Track external links for this entry
1245 entry.externalLinks = [];
1246 if (threadInfo.external_links && threadInfo.external_links.length > 0) {
1247 entry.externalLinks = threadInfo.external_links.map(link => ({
1248 url: link.url,
1249 normalized_url: link.normalized_url
1250 }));
1251 }
1252
1253 // Track references to other posts (outgoing links)
1254 entry.referencesTo = [];
1255 if (threadInfo.references && threadInfo.references.length > 0) {
1256 // Filter for only in-feed references
1257 threadInfo.references.forEach(ref => {
1258 if (ref.in_feed === true && entriesById[ref.id]) {
1259 entry.referencesTo.push({
1260 id: ref.id,
1261 title: ref.title,
1262 link: ref.link,
1263 author: entriesById[ref.id].author
1264 });
1265 }
1266 });
1267 }
1268
1269 // Track posts that reference this one (incoming links)
1270 entry.referencedBy = [];
1271 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) {
1272 // Filter for only in-feed references
1273 threadInfo.referenced_by.forEach(ref => {
1274 if (ref.in_feed === true && entriesById[ref.id]) {
1275 entry.referencedBy.push({
1276 id: ref.id,
1277 title: ref.title,
1278 link: ref.link,
1279 author: entriesById[ref.id].author
1280 });
1281 }
1282 });
1283 }
1284 }
1285 }
1286
1287 // Sort by date and create HTML
1288 const entriesArray = Object.values(entriesById);
1289 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published));
1290
1291 // Create a timeline structure by year/month
1292 const timeline = new Map();
1293 const monthNames = [
1294 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1295 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
1296 ];
1297 const shortMonthNames = [
1298 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1299 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
1300 ];
1301
1302 // Group entries by year and month for the timeline
1303 entriesArray.forEach(entry => {
1304 const date = new Date(entry.published);
1305 const year = date.getFullYear();
1306 const month = date.getMonth();
1307
1308 if (!timeline.has(year)) {
1309 timeline.set(year, new Map());
1310 }
1311
1312 const yearMap = timeline.get(year);
1313 if (!yearMap.has(month)) {
1314 yearMap.set(month, []);
1315 }
1316
1317 yearMap.get(month).push(entry);
1318 });
1319
1320 // Process all entries in strict date order
1321 let entriesHTML = '';
1322 const processedArticleIds = new Set();
1323
1324 // Create a copy of entriesArray to process strictly by date
1325 const entriesByDate = [...entriesArray];
1326
1327 // Track current month/year for date headers
1328 let currentYear = null;
1329 let currentMonth = null;
1330
1331 // Process each entry in date order
1332 for (const entry of entriesByDate) {
1333 // Skip entries already processed
1334 if (processedArticleIds.has(entry.articleId)) continue;
1335
1336 const date = new Date(entry.published);
1337 const year = date.getFullYear();
1338 const month = date.getMonth();
1339 const dateAttr = `data-year="${year}" data-month="${month}"`;
1340
1341 // Check if we need to add a new month/year header
1342 if (currentYear !== year || currentMonth !== month) {
1343 currentYear = year;
1344 currentMonth = month;
1345
1346 entriesHTML += `
1347 <div class="month-year-header" ${dateAttr}>
1348 <div class="date-column">
1349 <div class="month-year-label">${monthNames[month]} ${year}</div>
1350 </div>
1351 </div>`;
1352 }
1353
1354 // Function to get day with ordinal suffix
1355 function getDayWithOrdinal(date) {
1356 const day = date.getDate();
1357 let suffix = "th";
1358 if (day % 10 === 1 && day !== 11) {
1359 suffix = "st";
1360 } else if (day % 10 === 2 && day !== 12) {
1361 suffix = "nd";
1362 } else if (day % 10 === 3 && day !== 13) {
1363 suffix = "rd";
1364 }
1365 return day + suffix;
1366 }
1367
1368 // Add entry
1369 entriesHTML += `
1370 <article id="${entry.articleId}" class="feed-item" ${dateAttr}>
1371 <div class="feed-item-row">
1372 <div class="feed-item-date">${getDayWithOrdinal(date)}</div>
1373 <div class="feed-item-author">${entry.author}</div>
1374 <div class="feed-item-content-wrapper">
1375 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div>
1376
1377 ${entry.externalLinks && entry.externalLinks.length > 0 ? `
1378 <div class="preview-links">
1379 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => {
1380 // Find the first link object with this URL
1381 const link = entry.externalLinks.find(l => l.url === uniqueUrl);
1382 const url = new URL(link.url);
1383 let displayText = url.hostname.replace('www.', '');
1384
1385 // Special handling for GitHub links
1386 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
1387 // Extract the parts from pathname (remove leading slash)
1388 const parts = url.pathname.substring(1).split('/').filter(part => part);
1389 if (parts.length >= 2) {
1390 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`;
1391 }
1392 }
1393
1394 // Special handling for Wikipedia links
1395 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
1396 const titlePart = url.pathname.split('/').pop();
1397 if (titlePart) {
1398 const title = decodeURIComponent(titlePart).replace(/_/g, ' ');
1399 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`;
1400 }
1401 }
1402
1403 // Special handling for Twitter/X links
1404 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
1405 const parts = url.pathname.substring(1).split('/').filter(part => part);
1406 if (parts.length >= 1) {
1407 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1408 }
1409 }
1410
1411 // Special handling for LinkedIn links
1412 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
1413 const parts = url.pathname.substring(1).split('/').filter(part => part);
1414 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`;
1415 if (parts.length >= 2 && parts[0] === 'in') {
1416 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`;
1417 }
1418 }
1419
1420 // Special handling for YouTube links
1421 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
1422 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`;
1423 // Try to get video title from URL parameters
1424 const videoId = url.searchParams.get('v');
1425 if (url.pathname.includes('watch') && videoId) {
1426 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`;
1427 }
1428 }
1429
1430 // Special handling for OCaml package links
1431 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
1432 const parts = url.pathname.substring(1).split('/').filter(part => part);
1433 if (parts.length >= 2) {
1434 const packageName = parts[1];
1435 displayText = `${packageName} (OCaml)`;
1436 }
1437 }
1438
1439 // Special handling for Medium links
1440 else if (url.hostname.includes('medium.com')) {
1441 const parts = url.pathname.substring(1).split('/').filter(part => part);
1442 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`;
1443 if (parts.length >= 1) {
1444 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1445 }
1446 }
1447
1448 // Special handling for Stack Overflow links
1449 else if (url.hostname.includes('stackoverflow.com')) {
1450 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`;
1451 if (url.pathname.includes('questions')) {
1452 const parts = url.pathname.split('/');
1453 const questionId = parts.find(part => /^\d+$/.test(part));
1454 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`;
1455 }
1456 }
1457
1458 // Special handling for Dev.to links
1459 else if (url.hostname === 'dev.to') {
1460 const parts = url.pathname.substring(1).split('/').filter(part => part);
1461 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`;
1462 if (parts.length >= 1) {
1463 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1464 }
1465 }
1466
1467 // Special handling for Reddit links
1468 else if (url.hostname.includes('reddit.com')) {
1469 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`;
1470 if (url.pathname.includes('/r/')) {
1471 const parts = url.pathname.split('/');
1472 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
1473 if (subreddit) {
1474 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`;
1475 }
1476 }
1477 }
1478
1479 // Special handling for Hacker News links
1480 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
1481 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`;
1482 if (url.pathname.includes('item')) {
1483 const itemId = url.searchParams.get('id');
1484 if (itemId) {
1485 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`;
1486 }
1487 }
1488 }
1489
1490 // Special handling for Bluesky links
1491 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
1492 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`;
1493 // Try to extract handle or post info
1494 const parts = url.pathname.substring(1).split('/').filter(part => part);
1495 if (parts.length >= 1) {
1496 if (parts[0] === 'profile') {
1497 // This is a profile link
1498 if (parts.length >= 2) {
1499 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`;
1500 }
1501 } else if (parts[0] === 'post') {
1502 // This is a post link
1503 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`;
1504 } else {
1505 // Assume it's a handle
1506 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1507 }
1508 }
1509 }
1510
1511 // Determine link type for styling and future reference
1512 let linkType = '';
1513 if (url.hostname.includes('github')) linkType = 'github';
1514 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia';
1515 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter';
1516 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin';
1517 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') linkType = 'youtube';
1518 else if (url.hostname.includes('medium.com')) linkType = 'medium';
1519 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow';
1520 else if (url.hostname === 'dev.to') linkType = 'dev';
1521 else if (url.hostname.includes('reddit.com')) linkType = 'reddit';
1522 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews';
1523 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky';
1524 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml';
1525
1526 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`;
1527 }).join(' ')}
1528 </div>
1529 ` : ''}
1530
1531 ${entry.referencesTo && entry.referencesTo.length > 0 ? `
1532 <div class="preview-references">
1533 ${entry.referencesTo.map(ref => `
1534 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a>
1535 `).join(' ')}
1536 </div>
1537 ` : ''}
1538
1539 ${entry.referencedBy && entry.referencedBy.length > 0 ? `
1540 <div class="preview-references">
1541 ${entry.referencedBy.map(ref => `
1542 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a>
1543 `).join(' ')}
1544 </div>
1545 ` : ''}
1546 </div>
1547 </div>
1548 </article>
1549 `;
1550
1551 processedArticleIds.add(entry.articleId);
1552 }
1553
1554 // All articles have been processed in the main loop above
1555
1556 // No longer updating the source count element since it's been removed
1557
1558 // No toggle functions needed anymore
1559
1560 // Build timeline sidebar
1561 const timelineSidebar = document.getElementById('timeline-sidebar');
1562 let timelineHTML = '';
1563
1564 // Sort years in descending order
1565 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a);
1566
1567 sortedYears.forEach(year => {
1568 const yearMap = timeline.get(year);
1569 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`;
1570
1571 // Sort months in descending order (Dec to Jan)
1572 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a);
1573
1574 sortedMonths.forEach(month => {
1575 const entries = yearMap.get(month);
1576 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`;
1577 });
1578 });
1579
1580 timelineSidebar.innerHTML = timelineHTML;
1581
1582 // Set up scroll observer to highlight timeline items
1583 const observerOptions = {
1584 root: null,
1585 rootMargin: '-80px 0px',
1586 threshold: 0.1
1587 };
1588
1589 // Skip adding data attributes - we've already done this during HTML generation
1590
1591 // Create observer to track which period is in view
1592 globalFeedObserver = setupObserver(observerOptions);
1593
1594 // Hide loading, show content
1595 loadingContainer.style.display = 'none';
1596 feedItemsContainer.innerHTML = entriesHTML;
1597
1598 // Initialize month headers - hide all except first one
1599 const monthHeaders = document.querySelectorAll('.month-year-header');
1600 monthHeaders.forEach((header, index) => {
1601 header.style.visibility = index === 0 ? 'visible' : 'hidden';
1602 });
1603
1604 // If we have entries, set the most recent (first) entry's date as active in the timeline
1605 if (entriesArray.length > 0) {
1606 const mostRecentEntry = entriesArray[0];
1607 const date = new Date(mostRecentEntry.published);
1608 const year = date.getFullYear();
1609 const month = date.getMonth();
1610
1611 // Set most recent date as the active period in the timeline
1612 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
1613 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
1614
1615 // Clear all active classes first
1616 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1617 el.classList.remove('active');
1618 });
1619
1620 // Add active classes to the appropriate year/month
1621 if (yearEl) {
1622 yearEl.classList.add('active');
1623 lastActiveYear = year;
1624 }
1625 if (monthEl) {
1626 monthEl.classList.add('active');
1627 lastActiveMonth = month;
1628 }
1629
1630 // Show the month header for the most recent month
1631 document.querySelectorAll('.month-year-header').forEach(header => {
1632 header.style.visibility = 'hidden';
1633 });
1634 const recentHeader = document.querySelector(`.month-year-header[data-year="${year}"][data-month="${month}"]`);
1635 if (recentHeader) {
1636 recentHeader.style.visibility = 'visible';
1637 }
1638 }
1639
1640 // Helper function to observe all items with date attributes
1641 function observeAllDateItems() {
1642 // Observe all feed items for scroll tracking
1643 document.querySelectorAll('.feed-item').forEach(item => {
1644 globalFeedObserver.observe(item);
1645 });
1646
1647 // Also observe link items for timeline highlighting
1648 document.querySelectorAll('.link-item').forEach(item => {
1649 globalFeedObserver.observe(item);
1650 });
1651 }
1652
1653 // Initial observation of all items
1654 observeAllDateItems();
1655
1656 // Set initial display state for timeline based on initial active tab
1657 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab');
1658 if (initialActiveTab === 'people') {
1659 document.getElementById('timeline-sidebar').style.display = 'none';
1660 document.querySelector('.content').style.paddingRight = '0';
1661 } else {
1662 // Initialize the last active date from the first visible item
1663 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item';
1664 const visibleItems = Array.from(document.querySelectorAll(selector))
1665 .filter(item => {
1666 const rect = item.getBoundingClientRect();
1667 return rect.top >= 0 && rect.bottom <= window.innerHeight;
1668 });
1669
1670 if (visibleItems.length > 0) {
1671 lastActiveYear = visibleItems[0].getAttribute('data-year');
1672 lastActiveMonth = visibleItems[0].getAttribute('data-month');
1673 }
1674 }
1675
1676 // Set up hover effects
1677 setupHoverEffects();
1678
1679 // Process all external links from entries
1680 const linksContainer = document.getElementById('link-items');
1681 const allExternalLinks = [];
1682
1683 // Collect all external links from all entries with metadata
1684 Object.values(entriesById).forEach(entry => {
1685 if (entry.externalLinks && entry.externalLinks.length > 0) {
1686 entry.externalLinks.forEach(link => {
1687 // Only process if it's a valid URL
1688 if (link.url) {
1689 try {
1690 const url = new URL(link.url);
1691
1692 // Create a link object with metadata
1693 allExternalLinks.push({
1694 url: link.url,
1695 normalized_url: link.normalized_url,
1696 source: entry.author,
1697 date: new Date(entry.published),
1698 sourceFeed: entry.source,
1699 sourceTitle: entry.title,
1700 sourceLink: entry.link
1701 });
1702 } catch (e) {
1703 // Skip invalid URLs
1704 console.warn("Invalid URL:", link.url);
1705 }
1706 }
1707 });
1708 }
1709 });
1710
1711 // Sort links by date (newest first)
1712 allExternalLinks.sort((a, b) => b.date - a.date);
1713
1714 // Deduplicate links (keeping most recent occurrence)
1715 const dedupedLinks = [];
1716 const seenUrls = new Set();
1717
1718 allExternalLinks.forEach(link => {
1719 // Deduplicate based on normalized URL
1720 if (!seenUrls.has(link.normalized_url)) {
1721 seenUrls.add(link.normalized_url);
1722 dedupedLinks.push(link);
1723 }
1724 });
1725
1726 // Generate HTML for links view
1727 let linksHTML = '';
1728
1729 // Track current month/year for date headers in links view
1730 let currentLinkYear = null;
1731 let currentLinkMonth = null;
1732
1733 dedupedLinks.forEach(link => {
1734 const date = link.date;
1735 const year = date.getFullYear();
1736 const month = date.getMonth();
1737 const dateFormatted = formatDate(date);
1738 const url = new URL(link.url);
1739 let displayText = url.hostname.replace('www.', '');
1740 let iconPath = '';
1741
1742 // Check if we need to add a new month/year header
1743 if (currentLinkYear !== year || currentLinkMonth !== month) {
1744 currentLinkYear = year;
1745 currentLinkMonth = month;
1746
1747 linksHTML += `
1748 <div class="month-year-header" data-year="${year}" data-month="${month}">
1749 <div class="date-column">
1750 <div class="month-year-label">${monthNames[month]} ${year}</div>
1751 </div>
1752 </div>`;
1753 }
1754
1755 // Platform-specific display logic (same as in the main feed)
1756 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
1757 const parts = url.pathname.substring(1).split('/').filter(part => part);
1758 if (parts.length >= 2) {
1759 displayText = `${parts[0]}/${parts[1]}`;
1760 iconPath = 'brands-github.svg';
1761 }
1762 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
1763 const titlePart = url.pathname.split('/').pop();
1764 if (titlePart) {
1765 displayText = decodeURIComponent(titlePart).replace(/_/g, ' ');
1766 iconPath = 'brands-wikipedia-w.svg';
1767 }
1768 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
1769 const parts = url.pathname.substring(1).split('/').filter(part => part);
1770 if (parts.length >= 1) {
1771 displayText = `@${parts[0]}`;
1772 iconPath = 'brands-x-twitter.svg';
1773 }
1774 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
1775 iconPath = 'brands-linkedin.svg';
1776 displayText = 'LinkedIn';
1777 const parts = url.pathname.substring(1).split('/').filter(part => part);
1778 if (parts.length >= 2 && parts[0] === 'in') {
1779 displayText = parts[1];
1780 }
1781 } else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
1782 iconPath = 'brands-youtube.svg';
1783 displayText = 'YouTube Video';
1784 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
1785 const parts = url.pathname.substring(1).split('/').filter(part => part);
1786 if (parts.length >= 2) {
1787 const packageName = parts[1];
1788 displayText = `${packageName} (OCaml)`;
1789 }
1790 } else if (url.hostname.includes('medium.com')) {
1791 iconPath = 'brands-medium.svg';
1792 displayText = 'Medium';
1793 const parts = url.pathname.substring(1).split('/').filter(part => part);
1794 if (parts.length >= 1) {
1795 displayText = parts[0];
1796 }
1797 } else if (url.hostname.includes('stackoverflow.com')) {
1798 iconPath = 'brands-stack-overflow.svg';
1799 displayText = 'Stack Overflow';
1800 if (url.pathname.includes('questions')) {
1801 const parts = url.pathname.split('/');
1802 const questionId = parts.find(part => /^\d+$/.test(part));
1803 if (questionId) {
1804 displayText = `Q${questionId}`;
1805 }
1806 }
1807 } else if (url.hostname === 'dev.to') {
1808 iconPath = 'brands-dev.svg';
1809 displayText = 'DEV';
1810 const parts = url.pathname.substring(1).split('/').filter(part => part);
1811 if (parts.length >= 1) {
1812 displayText = parts[0];
1813 }
1814 } else if (url.hostname.includes('reddit.com')) {
1815 iconPath = 'brands-reddit.svg';
1816 displayText = 'Reddit';
1817 if (url.pathname.includes('/r/')) {
1818 const parts = url.pathname.split('/');
1819 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
1820 if (subreddit) {
1821 displayText = `r/${subreddit}`;
1822 }
1823 }
1824 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
1825 iconPath = 'brands-hacker-news.svg';
1826 displayText = 'Hacker News';
1827 if (url.pathname.includes('item')) {
1828 const itemId = url.searchParams.get('id');
1829 if (itemId) {
1830 displayText = `HN:${itemId}`;
1831 }
1832 }
1833 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
1834 iconPath = 'brands-bluesky.svg';
1835 displayText = 'Bluesky';
1836 const parts = url.pathname.substring(1).split('/').filter(part => part);
1837 if (parts.length >= 1) {
1838 if (parts[0] === 'profile' && parts.length >= 2) {
1839 displayText = `@${parts[1]}`;
1840 } else if (parts[0] === 'post') {
1841 displayText = 'Post';
1842 } else {
1843 displayText = `@${parts[0]}`;
1844 }
1845 }
1846 }
1847
1848 // Function to get day with ordinal suffix (reused)
1849 function getLinkDayWithOrdinal(date) {
1850 const day = date.getDate();
1851 let suffix = "th";
1852 if (day % 10 === 1 && day !== 11) {
1853 suffix = "st";
1854 } else if (day % 10 === 2 && day !== 12) {
1855 suffix = "nd";
1856 } else if (day % 10 === 3 && day !== 13) {
1857 suffix = "rd";
1858 }
1859 return day + suffix;
1860 }
1861
1862 // Create link item HTML
1863 linksHTML += `
1864 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}">
1865 <div class="link-item-date">${getLinkDayWithOrdinal(date)}</div>
1866 <div class="link-item-source" title="From: ${link.sourceTitle}">
1867 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;">
1868 ${link.source}
1869 </a>
1870 </div>
1871 <div class="link-item-content">
1872 <div class="link-item-url-container">
1873 <a href="${link.url}" class="link-item-url" target="_blank">
1874 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''}
1875 ${displayText}
1876 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span>
1877 </a>
1878 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank">
1879 <span class="link-source-icon">↗</span> ${link.sourceTitle}
1880 </a>
1881 </div>
1882 </div>
1883 </div>
1884 `;
1885 });
1886
1887 // Update the links container
1888 linksContainer.innerHTML = linksHTML;
1889
1890 // Initialize month headers in links view - hide all except first one
1891 const linkMonthHeaders = document.querySelectorAll('#link-items .month-year-header');
1892 linkMonthHeaders.forEach((header, index) => {
1893 header.style.visibility = index === 0 ? 'visible' : 'hidden';
1894 });
1895
1896 // If we have links, set the most recent link's date as active in the timeline for the links tab
1897 if (dedupedLinks.length > 0) {
1898 const mostRecentLink = dedupedLinks[0];
1899 const linkDate = mostRecentLink.date;
1900 const linkYear = linkDate.getFullYear();
1901 const linkMonth = linkDate.getMonth();
1902
1903 // Add a flag to remember we've set a most recent link
1904 window.mostRecentLinkSet = {
1905 year: linkYear,
1906 month: linkMonth
1907 };
1908 }
1909
1910 // Process people data
1911 const peopleContainer = document.querySelector('.people-container');
1912 const peopleMap = new Map(); // Map to store people data
1913
1914 // Fetch the mapping.json file to get author information
1915 const mappingResponse = await fetch('mapping.json');
1916 if (!mappingResponse.ok) {
1917 throw new Error('Failed to fetch mapping data');
1918 }
1919 const mappingData = await mappingResponse.json();
1920
1921 // Process author information from mapping data
1922 Object.entries(mappingData).forEach(([feedUrl, info]) => {
1923 const { name, site } = info;
1924 if (!peopleMap.has(name)) {
1925 peopleMap.set(name, {
1926 name: name,
1927 site: site,
1928 feedUrl: feedUrl,
1929 posts: [],
1930 postCount: 0,
1931 mostRecent: null
1932 });
1933 }
1934 });
1935
1936 // Associate entries with authors
1937 entriesArray.forEach(entry => {
1938 // Find the person who matches this entry's author
1939 // (taking into account potential differences in formatting)
1940 const person = Array.from(peopleMap.values()).find(p =>
1941 p.name === entry.author ||
1942 entry.author.includes(p.name) ||
1943 p.name.includes(entry.author)
1944 );
1945
1946 if (person) {
1947 person.posts.push(entry);
1948 person.postCount++;
1949
1950 // Track most recent post date
1951 const entryDate = new Date(entry.published);
1952 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) {
1953 person.mostRecent = entry;
1954 }
1955 }
1956 });
1957
1958 // Generate HTML for people cards
1959 let peopleHTML = '';
1960 Array.from(peopleMap.values())
1961 .sort((a, b) => b.postCount - a.postCount) // Sort by post count
1962 .forEach(person => {
1963 const recentPosts = person.posts
1964 .sort((a, b) => new Date(b.published) - new Date(a.published))
1965 .slice(0, 3); // Get top 3 most recent posts
1966
1967 peopleHTML += `
1968 <div class="person-card">
1969 <div class="person-name">${person.name}</div>
1970 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div>
1971
1972 <div class="person-stats">
1973 <div class="person-stat">
1974 <div class="stat-value">${person.postCount}</div>
1975 <div class="stat-label">Posts</div>
1976 </div>
1977 <div class="person-stat">
1978 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div>
1979 <div class="stat-label">Latest</div>
1980 </div>
1981 </div>
1982
1983 ${recentPosts.length > 0 ? `
1984 <div class="person-recent">
1985 <div class="recent-title">RECENT POSTS</div>
1986 <div class="recent-posts">
1987 ${recentPosts.map(post => `
1988 <div class="recent-post">
1989 <a href="${post.link}" target="_blank">${post.title}</a>
1990 <div class="recent-post-date">${formatDate(post.published)}</div>
1991 </div>
1992 `).join('')}
1993 </div>
1994 </div>
1995 ` : ''}
1996 </div>
1997 `;
1998 });
1999
2000 peopleContainer.innerHTML = peopleHTML;
2001
2002 // Initialize tabs
2003 setupTabs();
2004
2005 // Make timeline items clickable to scroll to relevant posts or links
2006 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => {
2007 item.addEventListener('click', () => {
2008 const year = item.getAttribute('data-year');
2009 const month = item.getAttribute('data-month');
2010
2011 // Store the selected date globally
2012 lastActiveYear = year;
2013 if (month !== null && month !== undefined) {
2014 lastActiveMonth = month;
2015 }
2016
2017
2018 // Find the first element with this date
2019 let selector = `[data-year="${year}"]`;
2020 if (month !== null && month !== undefined) {
2021 selector += `[data-month="${month}"]`;
2022 }
2023
2024 // Get the active tab
2025 const activeTab = document.querySelector('.tab-content.active');
2026 const activeTabId = activeTab.getAttribute('data-tab');
2027
2028 // Look for the target within the active tab
2029 const targetItem = activeTab.querySelector(selector);
2030
2031 // If no matching items in this tab or people tab is active, do nothing
2032 if (targetItem && activeTabId !== 'people') {
2033 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
2034
2035 // Highlight the selected timeline period
2036 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2037 el.classList.remove('active');
2038 });
2039
2040 // Set active classes
2041 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2042 const monthEl = month !== null && month !== undefined ?
2043 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null;
2044
2045 if (yearEl) yearEl.classList.add('active');
2046 if (monthEl) monthEl.classList.add('active');
2047
2048 // Update month header visibility when clicking timeline
2049 // Hide all headers
2050 activeTab.querySelectorAll('.month-year-header').forEach(header => {
2051 header.style.visibility = 'hidden';
2052 });
2053
2054 // Show only the clicked month header
2055 if (month !== null && month !== undefined) {
2056 const clickedHeader = activeTab.querySelector(`.month-year-header[data-year="${year}"][data-month="${month}"]`);
2057 if (clickedHeader) {
2058 clickedHeader.style.visibility = 'visible';
2059 }
2060 }
2061 }
2062 });
2063 });
2064
2065 } catch (error) {
2066 console.error('Error loading feed:', error);
2067 loadingContainer.style.display = 'none';
2068 feedItemsContainer.innerHTML = `
2069 <div class="error-message">
2070 <h3>Error Loading Feed</h3>
2071 <p>${error.message}</p>
2072 </div>
2073 `;
2074 }
2075 });
2076 </script>
2077</body>
2078</html>