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