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