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