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