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 --ripple-color: rgba(77, 250, 123, 0.04);
25 --ripple-color-strong: rgba(77, 250, 123, 0.06);
26 --matrix-color: rgba(77, 250, 123, 0.2);
27 --matrix-glow: rgba(77, 250, 123, 0.1);
28 --hover-glow: rgba(77, 250, 123, 0.15);
29 }
30
31 * {
32 margin: 0;
33 padding: 0;
34 box-sizing: border-box;
35 }
36
37 body {
38 font-family: 'Roboto', sans-serif;
39 background-color: var(--bg-color);
40 color: var(--text-color);
41 line-height: 1.5;
42 overflow-x: hidden;
43 position: relative;
44 }
45
46 body::before {
47 content: '';
48 position: fixed;
49 top: 0;
50 left: 0;
51 width: 100%;
52 height: 100%;
53 background: linear-gradient(rgba(10, 23, 15, 0.82), rgba(10, 23, 15, 0.92));
54 z-index: -1;
55 pointer-events: none;
56 }
57
58 #matrix-background {
59 position: fixed;
60 top: 0;
61 left: 0;
62 width: 100%;
63 height: 100%;
64 z-index: -2;
65 opacity: 0.6;
66 pointer-events: none;
67 }
68
69 header {
70 position: fixed;
71 top: 0;
72 width: 100%;
73 height: var(--header-height);
74 background-color: var(--bg-alt-color);
75 border-bottom: 1px solid var(--border-color);
76 display: flex;
77 align-items: center;
78 padding: 0 20px;
79 z-index: 100;
80 }
81
82 .header-container {
83 display: flex;
84 justify-content: space-between;
85 align-items: center;
86 width: 100%;
87 max-width: 1200px;
88 margin: 0 auto;
89 }
90
91 .header-left {
92 display: flex;
93 align-items: baseline;
94 gap: 15px;
95 }
96
97 .tagline {
98 font-size: 0.75rem;
99 color: var(--text-muted);
100 font-family: 'JetBrains Mono', monospace;
101 white-space: nowrap;
102 }
103
104
105
106 .tabs {
107 display: flex;
108 align-items: center;
109 gap: 8px;
110 }
111
112 .tab-button {
113 font-family: 'JetBrains Mono', monospace;
114 font-size: 0.9rem;
115 background-color: transparent;
116 border: none;
117 color: var(--text-muted);
118 padding: 8px 16px;
119 cursor: pointer;
120 border-radius: 4px;
121 transition: all 0.2s ease;
122 }
123
124 .tab-button:hover {
125 color: var(--text-color);
126 background-color: rgba(77, 250, 123, 0.05);
127 }
128
129 .tab-button.active {
130 color: var(--accent-color);
131 background-color: rgba(77, 250, 123, 0.1);
132 font-weight: 600;
133 }
134
135 .tab-content {
136 display: none;
137 }
138
139 .tab-content.active {
140 display: block;
141 }
142
143 .logo {
144 font-family: 'JetBrains Mono', monospace;
145 font-weight: 600;
146 font-size: 1.3rem;
147 color: var(--accent-color);
148 text-shadow: 0 0 10px var(--accent-shadow);
149 }
150
151 .logo span {
152 color: var(--accent-alt);
153 }
154
155 .info-panel {
156 font-family: 'JetBrains Mono', monospace;
157 font-size: 0.8rem;
158 color: var(--text-muted);
159 }
160
161 main {
162 margin-top: var(--header-height);
163 min-height: calc(100vh - var(--header-height));
164 display: flex;
165 position: relative;
166 padding: 15px 20px;
167 }
168
169 .content {
170 width: 100%;
171 max-width: 1200px;
172 margin: 0 auto;
173 padding-left: var(--sidebar-width);
174 }
175
176 .timeline-sidebar {
177 position: fixed;
178 top: var(--header-height);
179 left: 0;
180 width: var(--sidebar-width);
181 height: calc(100vh - var(--header-height));
182 background-color: var(--bg-alt-color);
183 border-right: 1px solid var(--border-color);
184 display: flex;
185 flex-direction: column;
186 overflow-y: auto;
187 padding: 15px 0;
188 z-index: 50;
189 scrollbar-width: none; /* For Firefox */
190 cursor: pointer; /* Show pointer cursor for the entire sidebar */
191 }
192
193 .timeline-sidebar::-webkit-scrollbar {
194 display: none; /* For Chrome/Safari/Edge */
195 }
196
197 .timeline-year {
198 padding: 5px 0;
199 text-align: center;
200 color: var(--text-muted);
201 font-size: 0.8rem;
202 font-family: 'JetBrains Mono', monospace;
203 position: relative;
204 transition: all 0.2s ease;
205 }
206
207 .timeline-month {
208 padding: 3px 0;
209 text-align: center;
210 color: var(--text-muted);
211 font-size: 0.7rem;
212 opacity: 0.8;
213 position: relative;
214 transition: all 0.2s ease;
215 }
216
217 .timeline-year:hover, .timeline-month:hover {
218 color: var(--accent-color);
219 transform: scale(1.05);
220 }
221
222 .timeline-year::before,
223 .timeline-month::before {
224 content: '';
225 position: absolute;
226 right: 20px;
227 top: 50%;
228 width: 7px;
229 height: 1px;
230 background-color: var(--border-color);
231 }
232
233 .timeline-year::after {
234 content: '';
235 position: absolute;
236 right: 15px;
237 top: 50%;
238 transform: translateY(-50%);
239 width: 4px;
240 height: 4px;
241 border-radius: 50%;
242 background-color: var(--accent-color);
243 }
244
245 .timeline-month::after {
246 content: '';
247 position: absolute;
248 right: 16px;
249 top: 50%;
250 transform: translateY(-50%);
251 width: 2px;
252 height: 2px;
253 border-radius: 50%;
254 background-color: var(--accent-alt);
255 }
256
257 .timeline-year.active {
258 color: var(--accent-color);
259 font-weight: 600;
260 background-color: rgba(77, 250, 123, 0.1);
261 border-radius: 4px;
262 }
263
264 .timeline-month.active {
265 color: var(--accent-alt);
266 font-weight: 600;
267 background-color: rgba(77, 250, 123, 0.05);
268 border-radius: 4px;
269 }
270
271 .timeline-year.active::after {
272 width: 8px;
273 height: 8px;
274 right: 13px;
275 box-shadow: 0 0 8px var(--accent-shadow);
276 }
277
278 .timeline-month.active::after {
279 width: 4px;
280 height: 4px;
281 right: 15px;
282 }
283
284 .feed-item {
285 background-color: var(--card-bg);
286 border: 1px solid var(--border-color);
287 border-radius: 4px;
288 margin-bottom: 8px;
289 overflow: hidden;
290 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
291 transition: background-color 0.2s ease;
292 }
293
294 .feed-item:hover {
295 background-color: #1a3028;
296 }
297
298 .feed-item-row {
299 display: flex;
300 align-items: center;
301 padding: 8px 15px;
302 width: 100%;
303 overflow: hidden;
304 position: relative;
305 }
306
307 .feed-item-left {
308 display: flex;
309 align-items: center;
310 margin-right: 10px;
311 }
312
313 .feed-item-date {
314 font-family: 'JetBrains Mono', monospace;
315 font-size: 0.75rem;
316 color: var(--text-muted);
317 margin-right: 10px;
318 min-width: 120px;
319 white-space: nowrap;
320 }
321
322 /* .date-column removed as part of sidebar simplification */
323
324 .month-year-header {
325 padding: 8px 0;
326 width: 100%;
327 margin-bottom: 5px;
328 margin-top: 10px;
329 }
330
331 .month-year-label {
332 font-weight: 600;
333 color: var(--accent-alt);
334 }
335
336 .feed-container {
337 position: relative;
338 padding-left: 15px; /* Reduced from 110px since we don't need space for date column anymore */
339 }
340
341 .feed-item-author {
342 font-family: 'JetBrains Mono', monospace;
343 color: var(--accent-alt);
344 font-size: 0.85rem;
345 min-width: 70px;
346 margin-right: 15px;
347 white-space: nowrap;
348 }
349
350 .feed-item-title {
351 font-size: 0.95rem;
352 font-weight: 400;
353 display: inline;
354 word-break: break-word;
355 }
356
357 .feed-item-title a {
358 color: var(--text-color);
359 text-decoration: none;
360 transition: color 0.2s ease;
361 }
362
363 .feed-item-title a:hover {
364 color: var(--accent-color);
365 }
366
367 .feed-item-content-wrapper {
368 flex: 1;
369 overflow: hidden;
370 white-space: nowrap;
371 text-overflow: ellipsis;
372 padding-right: 10px;
373 }
374
375 .feed-item-preview {
376 color: var(--text-muted);
377 font-size: 0.85rem;
378 overflow: hidden;
379 text-overflow: ellipsis;
380 white-space: nowrap;
381 transition: all 0.3s ease;
382 display: inline;
383 margin-left: 8px;
384 }
385
386 .feed-item-preview a {
387 color: var(--accent-alt);
388 text-decoration: underline;
389 }
390
391 .feed-item-actions {
392 display: flex;
393 align-items: center;
394 gap: 10px;
395 margin-left: auto;
396 }
397
398 .feed-item {
399 border-left: 3px solid transparent;
400 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
401 position: relative;
402 overflow: hidden;
403 z-index: 1;
404 }
405
406 .feed-item:hover {
407 border-left-color: var(--accent-color);
408 background-color: rgba(21, 39, 32, 0.95);
409 }
410
411 .feed-item::before {
412 content: '';
413 position: absolute;
414 top: 0;
415 left: 0;
416 right: 0;
417 bottom: 0;
418 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%),
419 rgba(77, 250, 123, 0.06) 0%,
420 rgba(77, 250, 123, 0.04) 30%,
421 rgba(77, 250, 123, 0) 70%);
422 opacity: 0;
423 z-index: 0;
424 transform: scale(0);
425 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1);
426 pointer-events: none;
427 }
428
429 .references-container {
430 padding: 5px 15px;
431 border-top: 1px dashed var(--border-color);
432 background-color: rgba(77, 250, 123, 0.02);
433 }
434
435 .reference-item {
436 display: flex;
437 align-items: center;
438 padding: 4px 0;
439 line-height: 1.3;
440 }
441
442 .reference-indicator {
443 color: var(--accent-color);
444 margin-right: 5px;
445 font-size: 0.85rem;
446 }
447
448
449 .feed-item:hover .feed-item-content-wrapper {
450 white-space: normal;
451 }
452
453 .feed-item:hover .feed-item-preview {
454 white-space: normal;
455 line-height: 1.4;
456 max-height: none;
457 display: inline;
458 margin-left: 8px;
459 opacity: 1;
460 }
461
462 .feed-item:hover::before {
463 opacity: 0.6;
464 transform: scale(1.5);
465 }
466
467 .preview-links,
468 .preview-references {
469 font-size: 0.8rem;
470 display: none;
471 flex-wrap: wrap;
472 align-items: center;
473 gap: 8px;
474 margin-top: 3px;
475 }
476
477 /* Common styles for all platform links */
478 .external-link-item[data-link-type] {
479 background-color: rgba(77, 180, 128, 0.08);
480 color: var(--accent-alt);
481 display: inline-flex;
482 align-items: center;
483 }
484
485 /* Platform-specific styling can be added here in the future if needed */
486
487 .external-link-item img {
488 display: inline-block;
489 vertical-align: middle;
490 filter: invert(1);
491 }
492
493 .feed-item:hover .preview-links,
494 .feed-item:hover .preview-references {
495 display: flex;
496 }
497
498 .reference-header {
499 font-family: 'JetBrains Mono', monospace;
500 color: var(--text-muted);
501 font-size: 0.9rem;
502 margin-bottom: 5px;
503 }
504
505 .reference-link {
506 color: var(--text-color);
507 text-decoration: none;
508 transition: color 0.2s ease;
509 }
510
511 .reference-link:hover {
512 color: var(--accent-color);
513 }
514
515 .reference-author {
516 color: var(--text-muted);
517 font-size: 0.85rem;
518 margin-left: 5px;
519 }
520
521 .external-links-label {
522 color: var(--text-muted);
523 font-family: 'JetBrains Mono', monospace;
524 margin-right: 10px;
525 }
526
527 .external-link-item {
528 display: inline-block;
529 color: var(--accent-alt);
530 text-decoration: none;
531 background-color: rgba(77, 180, 128, 0.08);
532 padding: 2px 6px;
533 border-radius: 3px;
534 transition: all 0.2s ease;
535 }
536
537 .external-link-item:hover {
538 background-color: rgba(77, 180, 128, 0.15);
539 text-decoration: underline;
540 }
541
542 .external-links-toggle {
543 background: transparent;
544 border: none;
545 color: var(--text-muted);
546 font-family: 'JetBrains Mono', monospace;
547 font-size: 0.75rem;
548 padding: 2px 5px;
549 cursor: pointer;
550 display: inline-flex;
551 align-items: center;
552 border-radius: 3px;
553 margin-left: 10px;
554 }
555
556 .external-links-toggle:hover {
557 background-color: rgba(77, 180, 128, 0.05);
558 color: var(--accent-alt);
559 }
560
561 .feed-item-content {
562 padding: 15px;
563 line-height: 1.6;
564 display: none;
565 border-top: 1px solid var(--border-color);
566 background-color: #1a2e24;
567 }
568
569 .feed-item-content img {
570 max-width: 100%;
571 height: auto;
572 border-radius: 4px;
573 margin: 10px 0;
574 }
575
576 .feed-item-content pre, .feed-item-content code {
577 font-family: 'JetBrains Mono', monospace;
578 background-color: #183025;
579 border-radius: 4px;
580 padding: 0.2em 0.4em;
581 font-size: 0.9em;
582 }
583
584 .feed-item-content pre {
585 padding: 12px;
586 overflow-x: auto;
587 margin: 12px 0;
588 }
589
590 .feed-item-content blockquote {
591 border-left: 3px solid var(--accent-color);
592 padding-left: 12px;
593 margin-left: 0;
594 color: var(--text-muted);
595 }
596
597 .read-more-btn,
598 .external-links-toggle,
599 .references-toggle {
600 background-color: transparent;
601 border: none;
602 color: var(--accent-color);
603 cursor: pointer;
604 font-size: 1rem;
605 padding: 2px 8px;
606 border-radius: 3px;
607 transition: all 0.2s ease;
608 display: inline-block;
609 }
610
611 .read-more-btn:hover,
612 .external-links-toggle:hover,
613 .references-toggle:hover {
614 background-color: rgba(77, 250, 123, 0.1);
615 transform: scale(1.1);
616 }
617
618 .external-link {
619 color: var(--text-muted);
620 font-size: 1rem;
621 display: inline-block;
622 text-decoration: none;
623 padding: 2px 8px;
624 border-radius: 3px;
625 transition: all 0.2s ease;
626 }
627
628 .external-link:hover {
629 color: var(--accent-alt);
630 transform: scale(1.1);
631 }
632
633 #loading {
634 display: flex;
635 flex-direction: column;
636 align-items: center;
637 justify-content: center;
638 min-height: 200px;
639 }
640
641 .loading-spinner {
642 border: 3px solid rgba(77, 250, 123, 0.1);
643 border-top: 3px solid var(--accent-color);
644 border-radius: 50%;
645 width: 30px;
646 height: 30px;
647 animation: spin 1s linear infinite;
648 margin-bottom: 12px;
649 }
650
651 @keyframes spin {
652 0% { transform: rotate(0deg); }
653 100% { transform: rotate(360deg); }
654 }
655
656 .loading-text {
657 font-family: 'JetBrains Mono', monospace;
658 color: var(--accent-color);
659 font-size: 0.9rem;
660 }
661
662 .error-message {
663 color: #ff4d4d;
664 text-align: center;
665 padding: 20px;
666 font-family: 'JetBrains Mono', monospace;
667 }
668
669 /* Link item styling */
670 .link-item {
671 background-color: var(--card-bg);
672 border: 1px solid var(--border-color);
673 border-radius: 4px;
674 margin-bottom: 8px;
675 overflow: hidden;
676 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
677 display: flex;
678 align-items: center;
679 padding: 10px 15px;
680 position: relative;
681 }
682
683 .link-item:hover {
684 background-color: rgba(21, 39, 32, 0.95);
685 border-left-color: var(--accent-color);
686 }
687
688 .link-item::before {
689 content: '';
690 position: absolute;
691 top: 0;
692 left: 0;
693 right: 0;
694 bottom: 0;
695 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%),
696 rgba(77, 250, 123, 0.06) 0%,
697 rgba(77, 250, 123, 0.04) 30%,
698 rgba(77, 250, 123, 0) 70%);
699 opacity: 0;
700 z-index: 0;
701 transform: scale(0);
702 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1);
703 pointer-events: none;
704 }
705
706 .link-item:hover::before {
707 opacity: 0.6;
708 transform: scale(1.5);
709 }
710
711 .link-item-date {
712 font-family: 'JetBrains Mono', monospace;
713 font-size: 0.75rem;
714 color: var(--text-muted);
715 min-width: 120px;
716 margin-right: 15px;
717 white-space: nowrap;
718 }
719
720 .link-item-source {
721 font-family: 'JetBrains Mono', monospace;
722 font-size: 0.85rem;
723 color: var(--accent-alt);
724 min-width: 100px;
725 margin-right: 15px;
726 white-space: nowrap;
727 overflow: hidden;
728 text-overflow: ellipsis;
729 }
730
731 .link-item-content {
732 flex: 1;
733 }
734
735 .link-item-url-container {
736 display: flex;
737 align-items: center;
738 justify-content: space-between;
739 width: 100%;
740 }
741
742 .link-item-url {
743 color: var(--text-color);
744 text-decoration: none;
745 font-size: 0.9rem;
746 display: flex;
747 align-items: center;
748 flex: 1;
749 min-width: 0;
750 margin-right: 10px;
751 }
752
753 .link-item-url:hover {
754 color: var(--accent-color);
755 }
756
757 .link-item-path {
758 color: var(--text-muted);
759 margin-left: 8px;
760 font-size: 0.8rem;
761 white-space: nowrap;
762 overflow: hidden;
763 text-overflow: ellipsis;
764 }
765
766 .link-source-reference {
767 color: var(--text-muted);
768 text-decoration: none;
769 transition: color 0.2s ease;
770 font-size: 0.75rem;
771 white-space: nowrap;
772 max-width: 180px;
773 overflow: hidden;
774 text-overflow: ellipsis;
775 display: inline-block;
776 }
777
778 .link-source-reference:hover {
779 color: var(--accent-alt);
780 }
781
782 .link-source-icon {
783 display: inline-block;
784 font-size: 0.7rem;
785 margin-right: 2px;
786 color: var(--accent-alt);
787 }
788
789 .link-item-icon {
790 display: inline-block;
791 margin-right: 8px;
792 filter: invert(1);
793 width: 16px;
794 height: 16px;
795 vertical-align: middle;
796 }
797
798 @media (max-width: 900px) {
799 .feed-item-preview {
800 display: none;
801 }
802
803 .link-item {
804 flex-direction: column;
805 align-items: flex-start;
806 }
807
808 .link-item-date {
809 margin-bottom: 6px;
810 min-width: auto;
811 width: 100%;
812 }
813
814 .link-item-source {
815 margin-bottom: 6px;
816 }
817
818 .link-item-url-container {
819 flex-direction: column;
820 align-items: flex-start;
821 }
822
823 .link-source-reference {
824 margin-top: 4px;
825 max-width: none;
826 }
827
828 .header-container {
829 flex-direction: column;
830 align-items: flex-start;
831 padding: 10px 0;
832 }
833
834 .header-left {
835 flex-direction: column;
836 align-items: flex-start;
837 gap: 5px;
838 }
839
840 .tagline {
841 white-space: normal;
842 font-size: 0.7rem;
843 }
844
845 header {
846 height: auto;
847 }
848
849 main {
850 margin-top: 140px;
851 }
852
853 .timeline-sidebar {
854 top: 140px;
855 height: calc(100vh - 140px);
856 width: 60px; /* Make the sidebar a bit narrower on mobile */
857 }
858
859 .tabs {
860 margin: 10px 0;
861 }
862 }
863
864 /* People tab styling */
865 .people-header {
866 font-family: 'JetBrains Mono', monospace;
867 color: var(--accent-color);
868 margin-bottom: 20px;
869 font-size: 1.4rem;
870 font-weight: 600;
871 }
872
873 .people-container {
874 display: grid;
875 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
876 gap: 20px;
877 }
878
879 .person-card {
880 background-color: var(--card-bg);
881 border: 1px solid var(--border-color);
882 border-radius: 4px;
883 overflow: hidden;
884 transition: all 0.2s ease;
885 padding: 15px;
886 }
887
888 .person-card:hover {
889 border-left-color: var(--accent-color);
890 background-color: rgba(77, 250, 123, 0.03);
891 }
892
893 .person-name {
894 font-family: 'JetBrains Mono', monospace;
895 color: var(--accent-alt);
896 font-size: 1.1rem;
897 margin-bottom: 8px;
898 font-weight: 600;
899 }
900
901 .person-site {
902 font-size: 0.9rem;
903 color: var(--text-muted);
904 margin-bottom: 12px;
905 }
906
907 .person-site a {
908 color: var(--text-muted);
909 text-decoration: none;
910 transition: color 0.2s ease;
911 }
912
913 .person-site a:hover {
914 color: var(--accent-color);
915 }
916
917 .person-stats {
918 display: flex;
919 gap: 15px;
920 margin-bottom: 15px;
921 font-family: 'JetBrains Mono', monospace;
922 font-size: 0.85rem;
923 }
924
925 .person-stat {
926 display: flex;
927 flex-direction: column;
928 align-items: center;
929 }
930
931 .stat-value {
932 color: var(--accent-color);
933 font-size: 1.1rem;
934 font-weight: 600;
935 }
936
937 .stat-label {
938 color: var(--text-muted);
939 font-size: 0.75rem;
940 }
941
942 .person-recent {
943 margin-top: 12px;
944 }
945
946 .recent-title {
947 font-family: 'JetBrains Mono', monospace;
948 color: var(--text-muted);
949 font-size: 0.85rem;
950 margin-bottom: 8px;
951 }
952
953 .recent-posts {
954 display: flex;
955 flex-direction: column;
956 gap: 8px;
957 }
958
959 .recent-post {
960 padding: 8px;
961 background-color: rgba(77, 250, 123, 0.03);
962 border-radius: 3px;
963 font-size: 0.9rem;
964 }
965
966 .recent-post a {
967 color: var(--text-color);
968 text-decoration: none;
969 transition: color 0.2s ease;
970 }
971
972 .recent-post a:hover {
973 color: var(--accent-color);
974 }
975
976 .recent-post-date {
977 font-family: 'JetBrains Mono', monospace;
978 color: var(--text-muted);
979 font-size: 0.75rem;
980 margin-top: 3px;
981 }
982
983 @media (max-width: 600px) {
984 .feed-item-author {
985 min-width: 50px;
986 margin-right: 10px;
987 }
988
989 .feed-item-date {
990 min-width: auto;
991 width: 100%;
992 margin-bottom: 5px;
993 }
994
995 .feed-item-row {
996 flex-direction: column;
997 align-items: flex-start;
998 }
999
1000 .tabs {
1001 gap: 2px;
1002 width: 100%;
1003 justify-content: space-between;
1004 }
1005
1006 .tab-button {
1007 padding: 6px 8px;
1008 font-size: 0.75rem;
1009 flex-grow: 1;
1010 text-align: center;
1011 }
1012
1013 .people-container {
1014 grid-template-columns: 1fr;
1015 }
1016
1017
1018 main {
1019 margin-top: 150px;
1020 }
1021
1022 .timeline-sidebar {
1023 top: 150px;
1024 height: calc(100vh - 150px);
1025 width: 50px; /* Even narrower on very small screens */
1026 }
1027
1028 .content {
1029 padding-left: 50px; /* Match the sidebar width on small screens */
1030 }
1031 }
1032 </style>
1033</head>
1034<body>
1035 <canvas id="matrix-background"></canvas>
1036 <header>
1037 <div class="header-container">
1038 <div class="header-left">
1039 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;">
1040 <div class="logo">Atomic<span>EEG</span></div>
1041 </a>
1042 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div>
1043 </div>
1044 <div class="tabs">
1045 <button class="tab-button active" data-tab="posts">Posts</button>
1046 <button class="tab-button" data-tab="links">Links</button>
1047 <button class="tab-button" data-tab="people">Vibes</button>
1048 </div>
1049 </div>
1050 </header>
1051
1052 <main>
1053 <section class="content">
1054 <div id="loading">
1055 <div class="loading-spinner"></div>
1056 <p class="loading-text">Growing Content...</p>
1057 </div>
1058 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div>
1059 <div id="link-items" class="tab-content feed-container" data-tab="links"></div>
1060 <div id="people-items" class="tab-content" data-tab="people">
1061 <h2 class="people-header">EEG Sources</h2>
1062 <div class="people-container"></div>
1063 </div>
1064 </section>
1065 <aside class="timeline-sidebar" id="timeline-sidebar">
1066 <!-- Timeline will be populated via JavaScript -->
1067 </aside>
1068 </main>
1069
1070 <script>
1071 document.addEventListener('DOMContentLoaded', async () => {
1072 // Matrix background effect
1073 const canvas = document.getElementById('matrix-background');
1074 const ctx = canvas.getContext('2d');
1075
1076 // Set canvas size to match window
1077 function resizeCanvas() {
1078 canvas.width = window.innerWidth;
1079 canvas.height = window.innerHeight;
1080 }
1081 resizeCanvas();
1082 window.addEventListener('resize', resizeCanvas);
1083
1084 // Vine/plant-related characters and elements
1085 const vineChars = '┃┃│┋┇┊┆╽╿┴┬╵╷└┕┖┗┘┙┚┛╘╙╚╛╯╰╱╲⌠⌡╎▏▕⏐▌▐░▒▓◥◤◢◣⎸⎹│';
1086 const leafChars = '☘❀✿❁❃❇❈❉❊❋✣✤✥✦✧✩✪✫✬✭✮✾✿❀❁❂❃❄⚘♠♣⚜⚘☘';
1087 const branchChars = '┌┐┘└├┬┴┤┼─┄┈┉┊┋╱╲╳☂⚢⌒~∞≈≋⋆✧✦✫';
1088 const fontSize = 14;
1089 const columns = Math.floor(canvas.width / fontSize * 0.7); // Fewer columns for sparser vines
1090
1091 // Drop positions for each column
1092 const drops = [];
1093
1094 // Initialize drops at random positions
1095 for (let i = 0; i < columns; i++) {
1096 // Random starting position
1097 drops[i] = Math.random() * -canvas.height;
1098 }
1099
1100 // Set up column types - some will be vines, some will have leaves
1101 const columnTypes = [];
1102 for (let i = 0; i < columns; i++) {
1103 // 70% of columns are vines, 25% are leaves, 5% are cross-connections
1104 const rand = Math.random();
1105 if (rand < 0.7) {
1106 columnTypes[i] = 'vine';
1107 } else if (rand < 0.95) {
1108 columnTypes[i] = 'leaf';
1109 } else {
1110 columnTypes[i] = 'branch';
1111 }
1112 }
1113
1114 // Store connections between vines
1115 const connections = [];
1116
1117 // Helper function to find nearby columns
1118 function findNearbyColumns(columnIndex, maxDistance = 3) {
1119 const nearby = [];
1120 for (let i = 0; i < columns; i++) {
1121 if (i !== columnIndex && Math.abs(i - columnIndex) <= maxDistance) {
1122 nearby.push(i);
1123 }
1124 }
1125 return nearby;
1126 }
1127
1128 // Last time random chars were changed
1129 const lastCharChangeTime = [];
1130 // The current characters displayed
1131 const currentChars = [];
1132 // Width/thickness of vines
1133 const vineThickness = [];
1134
1135 for (let i = 0; i < columns; i++) {
1136 lastCharChangeTime[i] = [];
1137 currentChars[i] = [];
1138
1139 // Random vine thickness between 1-3
1140 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1141
1142 for (let j = 0; j < canvas.height / fontSize; j++) {
1143 lastCharChangeTime[i][j] = 0;
1144
1145 if (columnTypes[i] === 'vine') {
1146 // Choose vine characters based on position and thickness
1147 if (j === 0) {
1148 // Top of vine - might be a leaf or flower
1149 currentChars[i][j] = Math.random() < 0.6 ?
1150 leafChars.charAt(Math.floor(Math.random() * leafChars.length)) :
1151 vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1152 } else {
1153 // Main vine character
1154 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1155 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1156 }
1157 } else if (columnTypes[i] === 'leaf') {
1158 // Leaf character - only at top or occasional spots along the vine
1159 if (j === 0 || Math.random() < 0.2) {
1160 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1161 } else {
1162 // Connecting vine
1163 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * 5)); // Thin vine characters
1164 }
1165 } else if (columnTypes[i] === 'branch') {
1166 // This is a branching column - will form connections between vines
1167 if (j === 0) {
1168 // Top of branch might be a leaf or flower
1169 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1170 } else {
1171 // Branch characters - horizontal or diagonal connectors
1172 currentChars[i][j] = branchChars.charAt(Math.floor(Math.random() * branchChars.length));
1173 }
1174 }
1175 }
1176 }
1177
1178 // Time when animation started
1179 const startTime = Date.now();
1180
1181 // Track connections between vines
1182 const crossConnections = [];
1183
1184 // Draw the rainforest vine effect
1185 function drawVineEffect() {
1186 // Semi-transparent background to create fade effect
1187 ctx.fillStyle = 'rgba(10, 23, 15, 0.05)';
1188 ctx.fillRect(0, 0, canvas.width, canvas.height);
1189
1190 const now = Date.now();
1191
1192 // Set font
1193 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`;
1194 ctx.textAlign = 'center';
1195
1196 // First, create cross-connections
1197 // Create new cross-connections occasionally
1198 if (Math.random() < 0.01) {
1199 // Find a source vine that's grown enough
1200 const sourceIndex = Math.floor(Math.random() * columns);
1201 if (drops[sourceIndex] > 100 && columnTypes[sourceIndex] === 'vine') {
1202 // Find a nearby column to connect to
1203 const nearby = findNearbyColumns(sourceIndex, 3);
1204 if (nearby.length > 0) {
1205 const targetIndex = nearby[Math.floor(Math.random() * nearby.length)];
1206 if (drops[targetIndex] > 80) {
1207 // The height should be somewhere between the two vines
1208 const sourceHeight = drops[sourceIndex];
1209 const targetHeight = drops[targetIndex];
1210 const connectionHeight = Math.min(sourceHeight, targetHeight) * 0.8;
1211
1212 // Create the connection
1213 crossConnections.push({
1214 source: sourceIndex,
1215 target: targetIndex,
1216 height: connectionHeight,
1217 character: branchChars.charAt(Math.floor(Math.random() * branchChars.length)),
1218 created: now
1219 });
1220 }
1221 }
1222 }
1223 }
1224
1225 // For each column
1226 for (let i = 0; i < columns; i++) {
1227 // Calculate current position of this vine
1228 const x = i * fontSize * 1.5; // Space vines further apart
1229
1230 // For each character in this column
1231 for (let j = 0; j < Math.ceil(drops[i] / fontSize); j++) {
1232 const y = j * fontSize;
1233
1234 // Skip rendering some characters to create gaps in vines
1235 if (Math.random() < 0.05 && j > 3) continue;
1236
1237 // Calculate age of this character
1238 const charAge = now - lastCharChangeTime[i][j];
1239
1240 // Randomly change some characters over time - slower rate for natural movement
1241 if (j === 0 && (Math.random() < 0.005 || charAge > 8000)) {
1242 // Top character might change between leaves/flowers
1243 if (columnTypes[i] === 'leaf' || Math.random() < 0.6) {
1244 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1245 } else {
1246 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineChars.length));
1247 }
1248 lastCharChangeTime[i][j] = now;
1249 } else if (j > 0 && Math.random() < 0.001) {
1250 // Occasionally grow new leaves along the vine
1251 if (Math.random() < 0.2) {
1252 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1253 } else {
1254 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1);
1255 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex));
1256 }
1257 lastCharChangeTime[i][j] = now;
1258 }
1259
1260 // Calculate distance from head of the vine
1261 const distanceFromHead = (drops[i] - y);
1262
1263 // Determine color based on position and type
1264 if (j === 0 && (currentChars[i][j] === '❀' || currentChars[i][j] === '✿' ||
1265 currentChars[i][j] === '❁' || currentChars[i][j] === '✾')) {
1266 // Flowers are more colorful - pinkish
1267 ctx.fillStyle = 'rgba(255, 180, 220, 0.9)';
1268 ctx.shadowColor = 'rgba(255, 150, 200, 0.6)';
1269 ctx.shadowBlur = 5;
1270 } else if (currentChars[i][j] === '☘' || leafChars.includes(currentChars[i][j])) {
1271 // Leaf characters are brightest with different green
1272 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1273 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1274 ctx.shadowBlur = 3;
1275 } else if (distanceFromHead < fontSize) {
1276 // Growing tip of vine is brightest
1277 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)';
1278 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)';
1279 ctx.shadowBlur = 5;
1280 } else if (distanceFromHead < fontSize * 8) {
1281 // Newer part of vine is brighter
1282 const opacity = 0.8 - (distanceFromHead / (fontSize * 10));
1283 ctx.fillStyle = `rgba(77, 180, 100, ${opacity.toFixed(2)})`;
1284 ctx.shadowColor = 'transparent';
1285 ctx.shadowBlur = 0;
1286 } else {
1287 // Older parts of vine are darker
1288 const opacity = Math.max(0, 0.4 - (distanceFromHead / (canvas.height * 2)));
1289 // Darker green for older vines
1290 ctx.fillStyle = `rgba(40, 120, 60, ${opacity.toFixed(2)})`;
1291 ctx.shadowColor = 'transparent';
1292 ctx.shadowBlur = 0;
1293 }
1294
1295 // Add slight random swaying to vines
1296 const swayAmount = Math.sin((now / 2000) + i) * 2; // Gentle swaying effect
1297 const adjustedX = x + swayAmount;
1298
1299 // Draw the character
1300 if (y < canvas.height) {
1301 // Adjust size for special characters
1302 if (leafChars.includes(currentChars[i][j])) {
1303 ctx.font = `${fontSize * 1.2}px 'JetBrains Mono', monospace`;
1304 ctx.fillText(currentChars[i][j], adjustedX, y);
1305 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Reset font
1306 } else {
1307 ctx.fillText(currentChars[i][j], adjustedX, y);
1308 }
1309 }
1310 }
1311
1312 // Move the vine down - slower for natural growth
1313 drops[i] += fontSize * (0.02 + Math.random() * 0.03);
1314
1315 // Reset vine when it reaches bottom or randomly (much less frequent)
1316 if (drops[i] > canvas.height * 2 || (Math.random() < 0.0005 && drops[i] > canvas.height * 0.6)) {
1317 drops[i] = Math.random() * -30;
1318 // Maybe change vine type
1319 if (Math.random() < 0.3) {
1320 columnTypes[i] = Math.random() < 0.7 ? 'vine' : 'leaf';
1321 vineThickness[i] = Math.floor(Math.random() * 3) + 1;
1322 }
1323 }
1324 }
1325
1326 // Draw cross connections between vines
1327 crossConnections.forEach((connection, index) => {
1328 const sourceX = connection.source * fontSize * 1.5;
1329 const targetX = connection.target * fontSize * 1.5;
1330 const y = connection.height;
1331 const heightIndex = Math.floor(y / fontSize);
1332
1333 // Calculate a safe display Y - make sure it's within the grown vines
1334 const safeY = Math.min(
1335 Math.min(drops[connection.source], drops[connection.target]),
1336 connection.height
1337 );
1338
1339 // Convert to display coords
1340 const displayY = Math.floor(safeY / fontSize) * fontSize;
1341
1342 // Only draw if connection is within visible area
1343 if (displayY < 0 || displayY > canvas.height) return;
1344
1345 // Connection age effect
1346 const age = now - connection.created;
1347 const maxAge = 20000; // 20 seconds lifetime for connections
1348
1349 // Remove old connections
1350 if (age > maxAge) {
1351 crossConnections.splice(index, 1);
1352 return;
1353 }
1354
1355 // Fade in/out effect
1356 let opacity = 1.0;
1357 if (age < 1000) {
1358 // Fade in
1359 opacity = age / 1000;
1360 } else if (age > maxAge - 2000) {
1361 // Fade out
1362 opacity = (maxAge - age) / 2000;
1363 }
1364
1365 // Draw connection
1366 const connectionWidth = Math.abs(targetX - sourceX);
1367 const steps = Math.ceil(connectionWidth / (fontSize * 0.8));
1368
1369 // Lighter green for branches
1370 ctx.fillStyle = `rgba(120, 255, 150, ${opacity.toFixed(2)})`;
1371 ctx.shadowColor = 'rgba(77, 250, 123, 0.4)';
1372 ctx.shadowBlur = 2;
1373
1374 // Draw branch character at each step
1375 let branchChar;
1376
1377 if (sourceX < targetX) {
1378 // Left to right
1379 branchChar = '─';
1380 } else {
1381 // Right to left
1382 branchChar = '─';
1383 }
1384
1385 for (let s = 0; s <= steps; s++) {
1386 // Calculate position
1387 const progress = s / steps;
1388 const stepX = sourceX + (targetX - sourceX) * progress;
1389 const wiggle = Math.sin(progress * Math.PI) * 5;
1390
1391 // Choose appropriate connection character
1392 let connChar = branchChar;
1393
1394 // Special characters for start, middle and end
1395 if (s === 0) {
1396 connChar = '├';
1397 } else if (s === steps) {
1398 connChar = '┤';
1399 } else if (s === Math.floor(steps/2)) {
1400 // Add a leaf or flower in the middle sometimes
1401 if (Math.random() < 0.3) {
1402 connChar = leafChars.charAt(Math.floor(Math.random() * leafChars.length));
1403 } else {
1404 connChar = s % 2 === 0 ? '┼' : '┴';
1405 }
1406 } else {
1407 // Occasional decorative elements
1408 if (Math.random() < 0.1) {
1409 connChar = '·';
1410 }
1411 }
1412
1413 ctx.fillText(connChar, stepX, displayY + wiggle);
1414 }
1415 });
1416
1417 // Schedule next frame
1418 requestAnimationFrame(drawVineEffect);
1419 }
1420
1421 // Start the animation
1422 drawVineEffect();
1423 // Add hover event listeners after DOM content is loaded
1424 function setupHoverEffects() {
1425 // Keep track of the currently active item
1426 let currentHoveredItem = null;
1427
1428 document.querySelectorAll('.feed-item').forEach(item => {
1429 item.addEventListener('mouseenter', () => {
1430 // Set this as current hovered item
1431 currentHoveredItem = item;
1432 });
1433
1434 // Track mouse position for the ripple effect
1435 item.addEventListener('mousemove', (e) => {
1436 // Get position relative to the element
1437 const rect = item.getBoundingClientRect();
1438 const x = ((e.clientX - rect.left) / rect.width) * 100;
1439 const y = ((e.clientY - rect.top) / rect.height) * 100;
1440
1441 // Set custom properties for the radial gradient
1442 item.style.setProperty('--mouse-x', `${x}%`);
1443 item.style.setProperty('--mouse-y', `${y}%`);
1444 });
1445 });
1446 }
1447
1448 // Tab switching functionality
1449 // Create global variables to store state
1450 let globalFeedObserver = null;
1451 let lastActiveYear = null;
1452 let lastActiveMonth = null;
1453
1454 function setupObserver(options) {
1455 // Create a new intersection observer for handling timeline scrolling
1456 return new IntersectionObserver((entries) => {
1457 entries.forEach(entry => {
1458 if (entry.isIntersecting) {
1459 const year = entry.target.getAttribute('data-year');
1460 const month = entry.target.getAttribute('data-month');
1461
1462 // Get the active tab
1463 const activeTab = document.querySelector('.tab-content.active');
1464 const activeTabId = activeTab.getAttribute('data-tab');
1465
1466 // Only process if we're on posts or links tab
1467 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) {
1468 // Clear all active classes
1469 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1470 el.classList.remove('active');
1471 });
1472
1473 // Set active classes
1474 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
1475 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
1476
1477 if (yearEl) {
1478 yearEl.classList.add('active');
1479 // Store the last active year globally
1480 lastActiveYear = year;
1481 }
1482 if (monthEl) {
1483 monthEl.classList.add('active');
1484 // Store the last active month globally
1485 lastActiveMonth = month;
1486 }
1487
1488 // Month headers are now simple inline elements, no need to toggle visibility
1489 }
1490 }
1491 });
1492 }, options);
1493 }
1494
1495 function setupTabs() {
1496 const tabButtons = document.querySelectorAll('.tab-button');
1497 const tabContents = document.querySelectorAll('.tab-content');
1498 const timeline = document.getElementById('timeline-sidebar');
1499
1500 tabButtons.forEach(button => {
1501 button.addEventListener('click', () => {
1502 const tabName = button.getAttribute('data-tab');
1503
1504 // Deactivate all tabs
1505 tabButtons.forEach(btn => btn.classList.remove('active'));
1506 tabContents.forEach(content => content.classList.remove('active'));
1507
1508 // Activate selected tab
1509 button.classList.add('active');
1510 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`);
1511 tabContent.classList.add('active');
1512
1513 // Month headers are now simple inline elements, no need to toggle visibility
1514
1515 // Show or hide timeline sidebar based on active tab
1516 if (tabName === 'people') {
1517 timeline.style.display = 'none';
1518 document.querySelector('.content').style.paddingLeft = '0';
1519 } else {
1520 timeline.style.display = 'flex';
1521 document.querySelector('.content').style.paddingLeft = 'var(--sidebar-width)';
1522
1523 // Reset timeline highlighting
1524 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
1525 el.classList.remove('active');
1526 });
1527
1528 // Disconnect and recreate the observer to ensure proper tracking
1529 if (globalFeedObserver) {
1530 globalFeedObserver.disconnect();
1531 }
1532
1533 // Setup a new observer
1534 globalFeedObserver = setupObserver({
1535 root: null,
1536 rootMargin: '-80px 0px',
1537 threshold: 0.1
1538 });
1539
1540 // Observe all items in the active tab
1541 observeAllDateItems();
1542
1543 // Always scroll to top when switching tabs
1544 window.scrollTo({ top: 0, behavior: 'smooth' });
1545 }
1546 });
1547 });
1548 }
1549 const feedItemsContainer = document.getElementById('feed-items');
1550 const loadingContainer = document.getElementById('loading');
1551
1552 // Function to format date (only date, no time)
1553 function formatDate(dateString) {
1554 const date = new Date(dateString);
1555 return date.toLocaleDateString('en-US', {
1556 year: 'numeric',
1557 month: 'short',
1558 day: 'numeric'
1559 });
1560 }
1561
1562 // We no longer need preview processing functions
1563 // since we're displaying content as-is with HTML tags
1564
1565 // Function removed - we no longer toggle full content
1566
1567 // Removed the external links toggle function as it's no longer needed
1568
1569 // Reference toggle function removed - references are now shown with CSS on hover
1570
1571 try {
1572 // Fetch the Atom feed and threads data in parallel
1573 const [feedResponse, threadsResponse] = await Promise.all([
1574 fetch('eeg.xml'),
1575 fetch('threads.json')
1576 ]);
1577
1578 if (!feedResponse.ok) {
1579 throw new Error('Failed to fetch feed');
1580 }
1581
1582 if (!threadsResponse.ok) {
1583 throw new Error('Failed to fetch threads data');
1584 }
1585
1586 const xmlText = await feedResponse.text();
1587 const threadsData = await threadsResponse.json();
1588
1589 const parser = new DOMParser();
1590 const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
1591
1592 // Process feed entries
1593 const entries = xmlDoc.getElementsByTagName('entry');
1594 const sources = new Set();
1595
1596 // No longer updating the entry count element since it's been removed
1597
1598 // Map to store entries by ID for easy lookup
1599 const entriesById = {};
1600
1601 // First pass: extract all entries and build the ID map
1602 for (let i = 0; i < entries.length; i++) {
1603 const entry = entries[i];
1604
1605 // Extract entry data
1606 const id = entry.getElementsByTagName('id')[0]?.textContent || '';
1607 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title';
1608 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#';
1609 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0];
1610 const contentText = contentElement?.textContent || '';
1611 const contentType = contentElement?.getAttribute('type') || 'text';
1612 const published = entry.getElementsByTagName('published')[0]?.textContent ||
1613 entry.getElementsByTagName('updated')[0]?.textContent || '';
1614 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown';
1615 const categories = entry.getElementsByTagName('category');
1616
1617 // Extract source from category (we're using category to store source name)
1618 let source = 'Unknown Source';
1619 if (categories.length > 0) {
1620 source = categories[0].getAttribute('term');
1621 sources.add(source);
1622 }
1623
1624 // Properly handle the content based on content type
1625 let contentHtml;
1626 if (contentType === 'html' || contentType === 'text/html') {
1627 // For HTML content, create a div and set innerHTML
1628 contentHtml = contentText;
1629 } else {
1630 // For text content, escape it and preserve newlines
1631 contentHtml = contentText
1632 .replace(/&/g, '&')
1633 .replace(/</g, '<')
1634 .replace(/>/g, '>')
1635 .replace(/\n/g, '<br>');
1636 }
1637
1638 // Store the entry data
1639 entriesById[id] = {
1640 id,
1641 articleId: `article-${i}`,
1642 title,
1643 link,
1644 contentHtml, // Use the content as-is with HTML tags
1645 published,
1646 author,
1647 source,
1648 threadGroup: null,
1649 isThreadParent: false,
1650 threadParentId: null,
1651 inThread: false,
1652 threadPosition: 0,
1653 externalLinks: [],
1654 };
1655 }
1656
1657 // Process reference relationships and external links
1658 for (const entryId in entriesById) {
1659 if (threadsData[entryId]) {
1660 const threadInfo = threadsData[entryId];
1661 const entry = entriesById[entryId];
1662
1663 // Track external links for this entry
1664 entry.externalLinks = [];
1665 if (threadInfo.external_links && threadInfo.external_links.length > 0) {
1666 entry.externalLinks = threadInfo.external_links.map(link => ({
1667 url: link.url,
1668 normalized_url: link.normalized_url
1669 }));
1670 }
1671
1672 // Track references to other posts (outgoing links)
1673 entry.referencesTo = [];
1674 if (threadInfo.references && threadInfo.references.length > 0) {
1675 // Filter for only in-feed references
1676 threadInfo.references.forEach(ref => {
1677 if (ref.in_feed === true && entriesById[ref.id]) {
1678 entry.referencesTo.push({
1679 id: ref.id,
1680 title: ref.title,
1681 link: ref.link,
1682 author: entriesById[ref.id].author
1683 });
1684 }
1685 });
1686 }
1687
1688 // Track posts that reference this one (incoming links)
1689 entry.referencedBy = [];
1690 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) {
1691 // Filter for only in-feed references
1692 threadInfo.referenced_by.forEach(ref => {
1693 if (ref.in_feed === true && entriesById[ref.id]) {
1694 entry.referencedBy.push({
1695 id: ref.id,
1696 title: ref.title,
1697 link: ref.link,
1698 author: entriesById[ref.id].author
1699 });
1700 }
1701 });
1702 }
1703 }
1704 }
1705
1706 // Sort by date and create HTML
1707 const entriesArray = Object.values(entriesById);
1708 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published));
1709
1710 // Create a timeline structure by year/month
1711 const timeline = new Map();
1712 const monthNames = [
1713 'January', 'February', 'March', 'April', 'May', 'June',
1714 'July', 'August', 'September', 'October', 'November', 'December'
1715 ];
1716 const shortMonthNames = [
1717 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1718 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
1719 ];
1720
1721 // Group entries by year and month for the timeline
1722 entriesArray.forEach(entry => {
1723 const date = new Date(entry.published);
1724 const year = date.getFullYear();
1725 const month = date.getMonth();
1726
1727 if (!timeline.has(year)) {
1728 timeline.set(year, new Map());
1729 }
1730
1731 const yearMap = timeline.get(year);
1732 if (!yearMap.has(month)) {
1733 yearMap.set(month, []);
1734 }
1735
1736 yearMap.get(month).push(entry);
1737 });
1738
1739 // Process all entries in strict date order
1740 let entriesHTML = '';
1741 const processedArticleIds = new Set();
1742
1743 // Create a copy of entriesArray to process strictly by date
1744 const entriesByDate = [...entriesArray];
1745
1746 // Track current month/year for date headers
1747 let currentYear = null;
1748 let currentMonth = null;
1749
1750 // Process each entry in date order
1751 for (const entry of entriesByDate) {
1752 // Skip entries already processed
1753 if (processedArticleIds.has(entry.articleId)) continue;
1754
1755 const date = new Date(entry.published);
1756 const year = date.getFullYear();
1757 const month = date.getMonth();
1758 const dateAttr = `data-year="${year}" data-month="${month}"`;
1759
1760 // Check if we need to add a new month/year header
1761 if (currentYear !== year || currentMonth !== month) {
1762 currentYear = year;
1763 currentMonth = month;
1764
1765 entriesHTML += `
1766 <div class="month-year-header" ${dateAttr}>
1767 <div class="month-year-label">${monthNames[month]} ${year}</div>
1768 </div>`;
1769 }
1770
1771 // Function to get day with ordinal suffix
1772 function getDayWithOrdinal(date) {
1773 const day = date.getDate();
1774 let suffix = "th";
1775 if (day % 10 === 1 && day !== 11) {
1776 suffix = "st";
1777 } else if (day % 10 === 2 && day !== 12) {
1778 suffix = "nd";
1779 } else if (day % 10 === 3 && day !== 13) {
1780 suffix = "rd";
1781 }
1782 return day + suffix;
1783 }
1784
1785 // Add entry
1786 entriesHTML += `
1787 <article id="${entry.articleId}" class="feed-item" ${dateAttr}>
1788 <div class="feed-item-row">
1789 <div class="feed-item-date">${getDayWithOrdinal(date)} ${shortMonthNames[month]} ${year}</div>
1790 <div class="feed-item-author">${entry.author}</div>
1791 <div class="feed-item-content-wrapper">
1792 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div>
1793
1794 ${entry.externalLinks && entry.externalLinks.length > 0 ? `
1795 <div class="preview-links">
1796 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => {
1797 // Find the first link object with this URL
1798 const link = entry.externalLinks.find(l => l.url === uniqueUrl);
1799 const url = new URL(link.url);
1800 let displayText = url.hostname.replace('www.', '');
1801
1802 // Special handling for GitHub links
1803 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
1804 // Extract the parts from pathname (remove leading slash)
1805 const parts = url.pathname.substring(1).split('/').filter(part => part);
1806 if (parts.length >= 2) {
1807 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`;
1808 }
1809 }
1810
1811 // Special handling for Wikipedia links
1812 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
1813 const titlePart = url.pathname.split('/').pop();
1814 if (titlePart) {
1815 const title = decodeURIComponent(titlePart).replace(/_/g, ' ');
1816 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`;
1817 }
1818 }
1819
1820 // Special handling for Twitter/X links
1821 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
1822 const parts = url.pathname.substring(1).split('/').filter(part => part);
1823 if (parts.length >= 1) {
1824 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1825 }
1826 }
1827
1828 // Special handling for LinkedIn links
1829 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
1830 const parts = url.pathname.substring(1).split('/').filter(part => part);
1831 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`;
1832 if (parts.length >= 2 && parts[0] === 'in') {
1833 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`;
1834 }
1835 }
1836
1837 // Special handling for YouTube links
1838 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
1839 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`;
1840 // Try to get video title from URL parameters
1841 const videoId = url.searchParams.get('v');
1842 if (url.pathname.includes('watch') && videoId) {
1843 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`;
1844 }
1845 }
1846
1847 // Special handling for OCaml package links
1848 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
1849 const parts = url.pathname.substring(1).split('/').filter(part => part);
1850 if (parts.length >= 2) {
1851 const packageName = parts[1];
1852 displayText = `${packageName} (OCaml)`;
1853 }
1854 }
1855
1856 // Special handling for Medium links
1857 else if (url.hostname.includes('medium.com')) {
1858 const parts = url.pathname.substring(1).split('/').filter(part => part);
1859 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`;
1860 if (parts.length >= 1) {
1861 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1862 }
1863 }
1864
1865 // Special handling for Stack Overflow links
1866 else if (url.hostname.includes('stackoverflow.com')) {
1867 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`;
1868 if (url.pathname.includes('questions')) {
1869 const parts = url.pathname.split('/');
1870 const questionId = parts.find(part => /^\d+$/.test(part));
1871 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`;
1872 }
1873 }
1874
1875 // Special handling for Dev.to links
1876 else if (url.hostname === 'dev.to') {
1877 const parts = url.pathname.substring(1).split('/').filter(part => part);
1878 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`;
1879 if (parts.length >= 1) {
1880 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`;
1881 }
1882 }
1883
1884 // Special handling for Reddit links
1885 else if (url.hostname.includes('reddit.com')) {
1886 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`;
1887 if (url.pathname.includes('/r/')) {
1888 const parts = url.pathname.split('/');
1889 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
1890 if (subreddit) {
1891 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`;
1892 }
1893 }
1894 }
1895
1896 // Special handling for Hacker News links
1897 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
1898 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`;
1899 if (url.pathname.includes('item')) {
1900 const itemId = url.searchParams.get('id');
1901 if (itemId) {
1902 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`;
1903 }
1904 }
1905 }
1906
1907 // Special handling for Bluesky links
1908 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
1909 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`;
1910 // Try to extract handle or post info
1911 const parts = url.pathname.substring(1).split('/').filter(part => part);
1912 if (parts.length >= 1) {
1913 if (parts[0] === 'profile') {
1914 // This is a profile link
1915 if (parts.length >= 2) {
1916 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`;
1917 }
1918 } else if (parts[0] === 'post') {
1919 // This is a post link
1920 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`;
1921 } else {
1922 // Assume it's a handle
1923 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`;
1924 }
1925 }
1926 }
1927
1928 // Determine link type for styling and future reference
1929 let linkType = '';
1930 if (url.hostname.includes('github')) linkType = 'github';
1931 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia';
1932 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter';
1933 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin';
1934 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') linkType = 'youtube';
1935 else if (url.hostname.includes('medium.com')) linkType = 'medium';
1936 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow';
1937 else if (url.hostname === 'dev.to') linkType = 'dev';
1938 else if (url.hostname.includes('reddit.com')) linkType = 'reddit';
1939 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews';
1940 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky';
1941 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml';
1942
1943 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`;
1944 }).join(' ')}
1945 </div>
1946 ` : ''}
1947
1948 ${entry.referencesTo && entry.referencesTo.length > 0 ? `
1949 <div class="preview-references">
1950 ${entry.referencesTo.map(ref => `
1951 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a>
1952 `).join(' ')}
1953 </div>
1954 ` : ''}
1955
1956 ${entry.referencedBy && entry.referencedBy.length > 0 ? `
1957 <div class="preview-references">
1958 ${entry.referencedBy.map(ref => `
1959 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a>
1960 `).join(' ')}
1961 </div>
1962 ` : ''}
1963 </div>
1964 </div>
1965 </article>
1966 `;
1967
1968 processedArticleIds.add(entry.articleId);
1969 }
1970
1971 // All articles have been processed in the main loop above
1972
1973 // No longer updating the source count element since it's been removed
1974
1975 // No toggle functions needed anymore
1976
1977 // Build timeline sidebar
1978 const timelineSidebar = document.getElementById('timeline-sidebar');
1979 let timelineHTML = '';
1980
1981 // Sort years in descending order
1982 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a);
1983
1984 sortedYears.forEach(year => {
1985 const yearMap = timeline.get(year);
1986 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`;
1987
1988 // Sort months in descending order (Dec to Jan)
1989 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a);
1990
1991 sortedMonths.forEach(month => {
1992 const entries = yearMap.get(month);
1993 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`;
1994 });
1995 });
1996
1997 timelineSidebar.innerHTML = timelineHTML;
1998
1999 // Set up scroll observer to highlight timeline items
2000 const observerOptions = {
2001 root: null,
2002 rootMargin: '-80px 0px',
2003 threshold: 0.1
2004 };
2005
2006 // Skip adding data attributes - we've already done this during HTML generation
2007
2008 // Create observer to track which period is in view
2009 globalFeedObserver = setupObserver(observerOptions);
2010
2011 // Hide loading, show content
2012 loadingContainer.style.display = 'none';
2013 feedItemsContainer.innerHTML = entriesHTML;
2014
2015 // Month headers are now all visible
2016
2017 // If we have entries, set the most recent (first) entry's date as active in the timeline
2018 if (entriesArray.length > 0) {
2019 const mostRecentEntry = entriesArray[0];
2020 const date = new Date(mostRecentEntry.published);
2021 const year = date.getFullYear();
2022 const month = date.getMonth();
2023
2024 // Set most recent date as the active period in the timeline
2025 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2026 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`);
2027
2028 // Clear all active classes first
2029 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2030 el.classList.remove('active');
2031 });
2032
2033 // Add active classes to the appropriate year/month
2034 if (yearEl) {
2035 yearEl.classList.add('active');
2036 lastActiveYear = year;
2037 }
2038 if (monthEl) {
2039 monthEl.classList.add('active');
2040 lastActiveMonth = month;
2041 }
2042
2043 // Month headers are now simple inline elements, no need to toggle visibility
2044 }
2045
2046 // Helper function to observe all items with date attributes
2047 function observeAllDateItems() {
2048 // Observe all feed items for scroll tracking
2049 document.querySelectorAll('.feed-item').forEach(item => {
2050 globalFeedObserver.observe(item);
2051 });
2052
2053 // Also observe link items for timeline highlighting
2054 document.querySelectorAll('.link-item').forEach(item => {
2055 globalFeedObserver.observe(item);
2056 });
2057 }
2058
2059 // Initial observation of all items
2060 observeAllDateItems();
2061
2062 // Set initial display state for timeline based on initial active tab
2063 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab');
2064 if (initialActiveTab === 'people') {
2065 document.getElementById('timeline-sidebar').style.display = 'none';
2066 document.querySelector('.content').style.paddingLeft = '0';
2067 } else {
2068 // Initialize the last active date from the first visible item
2069 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item';
2070 const visibleItems = Array.from(document.querySelectorAll(selector))
2071 .filter(item => {
2072 const rect = item.getBoundingClientRect();
2073 return rect.top >= 0 && rect.bottom <= window.innerHeight;
2074 });
2075
2076 if (visibleItems.length > 0) {
2077 lastActiveYear = visibleItems[0].getAttribute('data-year');
2078 lastActiveMonth = visibleItems[0].getAttribute('data-month');
2079 }
2080 }
2081
2082 // Set up hover effects and ripple animations
2083 setupHoverEffects();
2084
2085 // Create a ripple effect that travels across the content area
2086 const feedContainer = document.querySelector('.feed-container');
2087 feedContainer.addEventListener('mousemove', (e) => {
2088 // Ripple between items as mouse moves
2089 const items = document.querySelectorAll('.feed-item, .link-item');
2090 items.forEach(item => {
2091 const rect = item.getBoundingClientRect();
2092 const centerX = rect.left + rect.width / 2;
2093 const centerY = rect.top + rect.height / 2;
2094
2095 // Calculate distance from mouse to center of item
2096 const dx = e.clientX - centerX;
2097 const dy = e.clientY - centerY;
2098 const distance = Math.sqrt(dx * dx + dy * dy);
2099
2100 // Calculate fade based on distance
2101 const maxDistance = 400; // max distance for effect
2102 const intensity = Math.max(0, 1 - (distance / maxDistance));
2103
2104 if (intensity > 0.05) {
2105 // Extremely subtle glow - minimized for optimal text readability
2106 item.style.boxShadow = `0 0 ${intensity * 8}px var(--hover-glow)`;
2107 item.style.transform = `scale(${1 + intensity * 0.005})`;
2108 item.style.transition = 'box-shadow 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
2109 } else {
2110 item.style.boxShadow = 'none';
2111 item.style.transform = 'scale(1)';
2112 }
2113 });
2114 });
2115
2116 // Add hover tracking for link items too
2117 document.querySelectorAll('.link-item').forEach(item => {
2118 item.addEventListener('mousemove', (e) => {
2119 // Get position relative to the element
2120 const rect = item.getBoundingClientRect();
2121 const x = ((e.clientX - rect.left) / rect.width) * 100;
2122 const y = ((e.clientY - rect.top) / rect.height) * 100;
2123
2124 // Set custom properties for the radial gradient
2125 item.style.setProperty('--mouse-x', `${x}%`);
2126 item.style.setProperty('--mouse-y', `${y}%`);
2127 });
2128 });
2129
2130 // Process all external links from entries
2131 const linksContainer = document.getElementById('link-items');
2132 const allExternalLinks = [];
2133
2134 // Collect all external links from all entries with metadata
2135 Object.values(entriesById).forEach(entry => {
2136 if (entry.externalLinks && entry.externalLinks.length > 0) {
2137 entry.externalLinks.forEach(link => {
2138 // Only process if it's a valid URL
2139 if (link.url) {
2140 try {
2141 const url = new URL(link.url);
2142
2143 // Create a link object with metadata
2144 allExternalLinks.push({
2145 url: link.url,
2146 normalized_url: link.normalized_url,
2147 source: entry.author,
2148 date: new Date(entry.published),
2149 sourceFeed: entry.source,
2150 sourceTitle: entry.title,
2151 sourceLink: entry.link
2152 });
2153 } catch (e) {
2154 // Skip invalid URLs
2155 console.warn("Invalid URL:", link.url);
2156 }
2157 }
2158 });
2159 }
2160 });
2161
2162 // Sort links by date (newest first)
2163 allExternalLinks.sort((a, b) => b.date - a.date);
2164
2165 // Deduplicate links (keeping most recent occurrence)
2166 const dedupedLinks = [];
2167 const seenUrls = new Set();
2168
2169 allExternalLinks.forEach(link => {
2170 // Deduplicate based on normalized URL
2171 if (!seenUrls.has(link.normalized_url)) {
2172 seenUrls.add(link.normalized_url);
2173 dedupedLinks.push(link);
2174 }
2175 });
2176
2177 // Generate HTML for links view
2178 let linksHTML = '';
2179
2180 // Track current month/year for date headers in links view
2181 let currentLinkYear = null;
2182 let currentLinkMonth = null;
2183
2184 dedupedLinks.forEach(link => {
2185 const date = link.date;
2186 const year = date.getFullYear();
2187 const month = date.getMonth();
2188 const dateFormatted = formatDate(date);
2189 const url = new URL(link.url);
2190 let displayText = url.hostname.replace('www.', '');
2191 let iconPath = '';
2192
2193 // Check if we need to add a new month/year header
2194 if (currentLinkYear !== year || currentLinkMonth !== month) {
2195 currentLinkYear = year;
2196 currentLinkMonth = month;
2197
2198 linksHTML += `
2199 <div class="month-year-header" data-year="${year}" data-month="${month}">
2200 <div class="month-year-label">${monthNames[month]} ${year}</div>
2201 </div>`;
2202 }
2203
2204 // Platform-specific display logic (same as in the main feed)
2205 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') {
2206 const parts = url.pathname.substring(1).split('/').filter(part => part);
2207 if (parts.length >= 2) {
2208 displayText = `${parts[0]}/${parts[1]}`;
2209 iconPath = 'brands-github.svg';
2210 }
2211 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) {
2212 const titlePart = url.pathname.split('/').pop();
2213 if (titlePart) {
2214 displayText = decodeURIComponent(titlePart).replace(/_/g, ' ');
2215 iconPath = 'brands-wikipedia-w.svg';
2216 }
2217 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') {
2218 const parts = url.pathname.substring(1).split('/').filter(part => part);
2219 if (parts.length >= 1) {
2220 displayText = `@${parts[0]}`;
2221 iconPath = 'brands-x-twitter.svg';
2222 }
2223 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) {
2224 iconPath = 'brands-linkedin.svg';
2225 displayText = 'LinkedIn';
2226 const parts = url.pathname.substring(1).split('/').filter(part => part);
2227 if (parts.length >= 2 && parts[0] === 'in') {
2228 displayText = parts[1];
2229 }
2230 } else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') {
2231 iconPath = 'brands-youtube.svg';
2232 displayText = 'YouTube Video';
2233 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) {
2234 const parts = url.pathname.substring(1).split('/').filter(part => part);
2235 if (parts.length >= 2) {
2236 const packageName = parts[1];
2237 displayText = `${packageName} (OCaml)`;
2238 }
2239 } else if (url.hostname.includes('medium.com')) {
2240 iconPath = 'brands-medium.svg';
2241 displayText = 'Medium';
2242 const parts = url.pathname.substring(1).split('/').filter(part => part);
2243 if (parts.length >= 1) {
2244 displayText = parts[0];
2245 }
2246 } else if (url.hostname.includes('stackoverflow.com')) {
2247 iconPath = 'brands-stack-overflow.svg';
2248 displayText = 'Stack Overflow';
2249 if (url.pathname.includes('questions')) {
2250 const parts = url.pathname.split('/');
2251 const questionId = parts.find(part => /^\d+$/.test(part));
2252 if (questionId) {
2253 displayText = `Q${questionId}`;
2254 }
2255 }
2256 } else if (url.hostname === 'dev.to') {
2257 iconPath = 'brands-dev.svg';
2258 displayText = 'DEV';
2259 const parts = url.pathname.substring(1).split('/').filter(part => part);
2260 if (parts.length >= 1) {
2261 displayText = parts[0];
2262 }
2263 } else if (url.hostname.includes('reddit.com')) {
2264 iconPath = 'brands-reddit.svg';
2265 displayText = 'Reddit';
2266 if (url.pathname.includes('/r/')) {
2267 const parts = url.pathname.split('/');
2268 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r');
2269 if (subreddit) {
2270 displayText = `r/${subreddit}`;
2271 }
2272 }
2273 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) {
2274 iconPath = 'brands-hacker-news.svg';
2275 displayText = 'Hacker News';
2276 if (url.pathname.includes('item')) {
2277 const itemId = url.searchParams.get('id');
2278 if (itemId) {
2279 displayText = `HN:${itemId}`;
2280 }
2281 }
2282 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') {
2283 iconPath = 'brands-bluesky.svg';
2284 displayText = 'Bluesky';
2285 const parts = url.pathname.substring(1).split('/').filter(part => part);
2286 if (parts.length >= 1) {
2287 if (parts[0] === 'profile' && parts.length >= 2) {
2288 displayText = `@${parts[1]}`;
2289 } else if (parts[0] === 'post') {
2290 displayText = 'Post';
2291 } else {
2292 displayText = `@${parts[0]}`;
2293 }
2294 }
2295 }
2296
2297 // Function to get day with ordinal suffix (reused)
2298 function getLinkDayWithOrdinal(date) {
2299 const day = date.getDate();
2300 let suffix = "th";
2301 if (day % 10 === 1 && day !== 11) {
2302 suffix = "st";
2303 } else if (day % 10 === 2 && day !== 12) {
2304 suffix = "nd";
2305 } else if (day % 10 === 3 && day !== 13) {
2306 suffix = "rd";
2307 }
2308 return day + suffix;
2309 }
2310
2311 // Create link item HTML
2312 linksHTML += `
2313 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}">
2314 <div class="link-item-date">${getLinkDayWithOrdinal(date)} ${shortMonthNames[date.getMonth()]} ${date.getFullYear()}</div>
2315 <div class="link-item-source" title="From: ${link.sourceTitle}">
2316 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;">
2317 ${link.source}
2318 </a>
2319 </div>
2320 <div class="link-item-content">
2321 <div class="link-item-url-container">
2322 <a href="${link.url}" class="link-item-url" target="_blank">
2323 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''}
2324 ${displayText}
2325 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span>
2326 </a>
2327 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank">
2328 <span class="link-source-icon">↗</span> ${link.sourceTitle}
2329 </a>
2330 </div>
2331 </div>
2332 </div>
2333 `;
2334 });
2335
2336 // Update the links container
2337 linksContainer.innerHTML = linksHTML;
2338
2339 // Month headers in links view are now all visible
2340
2341 // If we have links, set the most recent link's date as active in the timeline for the links tab
2342 if (dedupedLinks.length > 0) {
2343 const mostRecentLink = dedupedLinks[0];
2344 const linkDate = mostRecentLink.date;
2345 const linkYear = linkDate.getFullYear();
2346 const linkMonth = linkDate.getMonth();
2347
2348 // Add a flag to remember we've set a most recent link
2349 window.mostRecentLinkSet = {
2350 year: linkYear,
2351 month: linkMonth
2352 };
2353 }
2354
2355 // Process people data
2356 const peopleContainer = document.querySelector('.people-container');
2357 const peopleMap = new Map(); // Map to store people data
2358
2359 // Fetch the mapping.json file to get author information
2360 const mappingResponse = await fetch('mapping.json');
2361 if (!mappingResponse.ok) {
2362 throw new Error('Failed to fetch mapping data');
2363 }
2364 const mappingData = await mappingResponse.json();
2365
2366 // Process author information from mapping data
2367 Object.entries(mappingData).forEach(([feedUrl, info]) => {
2368 const { name, site } = info;
2369 if (!peopleMap.has(name)) {
2370 peopleMap.set(name, {
2371 name: name,
2372 site: site,
2373 feedUrl: feedUrl,
2374 posts: [],
2375 postCount: 0,
2376 mostRecent: null
2377 });
2378 }
2379 });
2380
2381 // Associate entries with authors
2382 entriesArray.forEach(entry => {
2383 // Find the person who matches this entry's author
2384 // (taking into account potential differences in formatting)
2385 const person = Array.from(peopleMap.values()).find(p =>
2386 p.name === entry.author ||
2387 entry.author.includes(p.name) ||
2388 p.name.includes(entry.author)
2389 );
2390
2391 if (person) {
2392 person.posts.push(entry);
2393 person.postCount++;
2394
2395 // Track most recent post date
2396 const entryDate = new Date(entry.published);
2397 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) {
2398 person.mostRecent = entry;
2399 }
2400 }
2401 });
2402
2403 // Generate HTML for people cards
2404 let peopleHTML = '';
2405 Array.from(peopleMap.values())
2406 .sort((a, b) => b.postCount - a.postCount) // Sort by post count
2407 .forEach(person => {
2408 const recentPosts = person.posts
2409 .sort((a, b) => new Date(b.published) - new Date(a.published))
2410 .slice(0, 3); // Get top 3 most recent posts
2411
2412 peopleHTML += `
2413 <div class="person-card">
2414 <div class="person-name">${person.name}</div>
2415 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div>
2416
2417 <div class="person-stats">
2418 <div class="person-stat">
2419 <div class="stat-value">${person.postCount}</div>
2420 <div class="stat-label">Posts</div>
2421 </div>
2422 <div class="person-stat">
2423 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div>
2424 <div class="stat-label">Latest</div>
2425 </div>
2426 </div>
2427
2428 ${recentPosts.length > 0 ? `
2429 <div class="person-recent">
2430 <div class="recent-title">RECENT POSTS</div>
2431 <div class="recent-posts">
2432 ${recentPosts.map(post => `
2433 <div class="recent-post">
2434 <a href="${post.link}" target="_blank">${post.title}</a>
2435 <div class="recent-post-date">${formatDate(post.published)}</div>
2436 </div>
2437 `).join('')}
2438 </div>
2439 </div>
2440 ` : ''}
2441 </div>
2442 `;
2443 });
2444
2445 peopleContainer.innerHTML = peopleHTML;
2446
2447 // Initialize tabs
2448 setupTabs();
2449
2450 // Make timeline items clickable to scroll to relevant posts or links
2451 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => {
2452 item.addEventListener('click', () => {
2453 const year = item.getAttribute('data-year');
2454 const month = item.getAttribute('data-month');
2455
2456 // Store the selected date globally
2457 lastActiveYear = year;
2458 if (month !== null && month !== undefined) {
2459 lastActiveMonth = month;
2460 }
2461
2462
2463 // Find the first element with this date
2464 let selector = `[data-year="${year}"]`;
2465 if (month !== null && month !== undefined) {
2466 selector += `[data-month="${month}"]`;
2467 }
2468
2469 // Get the active tab
2470 const activeTab = document.querySelector('.tab-content.active');
2471 const activeTabId = activeTab.getAttribute('data-tab');
2472
2473 // Look for the target within the active tab
2474 const targetItem = activeTab.querySelector(selector);
2475
2476 // If no matching items in this tab or people tab is active, do nothing
2477 if (targetItem && activeTabId !== 'people') {
2478 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' });
2479
2480 // Highlight the selected timeline period
2481 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => {
2482 el.classList.remove('active');
2483 });
2484
2485 // Set active classes
2486 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`);
2487 const monthEl = month !== null && month !== undefined ?
2488 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null;
2489
2490 if (yearEl) yearEl.classList.add('active');
2491 if (monthEl) monthEl.classList.add('active');
2492
2493 // Month headers are now simple inline elements, no need to toggle visibility
2494 }
2495 });
2496 });
2497
2498 } catch (error) {
2499 console.error('Error loading feed:', error);
2500 loadingContainer.style.display = 'none';
2501 feedItemsContainer.innerHTML = `
2502 <div class="error-message">
2503 <h3>Error Loading Feed</h3>
2504 <p>${error.message}</p>
2505 </div>
2506 `;
2507 }
2508 });
2509 </script>
2510</body>
2511</html>