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