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