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