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