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