Atom feed for our EEG site
at main 132 kB view raw
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 --ripple-color: rgba(77, 250, 123, 0.04); 25 --ripple-color-strong: rgba(77, 250, 123, 0.06); 26 --matrix-color: rgba(77, 250, 123, 0.2); 27 --matrix-glow: rgba(77, 250, 123, 0.1); 28 --hover-glow: rgba(77, 250, 123, 0.15); 29 } 30 31 * { 32 margin: 0; 33 padding: 0; 34 box-sizing: border-box; 35 } 36 37 body { 38 font-family: 'Roboto', sans-serif; 39 background-color: var(--bg-color); 40 color: var(--text-color); 41 line-height: 1.5; 42 overflow-x: hidden; 43 position: relative; 44 } 45 46 body::before { 47 content: ''; 48 position: fixed; 49 top: 0; 50 left: 0; 51 width: 100%; 52 height: 100%; 53 background: linear-gradient(rgba(10, 23, 15, 0.82), rgba(10, 23, 15, 0.92)); 54 z-index: -1; 55 pointer-events: none; 56 } 57 58 #matrix-background { 59 position: fixed; 60 top: 0; 61 left: 0; 62 width: 100%; 63 height: 100%; 64 z-index: -2; 65 opacity: 0.6; 66 pointer-events: none; 67 } 68 69 header { 70 position: fixed; 71 top: 0; 72 width: 100%; 73 height: var(--header-height); 74 background-color: var(--bg-alt-color); 75 border-bottom: 1px solid var(--border-color); 76 display: flex; 77 align-items: center; 78 padding: 0 20px; 79 z-index: 100; 80 } 81 82 .header-container { 83 display: flex; 84 justify-content: space-between; 85 align-items: center; 86 width: 100%; 87 max-width: 1200px; 88 margin: 0 auto; 89 } 90 91 .header-left { 92 display: flex; 93 align-items: baseline; 94 gap: 15px; 95 } 96 97 .tagline { 98 font-size: 0.75rem; 99 color: var(--text-muted); 100 font-family: 'JetBrains Mono', monospace; 101 white-space: nowrap; 102 } 103 104 105 106 .tabs { 107 display: flex; 108 align-items: center; 109 gap: 8px; 110 } 111 112 .tab-button { 113 font-family: 'JetBrains Mono', monospace; 114 font-size: 0.9rem; 115 background-color: transparent; 116 border: none; 117 color: var(--text-muted); 118 padding: 8px 16px; 119 cursor: pointer; 120 border-radius: 4px; 121 transition: all 0.2s ease; 122 } 123 124 .tab-button:hover { 125 color: var(--text-color); 126 background-color: rgba(77, 250, 123, 0.05); 127 } 128 129 .tab-button.active { 130 color: var(--accent-color); 131 background-color: rgba(77, 250, 123, 0.1); 132 font-weight: 600; 133 } 134 135 .tab-content { 136 display: none; 137 } 138 139 .tab-content.active { 140 display: block; 141 } 142 143 .logo { 144 font-family: 'JetBrains Mono', monospace; 145 font-weight: 600; 146 font-size: 1.3rem; 147 color: var(--accent-color); 148 text-shadow: 0 0 10px var(--accent-shadow); 149 } 150 151 .logo span { 152 color: var(--accent-alt); 153 } 154 155 .info-panel { 156 font-family: 'JetBrains Mono', monospace; 157 font-size: 0.8rem; 158 color: var(--text-muted); 159 } 160 161 main { 162 margin-top: var(--header-height); 163 min-height: calc(100vh - var(--header-height)); 164 display: flex; 165 position: relative; 166 padding: 15px 20px; 167 } 168 169 .content { 170 width: 100%; 171 max-width: 1200px; 172 margin: 0 auto; 173 padding-left: var(--sidebar-width); 174 } 175 176 .timeline-sidebar { 177 position: fixed; 178 top: var(--header-height); 179 left: 0; 180 width: var(--sidebar-width); 181 height: calc(100vh - var(--header-height)); 182 background-color: var(--bg-alt-color); 183 border-right: 1px solid var(--border-color); 184 display: flex; 185 flex-direction: column; 186 overflow-y: auto; 187 padding: 15px 0; 188 z-index: 50; 189 scrollbar-width: none; /* For Firefox */ 190 cursor: pointer; /* Show pointer cursor for the entire sidebar */ 191 } 192 193 .timeline-sidebar::-webkit-scrollbar { 194 display: none; /* For Chrome/Safari/Edge */ 195 } 196 197 .timeline-year { 198 padding: 5px 0; 199 text-align: center; 200 color: var(--text-muted); 201 font-size: 0.8rem; 202 font-family: 'JetBrains Mono', monospace; 203 position: relative; 204 transition: all 0.2s ease; 205 } 206 207 .timeline-month { 208 padding: 3px 0; 209 text-align: center; 210 color: var(--text-muted); 211 font-size: 0.7rem; 212 opacity: 0.8; 213 position: relative; 214 transition: all 0.2s ease; 215 } 216 217 .timeline-year:hover, .timeline-month:hover { 218 color: var(--accent-color); 219 transform: scale(1.05); 220 } 221 222 .timeline-year::before, 223 .timeline-month::before { 224 content: ''; 225 position: absolute; 226 right: 20px; 227 top: 50%; 228 width: 7px; 229 height: 1px; 230 background-color: var(--border-color); 231 } 232 233 .timeline-year::after { 234 content: ''; 235 position: absolute; 236 right: 15px; 237 top: 50%; 238 transform: translateY(-50%); 239 width: 4px; 240 height: 4px; 241 border-radius: 50%; 242 background-color: var(--accent-color); 243 } 244 245 .timeline-month::after { 246 content: ''; 247 position: absolute; 248 right: 16px; 249 top: 50%; 250 transform: translateY(-50%); 251 width: 2px; 252 height: 2px; 253 border-radius: 50%; 254 background-color: var(--accent-alt); 255 } 256 257 .timeline-year.active { 258 color: var(--accent-color); 259 font-weight: 600; 260 background-color: rgba(77, 250, 123, 0.1); 261 border-radius: 4px; 262 } 263 264 .timeline-month.active { 265 color: var(--accent-alt); 266 font-weight: 600; 267 background-color: rgba(77, 250, 123, 0.05); 268 border-radius: 4px; 269 } 270 271 .timeline-year.active::after { 272 width: 8px; 273 height: 8px; 274 right: 13px; 275 box-shadow: 0 0 8px var(--accent-shadow); 276 } 277 278 .timeline-month.active::after { 279 width: 4px; 280 height: 4px; 281 right: 15px; 282 } 283 284 .feed-item { 285 background-color: var(--card-bg); 286 border: 1px solid var(--border-color); 287 border-radius: 4px; 288 margin-bottom: 8px; 289 overflow: hidden; 290 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 291 transition: background-color 0.2s ease; 292 } 293 294 .feed-item:hover { 295 background-color: #1a3028; 296 } 297 298 .feed-item-row { 299 display: flex; 300 align-items: center; 301 padding: 8px 15px; 302 width: 100%; 303 overflow: hidden; 304 position: relative; 305 } 306 307 .feed-item-left { 308 display: flex; 309 align-items: center; 310 margin-right: 10px; 311 } 312 313 .feed-item-date { 314 font-family: 'JetBrains Mono', monospace; 315 font-size: 0.75rem; 316 color: var(--text-muted); 317 margin-right: 10px; 318 min-width: 120px; 319 white-space: nowrap; 320 } 321 322 /* .date-column removed as part of sidebar simplification */ 323 324 .month-year-header { 325 padding: 8px 0; 326 width: 100%; 327 margin-bottom: 5px; 328 margin-top: 10px; 329 } 330 331 .month-year-label { 332 font-weight: 600; 333 color: var(--accent-alt); 334 } 335 336 .feed-container { 337 position: relative; 338 padding-left: 15px; /* Reduced from 110px since we don't need space for date column anymore */ 339 } 340 341 .feed-item-author { 342 font-family: 'JetBrains Mono', monospace; 343 color: var(--accent-alt); 344 font-size: 0.85rem; 345 min-width: 70px; 346 margin-right: 15px; 347 white-space: nowrap; 348 } 349 350 .feed-item-title { 351 font-size: 0.95rem; 352 font-weight: 400; 353 display: inline; 354 word-break: break-word; 355 } 356 357 .feed-item-title a { 358 color: var(--text-color); 359 text-decoration: none; 360 transition: color 0.2s ease; 361 } 362 363 .feed-item-title a:hover { 364 color: var(--accent-color); 365 } 366 367 .feed-item-content-wrapper { 368 flex: 1; 369 overflow: hidden; 370 white-space: nowrap; 371 text-overflow: ellipsis; 372 padding-right: 10px; 373 } 374 375 .feed-item-preview { 376 color: var(--text-muted); 377 font-size: 0.85rem; 378 overflow: hidden; 379 text-overflow: ellipsis; 380 white-space: nowrap; 381 transition: all 0.3s ease; 382 display: inline; 383 margin-left: 8px; 384 } 385 386 .feed-item-preview a { 387 color: var(--accent-alt); 388 text-decoration: underline; 389 } 390 391 .feed-item-actions { 392 display: flex; 393 align-items: center; 394 gap: 10px; 395 margin-left: auto; 396 } 397 398 .feed-item { 399 border-left: 3px solid transparent; 400 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); 401 position: relative; 402 overflow: hidden; 403 z-index: 1; 404 } 405 406 .feed-item:hover { 407 border-left-color: var(--accent-color); 408 background-color: rgba(21, 39, 32, 0.95); 409 } 410 411 .feed-item::before { 412 content: ''; 413 position: absolute; 414 top: 0; 415 left: 0; 416 right: 0; 417 bottom: 0; 418 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%), 419 rgba(77, 250, 123, 0.06) 0%, 420 rgba(77, 250, 123, 0.04) 30%, 421 rgba(77, 250, 123, 0) 70%); 422 opacity: 0; 423 z-index: 0; 424 transform: scale(0); 425 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1); 426 pointer-events: none; 427 } 428 429 .references-container { 430 padding: 5px 15px; 431 border-top: 1px dashed var(--border-color); 432 background-color: rgba(77, 250, 123, 0.02); 433 } 434 435 .reference-item { 436 display: flex; 437 align-items: center; 438 padding: 4px 0; 439 line-height: 1.3; 440 } 441 442 .reference-indicator { 443 color: var(--accent-color); 444 margin-right: 5px; 445 font-size: 0.85rem; 446 } 447 448 449 .feed-item:hover .feed-item-content-wrapper { 450 white-space: normal; 451 } 452 453 .feed-item:hover .feed-item-preview { 454 white-space: normal; 455 line-height: 1.4; 456 max-height: none; 457 display: inline; 458 margin-left: 8px; 459 opacity: 1; 460 } 461 462 .feed-item:hover::before { 463 opacity: 0.6; 464 transform: scale(1.5); 465 } 466 467 .preview-links, 468 .preview-references { 469 font-size: 0.8rem; 470 display: none; 471 flex-wrap: wrap; 472 align-items: center; 473 gap: 8px; 474 margin-top: 3px; 475 } 476 477 /* Common styles for all platform links */ 478 .external-link-item[data-link-type] { 479 background-color: rgba(77, 180, 128, 0.08); 480 color: var(--accent-alt); 481 display: inline-flex; 482 align-items: center; 483 } 484 485 /* Platform-specific styling can be added here in the future if needed */ 486 487 .external-link-item img { 488 display: inline-block; 489 vertical-align: middle; 490 filter: invert(1); 491 } 492 493 .feed-item:hover .preview-links, 494 .feed-item:hover .preview-references { 495 display: flex; 496 } 497 498 .reference-header { 499 font-family: 'JetBrains Mono', monospace; 500 color: var(--text-muted); 501 font-size: 0.9rem; 502 margin-bottom: 5px; 503 } 504 505 .reference-link { 506 color: var(--text-color); 507 text-decoration: none; 508 transition: color 0.2s ease; 509 } 510 511 .reference-link:hover { 512 color: var(--accent-color); 513 } 514 515 .reference-author { 516 color: var(--text-muted); 517 font-size: 0.85rem; 518 margin-left: 5px; 519 } 520 521 .external-links-label { 522 color: var(--text-muted); 523 font-family: 'JetBrains Mono', monospace; 524 margin-right: 10px; 525 } 526 527 .external-link-item { 528 display: inline-block; 529 color: var(--accent-alt); 530 text-decoration: none; 531 background-color: rgba(77, 180, 128, 0.08); 532 padding: 2px 6px; 533 border-radius: 3px; 534 transition: all 0.2s ease; 535 } 536 537 .external-link-item:hover { 538 background-color: rgba(77, 180, 128, 0.15); 539 text-decoration: underline; 540 } 541 542 .external-links-toggle { 543 background: transparent; 544 border: none; 545 color: var(--text-muted); 546 font-family: 'JetBrains Mono', monospace; 547 font-size: 0.75rem; 548 padding: 2px 5px; 549 cursor: pointer; 550 display: inline-flex; 551 align-items: center; 552 border-radius: 3px; 553 margin-left: 10px; 554 } 555 556 .external-links-toggle:hover { 557 background-color: rgba(77, 180, 128, 0.05); 558 color: var(--accent-alt); 559 } 560 561 .feed-item-content { 562 padding: 15px; 563 line-height: 1.6; 564 display: none; 565 border-top: 1px solid var(--border-color); 566 background-color: #1a2e24; 567 } 568 569 .feed-item-content img { 570 max-width: 100%; 571 height: auto; 572 border-radius: 4px; 573 margin: 10px 0; 574 } 575 576 .feed-item-content pre, .feed-item-content code { 577 font-family: 'JetBrains Mono', monospace; 578 background-color: #183025; 579 border-radius: 4px; 580 padding: 0.2em 0.4em; 581 font-size: 0.9em; 582 } 583 584 .feed-item-content pre { 585 padding: 12px; 586 overflow-x: auto; 587 margin: 12px 0; 588 } 589 590 .feed-item-content blockquote { 591 border-left: 3px solid var(--accent-color); 592 padding-left: 12px; 593 margin-left: 0; 594 color: var(--text-muted); 595 } 596 597 .read-more-btn, 598 .external-links-toggle, 599 .references-toggle { 600 background-color: transparent; 601 border: none; 602 color: var(--accent-color); 603 cursor: pointer; 604 font-size: 1rem; 605 padding: 2px 8px; 606 border-radius: 3px; 607 transition: all 0.2s ease; 608 display: inline-block; 609 } 610 611 .read-more-btn:hover, 612 .external-links-toggle:hover, 613 .references-toggle:hover { 614 background-color: rgba(77, 250, 123, 0.1); 615 transform: scale(1.1); 616 } 617 618 .external-link { 619 color: var(--text-muted); 620 font-size: 1rem; 621 display: inline-block; 622 text-decoration: none; 623 padding: 2px 8px; 624 border-radius: 3px; 625 transition: all 0.2s ease; 626 } 627 628 .external-link:hover { 629 color: var(--accent-alt); 630 transform: scale(1.1); 631 } 632 633 #loading { 634 display: flex; 635 flex-direction: column; 636 align-items: center; 637 justify-content: center; 638 min-height: 200px; 639 } 640 641 .loading-spinner { 642 border: 3px solid rgba(77, 250, 123, 0.1); 643 border-top: 3px solid var(--accent-color); 644 border-radius: 50%; 645 width: 30px; 646 height: 30px; 647 animation: spin 1s linear infinite; 648 margin-bottom: 12px; 649 } 650 651 @keyframes spin { 652 0% { transform: rotate(0deg); } 653 100% { transform: rotate(360deg); } 654 } 655 656 .loading-text { 657 font-family: 'JetBrains Mono', monospace; 658 color: var(--accent-color); 659 font-size: 0.9rem; 660 } 661 662 .error-message { 663 color: #ff4d4d; 664 text-align: center; 665 padding: 20px; 666 font-family: 'JetBrains Mono', monospace; 667 } 668 669 /* Link item styling */ 670 .link-item { 671 background-color: var(--card-bg); 672 border: 1px solid var(--border-color); 673 border-radius: 4px; 674 margin-bottom: 8px; 675 overflow: hidden; 676 transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); 677 display: flex; 678 align-items: center; 679 padding: 10px 15px; 680 position: relative; 681 } 682 683 .link-item:hover { 684 background-color: rgba(21, 39, 32, 0.95); 685 border-left-color: var(--accent-color); 686 } 687 688 .link-item::before { 689 content: ''; 690 position: absolute; 691 top: 0; 692 left: 0; 693 right: 0; 694 bottom: 0; 695 background: radial-gradient(circle at var(--mouse-x, 0%) var(--mouse-y, 0%), 696 rgba(77, 250, 123, 0.06) 0%, 697 rgba(77, 250, 123, 0.04) 30%, 698 rgba(77, 250, 123, 0) 70%); 699 opacity: 0; 700 z-index: 0; 701 transform: scale(0); 702 transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.19, 1, 0.22, 1); 703 pointer-events: none; 704 } 705 706 .link-item:hover::before { 707 opacity: 0.6; 708 transform: scale(1.5); 709 } 710 711 .link-item-date { 712 font-family: 'JetBrains Mono', monospace; 713 font-size: 0.75rem; 714 color: var(--text-muted); 715 min-width: 120px; 716 margin-right: 15px; 717 white-space: nowrap; 718 } 719 720 .link-item-source { 721 font-family: 'JetBrains Mono', monospace; 722 font-size: 0.85rem; 723 color: var(--accent-alt); 724 min-width: 100px; 725 margin-right: 15px; 726 white-space: nowrap; 727 overflow: hidden; 728 text-overflow: ellipsis; 729 } 730 731 .link-item-content { 732 flex: 1; 733 } 734 735 .link-item-url-container { 736 display: flex; 737 align-items: center; 738 justify-content: space-between; 739 width: 100%; 740 } 741 742 .link-item-url { 743 color: var(--text-color); 744 text-decoration: none; 745 font-size: 0.9rem; 746 display: flex; 747 align-items: center; 748 flex: 1; 749 min-width: 0; 750 margin-right: 10px; 751 } 752 753 .link-item-url:hover { 754 color: var(--accent-color); 755 } 756 757 .link-item-path { 758 color: var(--text-muted); 759 margin-left: 8px; 760 font-size: 0.8rem; 761 white-space: nowrap; 762 overflow: hidden; 763 text-overflow: ellipsis; 764 } 765 766 .link-source-reference { 767 color: var(--text-muted); 768 text-decoration: none; 769 transition: color 0.2s ease; 770 font-size: 0.75rem; 771 white-space: nowrap; 772 max-width: 180px; 773 overflow: hidden; 774 text-overflow: ellipsis; 775 display: inline-block; 776 } 777 778 .link-source-reference:hover { 779 color: var(--accent-alt); 780 } 781 782 .link-source-icon { 783 display: inline-block; 784 font-size: 0.7rem; 785 margin-right: 2px; 786 color: var(--accent-alt); 787 } 788 789 .link-item-icon { 790 display: inline-block; 791 margin-right: 8px; 792 filter: invert(1); 793 width: 16px; 794 height: 16px; 795 vertical-align: middle; 796 } 797 798 @media (max-width: 900px) { 799 .feed-item-preview { 800 display: none; 801 } 802 803 .link-item { 804 flex-direction: column; 805 align-items: flex-start; 806 } 807 808 .link-item-date { 809 margin-bottom: 6px; 810 min-width: auto; 811 width: 100%; 812 } 813 814 .link-item-source { 815 margin-bottom: 6px; 816 } 817 818 .link-item-url-container { 819 flex-direction: column; 820 align-items: flex-start; 821 } 822 823 .link-source-reference { 824 margin-top: 4px; 825 max-width: none; 826 } 827 828 .header-container { 829 flex-direction: column; 830 align-items: flex-start; 831 padding: 10px 0; 832 } 833 834 .header-left { 835 flex-direction: column; 836 align-items: flex-start; 837 gap: 5px; 838 } 839 840 .tagline { 841 white-space: normal; 842 font-size: 0.7rem; 843 } 844 845 header { 846 height: auto; 847 } 848 849 main { 850 margin-top: 140px; 851 } 852 853 .timeline-sidebar { 854 top: 140px; 855 height: calc(100vh - 140px); 856 width: 60px; /* Make the sidebar a bit narrower on mobile */ 857 } 858 859 .tabs { 860 margin: 10px 0; 861 } 862 } 863 864 /* People tab styling */ 865 .people-header { 866 font-family: 'JetBrains Mono', monospace; 867 color: var(--accent-color); 868 margin-bottom: 20px; 869 font-size: 1.4rem; 870 font-weight: 600; 871 } 872 873 .people-container { 874 display: grid; 875 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 876 gap: 20px; 877 } 878 879 .person-card { 880 background-color: var(--card-bg); 881 border: 1px solid var(--border-color); 882 border-radius: 4px; 883 overflow: hidden; 884 transition: all 0.2s ease; 885 padding: 15px; 886 } 887 888 .person-card:hover { 889 border-left-color: var(--accent-color); 890 background-color: rgba(77, 250, 123, 0.03); 891 } 892 893 .person-name { 894 font-family: 'JetBrains Mono', monospace; 895 color: var(--accent-alt); 896 font-size: 1.1rem; 897 margin-bottom: 8px; 898 font-weight: 600; 899 } 900 901 .person-site { 902 font-size: 0.9rem; 903 color: var(--text-muted); 904 margin-bottom: 12px; 905 } 906 907 .person-site a { 908 color: var(--text-muted); 909 text-decoration: none; 910 transition: color 0.2s ease; 911 } 912 913 .person-site a:hover { 914 color: var(--accent-color); 915 } 916 917 .person-stats { 918 display: flex; 919 gap: 15px; 920 margin-bottom: 15px; 921 font-family: 'JetBrains Mono', monospace; 922 font-size: 0.85rem; 923 } 924 925 .person-stat { 926 display: flex; 927 flex-direction: column; 928 align-items: center; 929 } 930 931 .stat-value { 932 color: var(--accent-color); 933 font-size: 1.1rem; 934 font-weight: 600; 935 } 936 937 .stat-label { 938 color: var(--text-muted); 939 font-size: 0.75rem; 940 } 941 942 .person-recent { 943 margin-top: 12px; 944 } 945 946 .recent-title { 947 font-family: 'JetBrains Mono', monospace; 948 color: var(--text-muted); 949 font-size: 0.85rem; 950 margin-bottom: 8px; 951 } 952 953 .recent-posts { 954 display: flex; 955 flex-direction: column; 956 gap: 8px; 957 } 958 959 .recent-post { 960 padding: 8px; 961 background-color: rgba(77, 250, 123, 0.03); 962 border-radius: 3px; 963 font-size: 0.9rem; 964 } 965 966 .recent-post a { 967 color: var(--text-color); 968 text-decoration: none; 969 transition: color 0.2s ease; 970 } 971 972 .recent-post a:hover { 973 color: var(--accent-color); 974 } 975 976 .recent-post-date { 977 font-family: 'JetBrains Mono', monospace; 978 color: var(--text-muted); 979 font-size: 0.75rem; 980 margin-top: 3px; 981 } 982 983 /* Styles for filter container */ 984 .filter-container { 985 display: flex; 986 align-items: center; 987 margin-bottom: 10px; 988 padding: 5px 10px; 989 background-color: var(--card-bg); 990 border: 1px solid var(--border-color); 991 border-radius: 4px; 992 align-self: flex-end; 993 width: auto; 994 margin-left: auto; 995 } 996 997 .filter-title { 998 font-family: 'JetBrains Mono', monospace; 999 color: var(--accent-color); 1000 font-size: 0.8rem; 1001 margin-right: 10px; 1002 } 1003 1004 .filter-options { 1005 display: flex; 1006 gap: 10px; 1007 } 1008 1009 .filter-option { 1010 position: relative; 1011 display: flex; 1012 align-items: center; 1013 cursor: pointer; 1014 user-select: none; 1015 } 1016 1017 .filter-checkbox { 1018 position: absolute; 1019 opacity: 0; 1020 height: 0; 1021 width: 0; 1022 } 1023 1024 .checkbox-custom { 1025 position: relative; 1026 display: inline-block; 1027 width: 14px; 1028 height: 14px; 1029 background-color: rgba(77, 250, 123, 0.05); 1030 border: 1px solid var(--accent-alt); 1031 border-radius: 3px; 1032 margin-right: 6px; 1033 transition: all 0.2s ease; 1034 } 1035 1036 .filter-checkbox:checked + .checkbox-custom::after { 1037 content: ''; 1038 position: absolute; 1039 top: 1px; 1040 left: 4px; 1041 width: 4px; 1042 height: 7px; 1043 border: solid var(--accent-color); 1044 border-width: 0 2px 2px 0; 1045 transform: rotate(45deg); 1046 } 1047 1048 .filter-checkbox:checked + .checkbox-custom { 1049 background-color: rgba(77, 250, 123, 0.15); 1050 border-color: var(--accent-color); 1051 } 1052 1053 .filter-label { 1054 font-size: 0.8rem; 1055 color: var(--text-color); 1056 } 1057 1058 .filter-option:hover .checkbox-custom { 1059 background-color: rgba(77, 250, 123, 0.1); 1060 border-color: var(--accent-color); 1061 } 1062 1063 .links-header { 1064 display: flex; 1065 justify-content: flex-end; 1066 margin-bottom: 10px; 1067 width: 100%; 1068 } 1069 1070 @media (max-width: 600px) { 1071 .feed-item-author { 1072 min-width: 50px; 1073 margin-right: 10px; 1074 } 1075 1076 .feed-item-date { 1077 min-width: auto; 1078 width: 100%; 1079 margin-bottom: 5px; 1080 } 1081 1082 .feed-item-row { 1083 flex-direction: column; 1084 align-items: flex-start; 1085 } 1086 1087 .tabs { 1088 gap: 2px; 1089 width: 100%; 1090 justify-content: space-between; 1091 } 1092 1093 .tab-button { 1094 padding: 6px 8px; 1095 font-size: 0.75rem; 1096 flex-grow: 1; 1097 text-align: center; 1098 } 1099 1100 .people-container { 1101 grid-template-columns: 1fr; 1102 } 1103 1104 1105 main { 1106 margin-top: 150px; 1107 } 1108 1109 .timeline-sidebar { 1110 top: 150px; 1111 height: calc(100vh - 150px); 1112 width: 50px; /* Even narrower on very small screens */ 1113 } 1114 1115 .content { 1116 padding-left: 50px; /* Match the sidebar width on small screens */ 1117 } 1118 1119 /* Hide green dot indicators on mobile to prevent text overlap */ 1120 .timeline-year::after, 1121 .timeline-month::after { 1122 display: none; 1123 } 1124 1125 /* Also hide the connecting line on mobile */ 1126 .timeline-year::before, 1127 .timeline-month::before { 1128 display: none; 1129 } 1130 } 1131 </style> 1132</head> 1133<body> 1134 <canvas id="matrix-background"></canvas> 1135 <header> 1136 <div class="header-container"> 1137 <div class="header-left"> 1138 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;"> 1139 <div class="logo">Atomic<span>EEG</span></div> 1140 </a> 1141 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div> 1142 </div> 1143 <div class="tabs"> 1144 <button class="tab-button active" data-tab="posts">Posts</button> 1145 <button class="tab-button" data-tab="links">Links</button> 1146 <button class="tab-button" data-tab="people">Vibes</button> 1147 </div> 1148 </div> 1149 </header> 1150 1151 <main> 1152 <section class="content"> 1153 <div id="loading"> 1154 <div class="loading-spinner"></div> 1155 <p class="loading-text">Growing Content...</p> 1156 </div> 1157 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div> 1158 <div class="tab-content" data-tab="links"> 1159 <div class="links-header"> 1160 <div class="filter-container"> 1161 <div class="filter-title">Filter:</div> 1162 <div class="filter-options"> 1163 <label class="filter-option"> 1164 <input type="checkbox" id="filter-papers" class="filter-checkbox" data-filter="academic"> 1165 <span class="checkbox-custom"></span> 1166 <span class="filter-label">Papers</span> 1167 </label> 1168 <label class="filter-option"> 1169 <input type="checkbox" id="filter-videos" class="filter-checkbox" data-filter="youtube"> 1170 <span class="checkbox-custom"></span> 1171 <span class="filter-label">Videos</span> 1172 </label> 1173 </div> 1174 </div> 1175 </div> 1176 <div id="link-items" class="feed-container"></div> 1177 </div> 1178 <div id="people-items" class="tab-content" data-tab="people"> 1179 <h2 class="people-header">EEG Sources</h2> 1180 <div class="people-container"></div> 1181 </div> 1182 </section> 1183 <aside class="timeline-sidebar" id="timeline-sidebar"> 1184 <!-- Timeline will be populated via JavaScript --> 1185 </aside> 1186 </main> 1187 1188 <script> 1189 document.addEventListener('DOMContentLoaded', async () => { 1190 // Matrix background effect 1191 const canvas = document.getElementById('matrix-background'); 1192 const ctx = canvas.getContext('2d'); 1193 1194 // Set canvas size to match window 1195 function resizeCanvas() { 1196 canvas.width = window.innerWidth; 1197 canvas.height = window.innerHeight; 1198 } 1199 resizeCanvas(); 1200 window.addEventListener('resize', resizeCanvas); 1201 1202 // Vine/plant-related characters and elements 1203 const vineChars = '┃┃│┋┇┊┆╽╿┴┬╵╷└┕┖┗┘┙┚┛╘╙╚╛╯╰╱╲⌠⌡╎▏▕⏐▌▐░▒▓◥◤◢◣⎸⎹│'; 1204 const leafChars = '☘❀✿❁❃❇❈❉❊❋✣✤✥✦✧✩✪✫✬✭✮✾✿❀❁❂❃❄⚘♠♣⚜⚘☘'; 1205 const branchChars = '┌┐┘└├┬┴┤┼─┄┈┉┊┋╱╲╳☂⚢⌒~∞≈≋⋆✧✦✫'; 1206 const fontSize = 14; 1207 const columns = Math.floor(canvas.width / fontSize * 0.7); // Fewer columns for sparser vines 1208 1209 // Drop positions for each column 1210 const drops = []; 1211 1212 // Initialize drops at random positions 1213 for (let i = 0; i < columns; i++) { 1214 // Random starting position 1215 drops[i] = Math.random() * -canvas.height; 1216 } 1217 1218 // Set up column types - some will be vines, some will have leaves 1219 const columnTypes = []; 1220 for (let i = 0; i < columns; i++) { 1221 // 70% of columns are vines, 25% are leaves, 5% are cross-connections 1222 const rand = Math.random(); 1223 if (rand < 0.7) { 1224 columnTypes[i] = 'vine'; 1225 } else if (rand < 0.95) { 1226 columnTypes[i] = 'leaf'; 1227 } else { 1228 columnTypes[i] = 'branch'; 1229 } 1230 } 1231 1232 // Store connections between vines 1233 const connections = []; 1234 1235 // Helper function to find nearby columns 1236 function findNearbyColumns(columnIndex, maxDistance = 3) { 1237 const nearby = []; 1238 for (let i = 0; i < columns; i++) { 1239 if (i !== columnIndex && Math.abs(i - columnIndex) <= maxDistance) { 1240 nearby.push(i); 1241 } 1242 } 1243 return nearby; 1244 } 1245 1246 // Last time random chars were changed 1247 const lastCharChangeTime = []; 1248 // The current characters displayed 1249 const currentChars = []; 1250 // Width/thickness of vines 1251 const vineThickness = []; 1252 1253 for (let i = 0; i < columns; i++) { 1254 lastCharChangeTime[i] = []; 1255 currentChars[i] = []; 1256 1257 // Random vine thickness between 1-3 1258 vineThickness[i] = Math.floor(Math.random() * 3) + 1; 1259 1260 for (let j = 0; j < canvas.height / fontSize; j++) { 1261 lastCharChangeTime[i][j] = 0; 1262 1263 if (columnTypes[i] === 'vine') { 1264 // Choose vine characters based on position and thickness 1265 if (j === 0) { 1266 // Top of vine - might be a leaf or flower 1267 currentChars[i][j] = Math.random() < 0.6 ? 1268 leafChars.charAt(Math.floor(Math.random() * leafChars.length)) : 1269 vineChars.charAt(Math.floor(Math.random() * vineChars.length)); 1270 } else { 1271 // Main vine character 1272 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1); 1273 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex)); 1274 } 1275 } else if (columnTypes[i] === 'leaf') { 1276 // Leaf character - only at top or occasional spots along the vine 1277 if (j === 0 || Math.random() < 0.2) { 1278 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1279 } else { 1280 // Connecting vine 1281 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * 5)); // Thin vine characters 1282 } 1283 } else if (columnTypes[i] === 'branch') { 1284 // This is a branching column - will form connections between vines 1285 if (j === 0) { 1286 // Top of branch might be a leaf or flower 1287 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1288 } else { 1289 // Branch characters - horizontal or diagonal connectors 1290 currentChars[i][j] = branchChars.charAt(Math.floor(Math.random() * branchChars.length)); 1291 } 1292 } 1293 } 1294 } 1295 1296 // Time when animation started 1297 const startTime = Date.now(); 1298 1299 // Track connections between vines 1300 const crossConnections = []; 1301 1302 // Draw the rainforest vine effect 1303 function drawVineEffect() { 1304 // Semi-transparent background to create fade effect 1305 ctx.fillStyle = 'rgba(10, 23, 15, 0.05)'; 1306 ctx.fillRect(0, 0, canvas.width, canvas.height); 1307 1308 const now = Date.now(); 1309 1310 // Set font 1311 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; 1312 ctx.textAlign = 'center'; 1313 1314 // First, create cross-connections 1315 // Create new cross-connections occasionally 1316 if (Math.random() < 0.01) { 1317 // Find a source vine that's grown enough 1318 const sourceIndex = Math.floor(Math.random() * columns); 1319 if (drops[sourceIndex] > 100 && columnTypes[sourceIndex] === 'vine') { 1320 // Find a nearby column to connect to 1321 const nearby = findNearbyColumns(sourceIndex, 3); 1322 if (nearby.length > 0) { 1323 const targetIndex = nearby[Math.floor(Math.random() * nearby.length)]; 1324 if (drops[targetIndex] > 80) { 1325 // The height should be somewhere between the two vines 1326 const sourceHeight = drops[sourceIndex]; 1327 const targetHeight = drops[targetIndex]; 1328 const connectionHeight = Math.min(sourceHeight, targetHeight) * 0.8; 1329 1330 // Create the connection 1331 crossConnections.push({ 1332 source: sourceIndex, 1333 target: targetIndex, 1334 height: connectionHeight, 1335 character: branchChars.charAt(Math.floor(Math.random() * branchChars.length)), 1336 created: now 1337 }); 1338 } 1339 } 1340 } 1341 } 1342 1343 // For each column 1344 for (let i = 0; i < columns; i++) { 1345 // Calculate current position of this vine 1346 const x = i * fontSize * 1.5; // Space vines further apart 1347 1348 // For each character in this column 1349 for (let j = 0; j < Math.ceil(drops[i] / fontSize); j++) { 1350 const y = j * fontSize; 1351 1352 // Skip rendering some characters to create gaps in vines 1353 if (Math.random() < 0.05 && j > 3) continue; 1354 1355 // Calculate age of this character 1356 const charAge = now - lastCharChangeTime[i][j]; 1357 1358 // Randomly change some characters over time - slower rate for natural movement 1359 if (j === 0 && (Math.random() < 0.005 || charAge > 8000)) { 1360 // Top character might change between leaves/flowers 1361 if (columnTypes[i] === 'leaf' || Math.random() < 0.6) { 1362 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1363 } else { 1364 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineChars.length)); 1365 } 1366 lastCharChangeTime[i][j] = now; 1367 } else if (j > 0 && Math.random() < 0.001) { 1368 // Occasionally grow new leaves along the vine 1369 if (Math.random() < 0.2) { 1370 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1371 } else { 1372 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1); 1373 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex)); 1374 } 1375 lastCharChangeTime[i][j] = now; 1376 } 1377 1378 // Calculate distance from head of the vine 1379 const distanceFromHead = (drops[i] - y); 1380 1381 // Determine color based on position and type 1382 if (j === 0 && (currentChars[i][j] === '❀' || currentChars[i][j] === '✿' || 1383 currentChars[i][j] === '❁' || currentChars[i][j] === '✾')) { 1384 // Flowers are more colorful - pinkish 1385 ctx.fillStyle = 'rgba(255, 180, 220, 0.9)'; 1386 ctx.shadowColor = 'rgba(255, 150, 200, 0.6)'; 1387 ctx.shadowBlur = 5; 1388 } else if (currentChars[i][j] === '☘' || leafChars.includes(currentChars[i][j])) { 1389 // Leaf characters are brightest with different green 1390 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)'; 1391 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)'; 1392 ctx.shadowBlur = 3; 1393 } else if (distanceFromHead < fontSize) { 1394 // Growing tip of vine is brightest 1395 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)'; 1396 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)'; 1397 ctx.shadowBlur = 5; 1398 } else if (distanceFromHead < fontSize * 8) { 1399 // Newer part of vine is brighter 1400 const opacity = 0.8 - (distanceFromHead / (fontSize * 10)); 1401 ctx.fillStyle = `rgba(77, 180, 100, ${opacity.toFixed(2)})`; 1402 ctx.shadowColor = 'transparent'; 1403 ctx.shadowBlur = 0; 1404 } else { 1405 // Older parts of vine are darker 1406 const opacity = Math.max(0, 0.4 - (distanceFromHead / (canvas.height * 2))); 1407 // Darker green for older vines 1408 ctx.fillStyle = `rgba(40, 120, 60, ${opacity.toFixed(2)})`; 1409 ctx.shadowColor = 'transparent'; 1410 ctx.shadowBlur = 0; 1411 } 1412 1413 // Add slight random swaying to vines 1414 const swayAmount = Math.sin((now / 2000) + i) * 2; // Gentle swaying effect 1415 const adjustedX = x + swayAmount; 1416 1417 // Draw the character 1418 if (y < canvas.height) { 1419 // Adjust size for special characters 1420 if (leafChars.includes(currentChars[i][j])) { 1421 ctx.font = `${fontSize * 1.2}px 'JetBrains Mono', monospace`; 1422 ctx.fillText(currentChars[i][j], adjustedX, y); 1423 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Reset font 1424 } else { 1425 ctx.fillText(currentChars[i][j], adjustedX, y); 1426 } 1427 } 1428 } 1429 1430 // Move the vine down - slower for natural growth 1431 drops[i] += fontSize * (0.02 + Math.random() * 0.03); 1432 1433 // Reset vine when it reaches bottom or randomly (much less frequent) 1434 if (drops[i] > canvas.height * 2 || (Math.random() < 0.0005 && drops[i] > canvas.height * 0.6)) { 1435 drops[i] = Math.random() * -30; 1436 // Maybe change vine type 1437 if (Math.random() < 0.3) { 1438 columnTypes[i] = Math.random() < 0.7 ? 'vine' : 'leaf'; 1439 vineThickness[i] = Math.floor(Math.random() * 3) + 1; 1440 } 1441 } 1442 } 1443 1444 // Draw cross connections between vines 1445 crossConnections.forEach((connection, index) => { 1446 const sourceX = connection.source * fontSize * 1.5; 1447 const targetX = connection.target * fontSize * 1.5; 1448 const y = connection.height; 1449 const heightIndex = Math.floor(y / fontSize); 1450 1451 // Calculate a safe display Y - make sure it's within the grown vines 1452 const safeY = Math.min( 1453 Math.min(drops[connection.source], drops[connection.target]), 1454 connection.height 1455 ); 1456 1457 // Convert to display coords 1458 const displayY = Math.floor(safeY / fontSize) * fontSize; 1459 1460 // Only draw if connection is within visible area 1461 if (displayY < 0 || displayY > canvas.height) return; 1462 1463 // Connection age effect 1464 const age = now - connection.created; 1465 const maxAge = 20000; // 20 seconds lifetime for connections 1466 1467 // Remove old connections 1468 if (age > maxAge) { 1469 crossConnections.splice(index, 1); 1470 return; 1471 } 1472 1473 // Fade in/out effect 1474 let opacity = 1.0; 1475 if (age < 1000) { 1476 // Fade in 1477 opacity = age / 1000; 1478 } else if (age > maxAge - 2000) { 1479 // Fade out 1480 opacity = (maxAge - age) / 2000; 1481 } 1482 1483 // Draw connection 1484 const connectionWidth = Math.abs(targetX - sourceX); 1485 const steps = Math.ceil(connectionWidth / (fontSize * 0.8)); 1486 1487 // Lighter green for branches 1488 ctx.fillStyle = `rgba(120, 255, 150, ${opacity.toFixed(2)})`; 1489 ctx.shadowColor = 'rgba(77, 250, 123, 0.4)'; 1490 ctx.shadowBlur = 2; 1491 1492 // Draw branch character at each step 1493 let branchChar; 1494 1495 if (sourceX < targetX) { 1496 // Left to right 1497 branchChar = '─'; 1498 } else { 1499 // Right to left 1500 branchChar = '─'; 1501 } 1502 1503 for (let s = 0; s <= steps; s++) { 1504 // Calculate position 1505 const progress = s / steps; 1506 const stepX = sourceX + (targetX - sourceX) * progress; 1507 const wiggle = Math.sin(progress * Math.PI) * 5; 1508 1509 // Choose appropriate connection character 1510 let connChar = branchChar; 1511 1512 // Special characters for start, middle and end 1513 if (s === 0) { 1514 connChar = '├'; 1515 } else if (s === steps) { 1516 connChar = '┤'; 1517 } else if (s === Math.floor(steps/2)) { 1518 // Add a leaf or flower in the middle sometimes 1519 if (Math.random() < 0.3) { 1520 connChar = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1521 } else { 1522 connChar = s % 2 === 0 ? '┼' : '┴'; 1523 } 1524 } else { 1525 // Occasional decorative elements 1526 if (Math.random() < 0.1) { 1527 connChar = '·'; 1528 } 1529 } 1530 1531 ctx.fillText(connChar, stepX, displayY + wiggle); 1532 } 1533 }); 1534 1535 // Schedule next frame 1536 requestAnimationFrame(drawVineEffect); 1537 } 1538 1539 // Start the animation 1540 drawVineEffect(); 1541 // Add hover event listeners after DOM content is loaded 1542 function setupHoverEffects() { 1543 // Keep track of the currently active item 1544 let currentHoveredItem = null; 1545 1546 document.querySelectorAll('.feed-item').forEach(item => { 1547 item.addEventListener('mouseenter', () => { 1548 // Set this as current hovered item 1549 currentHoveredItem = item; 1550 }); 1551 1552 // Track mouse position for the ripple effect 1553 item.addEventListener('mousemove', (e) => { 1554 // Get position relative to the element 1555 const rect = item.getBoundingClientRect(); 1556 const x = ((e.clientX - rect.left) / rect.width) * 100; 1557 const y = ((e.clientY - rect.top) / rect.height) * 100; 1558 1559 // Set custom properties for the radial gradient 1560 item.style.setProperty('--mouse-x', `${x}%`); 1561 item.style.setProperty('--mouse-y', `${y}%`); 1562 }); 1563 }); 1564 } 1565 1566 // Tab switching functionality 1567 // Create global variables to store state 1568 let globalFeedObserver = null; 1569 let lastActiveYear = null; 1570 let lastActiveMonth = null; 1571 1572 function setupObserver(options) { 1573 // Create a new intersection observer for handling timeline scrolling 1574 return new IntersectionObserver((entries) => { 1575 entries.forEach(entry => { 1576 if (entry.isIntersecting) { 1577 const year = entry.target.getAttribute('data-year'); 1578 const month = entry.target.getAttribute('data-month'); 1579 1580 // Get the active tab 1581 const activeTab = document.querySelector('.tab-content.active'); 1582 const activeTabId = activeTab.getAttribute('data-tab'); 1583 1584 // Only process if we're on posts or links tab 1585 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) { 1586 // Clear all active classes 1587 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 1588 el.classList.remove('active'); 1589 }); 1590 1591 // Set active classes 1592 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 1593 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`); 1594 1595 if (yearEl) { 1596 yearEl.classList.add('active'); 1597 // Store the last active year globally 1598 lastActiveYear = year; 1599 } 1600 if (monthEl) { 1601 monthEl.classList.add('active'); 1602 // Store the last active month globally 1603 lastActiveMonth = month; 1604 } 1605 1606 // Month headers are now simple inline elements, no need to toggle visibility 1607 } 1608 } 1609 }); 1610 }, options); 1611 } 1612 1613 function setupTabs() { 1614 const tabButtons = document.querySelectorAll('.tab-button'); 1615 const tabContents = document.querySelectorAll('.tab-content'); 1616 const timeline = document.getElementById('timeline-sidebar'); 1617 1618 tabButtons.forEach(button => { 1619 button.addEventListener('click', () => { 1620 const tabName = button.getAttribute('data-tab'); 1621 1622 // Deactivate all tabs 1623 tabButtons.forEach(btn => btn.classList.remove('active')); 1624 tabContents.forEach(content => content.classList.remove('active')); 1625 1626 // Activate selected tab 1627 button.classList.add('active'); 1628 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`); 1629 tabContent.classList.add('active'); 1630 1631 // Month headers are now simple inline elements, no need to toggle visibility 1632 1633 // Show or hide timeline sidebar based on active tab 1634 if (tabName === 'people') { 1635 timeline.style.display = 'none'; 1636 document.querySelector('.content').style.paddingLeft = '0'; 1637 } else { 1638 timeline.style.display = 'flex'; 1639 document.querySelector('.content').style.paddingLeft = 'var(--sidebar-width)'; 1640 1641 // Reset timeline highlighting 1642 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 1643 el.classList.remove('active'); 1644 }); 1645 1646 // Disconnect and recreate the observer to ensure proper tracking 1647 if (globalFeedObserver) { 1648 globalFeedObserver.disconnect(); 1649 } 1650 1651 // Setup a new observer 1652 globalFeedObserver = setupObserver({ 1653 root: null, 1654 rootMargin: '-80px 0px', 1655 threshold: 0.1 1656 }); 1657 1658 // Observe all items in the active tab 1659 observeAllDateItems(); 1660 1661 // Always scroll to top when switching tabs 1662 window.scrollTo({ top: 0, behavior: 'smooth' }); 1663 } 1664 }); 1665 }); 1666 } 1667 const feedItemsContainer = document.getElementById('feed-items'); 1668 const loadingContainer = document.getElementById('loading'); 1669 1670 // Function to format date (only date, no time) 1671 function formatDate(dateString) { 1672 const date = new Date(dateString); 1673 return date.toLocaleDateString('en-US', { 1674 year: 'numeric', 1675 month: 'short', 1676 day: 'numeric' 1677 }); 1678 } 1679 1680 // We no longer need preview processing functions 1681 // since we're displaying content as-is with HTML tags 1682 1683 // Function removed - we no longer toggle full content 1684 1685 // Removed the external links toggle function as it's no longer needed 1686 1687 // Reference toggle function removed - references are now shown with CSS on hover 1688 1689 try { 1690 // Fetch the Atom feed and threads data in parallel 1691 const [feedResponse, threadsResponse] = await Promise.all([ 1692 fetch('eeg.xml'), 1693 fetch('threads.json') 1694 ]); 1695 1696 if (!feedResponse.ok) { 1697 throw new Error('Failed to fetch feed'); 1698 } 1699 1700 if (!threadsResponse.ok) { 1701 throw new Error('Failed to fetch threads data'); 1702 } 1703 1704 const xmlText = await feedResponse.text(); 1705 const threadsData = await threadsResponse.json(); 1706 1707 const parser = new DOMParser(); 1708 const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); 1709 1710 // Process feed entries 1711 const entries = xmlDoc.getElementsByTagName('entry'); 1712 const sources = new Set(); 1713 1714 // No longer updating the entry count element since it's been removed 1715 1716 // Map to store entries by ID for easy lookup 1717 const entriesById = {}; 1718 1719 // First pass: extract all entries and build the ID map 1720 for (let i = 0; i < entries.length; i++) { 1721 const entry = entries[i]; 1722 1723 // Extract entry data 1724 const id = entry.getElementsByTagName('id')[0]?.textContent || ''; 1725 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title'; 1726 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#'; 1727 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0]; 1728 const contentText = contentElement?.textContent || ''; 1729 const contentType = contentElement?.getAttribute('type') || 'text'; 1730 const published = entry.getElementsByTagName('published')[0]?.textContent || 1731 entry.getElementsByTagName('updated')[0]?.textContent || ''; 1732 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown'; 1733 const categories = entry.getElementsByTagName('category'); 1734 1735 // Extract source from category (we're using category to store source name) 1736 let source = 'Unknown Source'; 1737 if (categories.length > 0) { 1738 source = categories[0].getAttribute('term'); 1739 sources.add(source); 1740 } 1741 1742 // Properly handle the content based on content type 1743 let contentHtml; 1744 if (contentType === 'html' || contentType === 'text/html') { 1745 // For HTML content, create a div and set innerHTML 1746 contentHtml = contentText; 1747 } else { 1748 // For text content, escape it and preserve newlines 1749 contentHtml = contentText 1750 .replace(/&/g, '&amp;') 1751 .replace(/</g, '&lt;') 1752 .replace(/>/g, '&gt;') 1753 .replace(/\n/g, '<br>'); 1754 } 1755 1756 // Store the entry data 1757 entriesById[id] = { 1758 id, 1759 articleId: `article-${i}`, 1760 title, 1761 link, 1762 contentHtml, // Use the content as-is with HTML tags 1763 published, 1764 author, 1765 source, 1766 threadGroup: null, 1767 isThreadParent: false, 1768 threadParentId: null, 1769 inThread: false, 1770 threadPosition: 0, 1771 externalLinks: [], 1772 }; 1773 } 1774 1775 // Process reference relationships and external links 1776 for (const entryId in entriesById) { 1777 if (threadsData[entryId]) { 1778 const threadInfo = threadsData[entryId]; 1779 const entry = entriesById[entryId]; 1780 1781 // Track external links for this entry 1782 entry.externalLinks = []; 1783 if (threadInfo.external_links && threadInfo.external_links.length > 0) { 1784 entry.externalLinks = threadInfo.external_links.map(link => ({ 1785 url: link.url, 1786 normalized_url: link.normalized_url 1787 })); 1788 } 1789 1790 // Track references to other posts (outgoing links) 1791 entry.referencesTo = []; 1792 if (threadInfo.references && threadInfo.references.length > 0) { 1793 // Filter for only in-feed references 1794 threadInfo.references.forEach(ref => { 1795 if (ref.in_feed === true && entriesById[ref.id]) { 1796 entry.referencesTo.push({ 1797 id: ref.id, 1798 title: ref.title, 1799 link: ref.link, 1800 author: entriesById[ref.id].author 1801 }); 1802 } 1803 }); 1804 } 1805 1806 // Track posts that reference this one (incoming links) 1807 entry.referencedBy = []; 1808 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) { 1809 // Filter for only in-feed references 1810 threadInfo.referenced_by.forEach(ref => { 1811 if (ref.in_feed === true && entriesById[ref.id]) { 1812 entry.referencedBy.push({ 1813 id: ref.id, 1814 title: ref.title, 1815 link: ref.link, 1816 author: entriesById[ref.id].author 1817 }); 1818 } 1819 }); 1820 } 1821 } 1822 } 1823 1824 // Sort by date and create HTML 1825 const entriesArray = Object.values(entriesById); 1826 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published)); 1827 1828 // Create a timeline structure by year/month 1829 const timeline = new Map(); 1830 const monthNames = [ 1831 'January', 'February', 'March', 'April', 'May', 'June', 1832 'July', 'August', 'September', 'October', 'November', 'December' 1833 ]; 1834 const shortMonthNames = [ 1835 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1836 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' 1837 ]; 1838 1839 // Group entries by year and month for the timeline 1840 entriesArray.forEach(entry => { 1841 const date = new Date(entry.published); 1842 const year = date.getFullYear(); 1843 const month = date.getMonth(); 1844 1845 if (!timeline.has(year)) { 1846 timeline.set(year, new Map()); 1847 } 1848 1849 const yearMap = timeline.get(year); 1850 if (!yearMap.has(month)) { 1851 yearMap.set(month, []); 1852 } 1853 1854 yearMap.get(month).push(entry); 1855 }); 1856 1857 // Process all entries in strict date order 1858 let entriesHTML = ''; 1859 const processedArticleIds = new Set(); 1860 1861 // Create a copy of entriesArray to process strictly by date 1862 const entriesByDate = [...entriesArray]; 1863 1864 // Track current month/year for date headers 1865 let currentYear = null; 1866 let currentMonth = null; 1867 1868 // Process each entry in date order 1869 for (const entry of entriesByDate) { 1870 // Skip entries already processed 1871 if (processedArticleIds.has(entry.articleId)) continue; 1872 1873 const date = new Date(entry.published); 1874 const year = date.getFullYear(); 1875 const month = date.getMonth(); 1876 const dateAttr = `data-year="${year}" data-month="${month}"`; 1877 1878 // Check if we need to add a new month/year header 1879 if (currentYear !== year || currentMonth !== month) { 1880 currentYear = year; 1881 currentMonth = month; 1882 1883 entriesHTML += ` 1884 <div class="month-year-header" ${dateAttr}> 1885 <div class="month-year-label">${monthNames[month]} ${year}</div> 1886 </div>`; 1887 } 1888 1889 // Function to get day with ordinal suffix 1890 function getDayWithOrdinal(date) { 1891 const day = date.getDate(); 1892 let suffix = "th"; 1893 if (day % 10 === 1 && day !== 11) { 1894 suffix = "st"; 1895 } else if (day % 10 === 2 && day !== 12) { 1896 suffix = "nd"; 1897 } else if (day % 10 === 3 && day !== 13) { 1898 suffix = "rd"; 1899 } 1900 return day + suffix; 1901 } 1902 1903 // Add entry 1904 entriesHTML += ` 1905 <article id="${entry.articleId}" class="feed-item" ${dateAttr}> 1906 <div class="feed-item-row"> 1907 <div class="feed-item-date">${getDayWithOrdinal(date)} ${shortMonthNames[month]} ${year}</div> 1908 <div class="feed-item-author">${entry.author}</div> 1909 <div class="feed-item-content-wrapper"> 1910 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div> 1911 1912 ${entry.externalLinks && entry.externalLinks.length > 0 ? ` 1913 <div class="preview-links"> 1914 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => { 1915 // Find the first link object with this URL 1916 const link = entry.externalLinks.find(l => l.url === uniqueUrl); 1917 const url = new URL(link.url); 1918 let displayText = url.hostname.replace('www.', ''); 1919 1920 // Special handling for GitHub links 1921 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') { 1922 // Extract the parts from pathname (remove leading slash) 1923 const parts = url.pathname.substring(1).split('/').filter(part => part); 1924 if (parts.length >= 2) { 1925 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`; 1926 } 1927 } 1928 1929 // Special handling for Wikipedia links 1930 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) { 1931 const titlePart = url.pathname.split('/').pop(); 1932 if (titlePart) { 1933 const title = decodeURIComponent(titlePart).replace(/_/g, ' '); 1934 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`; 1935 } 1936 } 1937 1938 // Special handling for Twitter/X links 1939 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') { 1940 const parts = url.pathname.substring(1).split('/').filter(part => part); 1941 if (parts.length >= 1) { 1942 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`; 1943 } 1944 } 1945 1946 // Special handling for LinkedIn links 1947 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) { 1948 const parts = url.pathname.substring(1).split('/').filter(part => part); 1949 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`; 1950 if (parts.length >= 2 && parts[0] === 'in') { 1951 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`; 1952 } 1953 } 1954 1955 // Special handling for YouTube links 1956 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') { 1957 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`; 1958 // Try to get video title from URL parameters 1959 const videoId = url.searchParams.get('v'); 1960 if (url.pathname.includes('watch') && videoId) { 1961 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`; 1962 } 1963 } 1964 1965 // Special handling for OCaml package links 1966 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) { 1967 const parts = url.pathname.substring(1).split('/').filter(part => part); 1968 if (parts.length >= 2) { 1969 const packageName = parts[1]; 1970 displayText = `${packageName} (OCaml)`; 1971 } 1972 } 1973 1974 // Special handling for Medium links 1975 else if (url.hostname.includes('medium.com')) { 1976 const parts = url.pathname.substring(1).split('/').filter(part => part); 1977 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`; 1978 if (parts.length >= 1) { 1979 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`; 1980 } 1981 } 1982 1983 // Special handling for Stack Overflow links 1984 else if (url.hostname.includes('stackoverflow.com')) { 1985 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`; 1986 if (url.pathname.includes('questions')) { 1987 const parts = url.pathname.split('/'); 1988 const questionId = parts.find(part => /^\d+$/.test(part)); 1989 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`; 1990 } 1991 } 1992 1993 // Special handling for Dev.to links 1994 else if (url.hostname === 'dev.to') { 1995 const parts = url.pathname.substring(1).split('/').filter(part => part); 1996 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`; 1997 if (parts.length >= 1) { 1998 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`; 1999 } 2000 } 2001 2002 // Special handling for Reddit links 2003 else if (url.hostname.includes('reddit.com')) { 2004 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`; 2005 if (url.pathname.includes('/r/')) { 2006 const parts = url.pathname.split('/'); 2007 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r'); 2008 if (subreddit) { 2009 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`; 2010 } 2011 } 2012 } 2013 2014 // Special handling for Hacker News links 2015 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) { 2016 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`; 2017 if (url.pathname.includes('item')) { 2018 const itemId = url.searchParams.get('id'); 2019 if (itemId) { 2020 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`; 2021 } 2022 } 2023 } 2024 2025 // Special handling for Bluesky links 2026 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') { 2027 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`; 2028 // Try to extract handle or post info 2029 const parts = url.pathname.substring(1).split('/').filter(part => part); 2030 if (parts.length >= 1) { 2031 if (parts[0] === 'profile') { 2032 // This is a profile link 2033 if (parts.length >= 2) { 2034 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`; 2035 } 2036 } else if (parts[0] === 'post') { 2037 // This is a post link 2038 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`; 2039 } else { 2040 // Assume it's a handle 2041 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`; 2042 } 2043 } 2044 } 2045 2046 // Academic paper detection - PDF files and academic domains 2047 else if ( 2048 link.url.toLowerCase().endsWith('.pdf') || 2049 url.hostname.includes('arxiv.org') || 2050 url.hostname.includes('nature.com') || 2051 url.hostname.includes('science.org') || 2052 url.hostname.includes('mdpi.com') || 2053 url.hostname.includes('doi.org') 2054 ) { 2055 // Set display text based on source 2056 if (url.hostname.includes('arxiv.org')) { 2057 // Try to extract arXiv ID 2058 const arxivIdMatch = url.pathname.match(/\d+\.\d+/); 2059 if (arxivIdMatch) { 2060 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${arxivIdMatch[0]}`; 2061 } else { 2062 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`; 2063 } 2064 } else if (url.hostname.includes('nature.com')) { 2065 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`; 2066 } else if (url.hostname.includes('science.org')) { 2067 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`; 2068 } else if (url.hostname.includes('mdpi.com')) { 2069 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Paper`; 2070 } else if (link.url.toLowerCase().endsWith('.pdf')) { 2071 // For direct PDF links, try to get a meaningful filename 2072 const pathParts = url.pathname.split('/'); 2073 const filename = pathParts[pathParts.length - 1]; 2074 if (filename) { 2075 displayText = `<img src="solid-book-open.svg" width="13https://www.blogger.com/feeds/19062127/posts/default" height="14" style="vertical-align: middle; margin-right: 4px;"> ${decodeURIComponent(filename)}`; 2076 } else { 2077 displayText = `<img src="solid-book-open.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Document`; 2078 } 2079 } 2080 } 2081 2082 // Determine link type for styling and future reference 2083 let linkType = ''; 2084 if (url.hostname.includes('github')) linkType = 'github'; 2085 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia'; 2086 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter'; 2087 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin'; 2088 else if ( 2089 url.hostname.includes('youtube.com') || 2090 url.hostname === 'youtu.be' || 2091 url.hostname === 'watch.eeg.cl.cam.ac.uk' || 2092 url.hostname === 'crank.recoil.org' || 2093 url.hostname === 'watch.ocaml.org' 2094 ) linkType = 'youtube'; 2095 else if (url.hostname.includes('medium.com')) linkType = 'medium'; 2096 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow'; 2097 else if (url.hostname === 'dev.to') linkType = 'dev'; 2098 else if (url.hostname.includes('reddit.com')) linkType = 'reddit'; 2099 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews'; 2100 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky'; 2101 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml'; 2102 else if ( 2103 link.url.toLowerCase().endsWith('.pdf') || 2104 url.hostname.includes('arxiv.org') || 2105 url.hostname.includes('nature.com') || 2106 url.hostname.includes('science.org') || 2107 url.hostname.includes('mdpi.com') 2108 ) linkType = 'academic'; 2109 2110 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`; 2111 }).join(' ')} 2112 </div> 2113 ` : ''} 2114 2115 ${entry.referencesTo && entry.referencesTo.length > 0 ? ` 2116 <div class="preview-references"> 2117 ${entry.referencesTo.map(ref => ` 2118 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a> 2119 `).join(' ')} 2120 </div> 2121 ` : ''} 2122 2123 ${entry.referencedBy && entry.referencedBy.length > 0 ? ` 2124 <div class="preview-references"> 2125 ${entry.referencedBy.map(ref => ` 2126 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a> 2127 `).join(' ')} 2128 </div> 2129 ` : ''} 2130 </div> 2131 </div> 2132 </article> 2133 `; 2134 2135 processedArticleIds.add(entry.articleId); 2136 } 2137 2138 // All articles have been processed in the main loop above 2139 2140 // No longer updating the source count element since it's been removed 2141 2142 // No toggle functions needed anymore 2143 2144 // Build timeline sidebar 2145 const timelineSidebar = document.getElementById('timeline-sidebar'); 2146 let timelineHTML = ''; 2147 2148 // Sort years in descending order 2149 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a); 2150 2151 sortedYears.forEach(year => { 2152 const yearMap = timeline.get(year); 2153 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`; 2154 2155 // Sort months in descending order (Dec to Jan) 2156 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a); 2157 2158 sortedMonths.forEach(month => { 2159 const entries = yearMap.get(month); 2160 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`; 2161 }); 2162 }); 2163 2164 timelineSidebar.innerHTML = timelineHTML; 2165 2166 // Set up scroll observer to highlight timeline items 2167 const observerOptions = { 2168 root: null, 2169 rootMargin: '-80px 0px', 2170 threshold: 0.1 2171 }; 2172 2173 // Skip adding data attributes - we've already done this during HTML generation 2174 2175 // Create observer to track which period is in view 2176 globalFeedObserver = setupObserver(observerOptions); 2177 2178 // Hide loading, show content 2179 loadingContainer.style.display = 'none'; 2180 feedItemsContainer.innerHTML = entriesHTML; 2181 2182 // Month headers are now all visible 2183 2184 // If we have entries, set the most recent (first) entry's date as active in the timeline 2185 if (entriesArray.length > 0) { 2186 const mostRecentEntry = entriesArray[0]; 2187 const date = new Date(mostRecentEntry.published); 2188 const year = date.getFullYear(); 2189 const month = date.getMonth(); 2190 2191 // Set most recent date as the active period in the timeline 2192 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 2193 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`); 2194 2195 // Clear all active classes first 2196 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 2197 el.classList.remove('active'); 2198 }); 2199 2200 // Add active classes to the appropriate year/month 2201 if (yearEl) { 2202 yearEl.classList.add('active'); 2203 lastActiveYear = year; 2204 } 2205 if (monthEl) { 2206 monthEl.classList.add('active'); 2207 lastActiveMonth = month; 2208 } 2209 2210 // Month headers are now simple inline elements, no need to toggle visibility 2211 } 2212 2213 // Helper function to observe all items with date attributes 2214 function observeAllDateItems() { 2215 // Observe all feed items for scroll tracking 2216 document.querySelectorAll('.feed-item').forEach(item => { 2217 globalFeedObserver.observe(item); 2218 }); 2219 2220 // Also observe link items for timeline highlighting 2221 document.querySelectorAll('.link-item').forEach(item => { 2222 globalFeedObserver.observe(item); 2223 }); 2224 } 2225 2226 // Initial observation of all items 2227 observeAllDateItems(); 2228 2229 // Set initial display state for timeline based on initial active tab 2230 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab'); 2231 if (initialActiveTab === 'people') { 2232 document.getElementById('timeline-sidebar').style.display = 'none'; 2233 document.querySelector('.content').style.paddingLeft = '0'; 2234 } else { 2235 // Initialize the last active date from the first visible item 2236 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item'; 2237 const visibleItems = Array.from(document.querySelectorAll(selector)) 2238 .filter(item => { 2239 const rect = item.getBoundingClientRect(); 2240 return rect.top >= 0 && rect.bottom <= window.innerHeight; 2241 }); 2242 2243 if (visibleItems.length > 0) { 2244 lastActiveYear = visibleItems[0].getAttribute('data-year'); 2245 lastActiveMonth = visibleItems[0].getAttribute('data-month'); 2246 } 2247 } 2248 2249 // Set up hover effects and ripple animations 2250 setupHoverEffects(); 2251 2252 // Create a ripple effect that travels across the content area 2253 const feedContainer = document.querySelector('.feed-container'); 2254 feedContainer.addEventListener('mousemove', (e) => { 2255 // Ripple between items as mouse moves 2256 const items = document.querySelectorAll('.feed-item, .link-item'); 2257 items.forEach(item => { 2258 const rect = item.getBoundingClientRect(); 2259 const centerX = rect.left + rect.width / 2; 2260 const centerY = rect.top + rect.height / 2; 2261 2262 // Calculate distance from mouse to center of item 2263 const dx = e.clientX - centerX; 2264 const dy = e.clientY - centerY; 2265 const distance = Math.sqrt(dx * dx + dy * dy); 2266 2267 // Calculate fade based on distance 2268 const maxDistance = 400; // max distance for effect 2269 const intensity = Math.max(0, 1 - (distance / maxDistance)); 2270 2271 if (intensity > 0.05) { 2272 // Extremely subtle glow - minimized for optimal text readability 2273 item.style.boxShadow = `0 0 ${intensity * 8}px var(--hover-glow)`; 2274 item.style.transform = `scale(${1 + intensity * 0.005})`; 2275 item.style.transition = 'box-shadow 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; 2276 } else { 2277 item.style.boxShadow = 'none'; 2278 item.style.transform = 'scale(1)'; 2279 } 2280 }); 2281 }); 2282 2283 // Add hover tracking for link items too 2284 document.querySelectorAll('.link-item').forEach(item => { 2285 item.addEventListener('mousemove', (e) => { 2286 // Get position relative to the element 2287 const rect = item.getBoundingClientRect(); 2288 const x = ((e.clientX - rect.left) / rect.width) * 100; 2289 const y = ((e.clientY - rect.top) / rect.height) * 100; 2290 2291 // Set custom properties for the radial gradient 2292 item.style.setProperty('--mouse-x', `${x}%`); 2293 item.style.setProperty('--mouse-y', `${y}%`); 2294 }); 2295 }); 2296 2297 // Process all external links from entries 2298 const linksContainer = document.getElementById('link-items'); 2299 const allExternalLinks = []; 2300 2301 // Collect all external links from all entries with metadata 2302 Object.values(entriesById).forEach(entry => { 2303 if (entry.externalLinks && entry.externalLinks.length > 0) { 2304 entry.externalLinks.forEach(link => { 2305 // Only process if it's a valid URL 2306 if (link.url) { 2307 try { 2308 const url = new URL(link.url); 2309 2310 // Create a link object with metadata 2311 allExternalLinks.push({ 2312 url: link.url, 2313 normalized_url: link.normalized_url, 2314 source: entry.author, 2315 date: new Date(entry.published), 2316 sourceFeed: entry.source, 2317 sourceTitle: entry.title, 2318 sourceLink: entry.link 2319 }); 2320 } catch (e) { 2321 // Skip invalid URLs 2322 console.warn("Invalid URL:", link.url); 2323 } 2324 } 2325 }); 2326 } 2327 }); 2328 2329 // Sort links by date (newest first) 2330 allExternalLinks.sort((a, b) => b.date - a.date); 2331 2332 // Deduplicate links (keeping most recent occurrence) 2333 const dedupedLinks = []; 2334 const seenUrls = new Set(); 2335 2336 allExternalLinks.forEach(link => { 2337 // Deduplicate based on normalized URL 2338 if (!seenUrls.has(link.normalized_url)) { 2339 seenUrls.add(link.normalized_url); 2340 dedupedLinks.push(link); 2341 } 2342 }); 2343 2344 // Generate HTML for links view 2345 let linksHTML = ''; 2346 2347 // Track current month/year for date headers in links view 2348 let currentLinkYear = null; 2349 let currentLinkMonth = null; 2350 2351 dedupedLinks.forEach(link => { 2352 const date = link.date; 2353 const year = date.getFullYear(); 2354 const month = date.getMonth(); 2355 const dateFormatted = formatDate(date); 2356 const url = new URL(link.url); 2357 let displayText = url.hostname.replace('www.', ''); 2358 let iconPath = ''; 2359 2360 // Check if we need to add a new month/year header 2361 if (currentLinkYear !== year || currentLinkMonth !== month) { 2362 currentLinkYear = year; 2363 currentLinkMonth = month; 2364 2365 linksHTML += ` 2366 <div class="month-year-header" data-year="${year}" data-month="${month}"> 2367 <div class="month-year-label">${monthNames[month]} ${year}</div> 2368 </div>`; 2369 } 2370 2371 // Platform-specific display logic (same as in the main feed) 2372 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') { 2373 const parts = url.pathname.substring(1).split('/').filter(part => part); 2374 if (parts.length >= 2) { 2375 displayText = `${parts[0]}/${parts[1]}`; 2376 iconPath = 'brands-github.svg'; 2377 } 2378 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) { 2379 const titlePart = url.pathname.split('/').pop(); 2380 if (titlePart) { 2381 displayText = decodeURIComponent(titlePart).replace(/_/g, ' '); 2382 iconPath = 'brands-wikipedia-w.svg'; 2383 } 2384 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') { 2385 const parts = url.pathname.substring(1).split('/').filter(part => part); 2386 if (parts.length >= 1) { 2387 displayText = `@${parts[0]}`; 2388 iconPath = 'brands-x-twitter.svg'; 2389 } 2390 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) { 2391 iconPath = 'brands-linkedin.svg'; 2392 displayText = 'LinkedIn'; 2393 const parts = url.pathname.substring(1).split('/').filter(part => part); 2394 if (parts.length >= 2 && parts[0] === 'in') { 2395 displayText = parts[1]; 2396 } 2397 } else if ( 2398 url.hostname.includes('youtube.com') || 2399 url.hostname === 'youtu.be' || 2400 url.hostname === 'watch.eeg.cl.cam.ac.uk' || 2401 url.hostname === 'crank.recoil.org' || 2402 url.hostname === 'watch.ocaml.org' 2403 ) { 2404 iconPath = 'brands-youtube.svg'; 2405 2406 // Custom display text for specific video platforms 2407 if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') { 2408 displayText = 'YouTube Video'; 2409 } else if (url.hostname === 'watch.eeg.cl.cam.ac.uk') { 2410 displayText = 'EEG Video'; 2411 } else if (url.hostname === 'crank.recoil.org') { 2412 displayText = 'Crank Video'; 2413 } else if (url.hostname === 'watch.ocaml.org') { 2414 displayText = 'OCaml Video'; 2415 } else { 2416 displayText = 'Video'; 2417 } 2418 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) { 2419 const parts = url.pathname.substring(1).split('/').filter(part => part); 2420 if (parts.length >= 2) { 2421 const packageName = parts[1]; 2422 displayText = `${packageName} (OCaml)`; 2423 } 2424 } else if (url.hostname.includes('medium.com')) { 2425 iconPath = 'brands-medium.svg'; 2426 displayText = 'Medium'; 2427 const parts = url.pathname.substring(1).split('/').filter(part => part); 2428 if (parts.length >= 1) { 2429 displayText = parts[0]; 2430 } 2431 } else if (url.hostname.includes('stackoverflow.com')) { 2432 iconPath = 'brands-stack-overflow.svg'; 2433 displayText = 'Stack Overflow'; 2434 if (url.pathname.includes('questions')) { 2435 const parts = url.pathname.split('/'); 2436 const questionId = parts.find(part => /^\d+$/.test(part)); 2437 if (questionId) { 2438 displayText = `Q${questionId}`; 2439 } 2440 } 2441 } else if (url.hostname === 'dev.to') { 2442 iconPath = 'brands-dev.svg'; 2443 displayText = 'DEV'; 2444 const parts = url.pathname.substring(1).split('/').filter(part => part); 2445 if (parts.length >= 1) { 2446 displayText = parts[0]; 2447 } 2448 } else if (url.hostname.includes('reddit.com')) { 2449 iconPath = 'brands-reddit.svg'; 2450 displayText = 'Reddit'; 2451 if (url.pathname.includes('/r/')) { 2452 const parts = url.pathname.split('/'); 2453 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r'); 2454 if (subreddit) { 2455 displayText = `r/${subreddit}`; 2456 } 2457 } 2458 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) { 2459 iconPath = 'brands-hacker-news.svg'; 2460 displayText = 'Hacker News'; 2461 if (url.pathname.includes('item')) { 2462 const itemId = url.searchParams.get('id'); 2463 if (itemId) { 2464 displayText = `HN:${itemId}`; 2465 } 2466 } 2467 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') { 2468 iconPath = 'brands-bluesky.svg'; 2469 displayText = 'Bluesky'; 2470 const parts = url.pathname.substring(1).split('/').filter(part => part); 2471 if (parts.length >= 1) { 2472 if (parts[0] === 'profile' && parts.length >= 2) { 2473 displayText = `@${parts[1]}`; 2474 } else if (parts[0] === 'post') { 2475 displayText = 'Post'; 2476 } else { 2477 displayText = `@${parts[0]}`; 2478 } 2479 } 2480 } 2481 2482 // Academic paper detection - PDF files and academic domains 2483 else if ( 2484 link.url.toLowerCase().endsWith('.pdf') || 2485 url.hostname.includes('arxiv.org') || 2486 url.hostname.includes('nature.com') || 2487 url.hostname.includes('science.org') || 2488 url.hostname.includes('mdpi.com') || 2489 url.hostname.includes('doi.org') 2490 ) { 2491 iconPath = 'solid-book-open.svg'; 2492 2493 // Set display text based on source 2494 if (url.hostname.includes('arxiv.org')) { 2495 // Try to extract arXiv ID 2496 const arxivIdMatch = url.pathname.match(/\d+\.\d+/); 2497 if (arxivIdMatch) { 2498 displayText = arxivIdMatch[0]; 2499 } else { 2500 displayText = 'Paper'; 2501 } 2502 } else if (url.hostname.includes('nature.com')) { 2503 displayText = 'Paper'; 2504 } else if (url.hostname.includes('science.org')) { 2505 displayText = 'Paper'; 2506 } else if (url.hostname.includes('mdpi.com')) { 2507 displayText = 'Paper'; 2508 } else if (link.url.toLowerCase().endsWith('.pdf')) { 2509 // For direct PDF links, try to get a meaningful filename 2510 const pathParts = url.pathname.split('/'); 2511 const filename = pathParts[pathParts.length - 1]; 2512 if (filename) { 2513 displayText = decodeURIComponent(filename); 2514 } else { 2515 displayText = 'Document'; 2516 } 2517 } 2518 } 2519 2520 // Function to get day with ordinal suffix (reused) 2521 function getLinkDayWithOrdinal(date) { 2522 const day = date.getDate(); 2523 let suffix = "th"; 2524 if (day % 10 === 1 && day !== 11) { 2525 suffix = "st"; 2526 } else if (day % 10 === 2 && day !== 12) { 2527 suffix = "nd"; 2528 } else if (day % 10 === 3 && day !== 13) { 2529 suffix = "rd"; 2530 } 2531 return day + suffix; 2532 } 2533 2534 // Create link item HTML 2535 linksHTML += ` 2536 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}"> 2537 <div class="link-item-date">${getLinkDayWithOrdinal(date)} ${shortMonthNames[date.getMonth()]} ${date.getFullYear()}</div> 2538 <div class="link-item-source" title="From: ${link.sourceTitle}"> 2539 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;"> 2540 ${link.source} 2541 </a> 2542 </div> 2543 <div class="link-item-content"> 2544 <div class="link-item-url-container"> 2545 <a href="${link.url}" class="link-item-url" target="_blank"> 2546 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''} 2547 ${displayText} 2548 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span> 2549 </a> 2550 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank"> 2551 <span class="link-source-icon">↗</span> ${link.sourceTitle} 2552 </a> 2553 </div> 2554 </div> 2555 </div> 2556 `; 2557 }); 2558 2559 // Update the links container 2560 linksContainer.innerHTML = linksHTML; 2561 2562 // Month headers in links view are now all visible 2563 2564 // If we have links, set the most recent link's date as active in the timeline for the links tab 2565 if (dedupedLinks.length > 0) { 2566 const mostRecentLink = dedupedLinks[0]; 2567 const linkDate = mostRecentLink.date; 2568 const linkYear = linkDate.getFullYear(); 2569 const linkMonth = linkDate.getMonth(); 2570 2571 // Add a flag to remember we've set a most recent link 2572 window.mostRecentLinkSet = { 2573 year: linkYear, 2574 month: linkMonth 2575 }; 2576 } 2577 2578 // Process people data 2579 const peopleContainer = document.querySelector('.people-container'); 2580 const peopleMap = new Map(); // Map to store people data 2581 2582 // Fetch the mapping.json file to get author information 2583 const mappingResponse = await fetch('mapping.json'); 2584 if (!mappingResponse.ok) { 2585 throw new Error('Failed to fetch mapping data'); 2586 } 2587 const mappingData = await mappingResponse.json(); 2588 2589 // Process author information from mapping data 2590 Object.entries(mappingData).forEach(([feedUrl, info]) => { 2591 const { name, site } = info; 2592 if (!peopleMap.has(name)) { 2593 peopleMap.set(name, { 2594 name: name, 2595 site: site, 2596 feedUrl: feedUrl, 2597 posts: [], 2598 postCount: 0, 2599 mostRecent: null 2600 }); 2601 } 2602 }); 2603 2604 // Associate entries with authors 2605 entriesArray.forEach(entry => { 2606 // Find the person who matches this entry's author 2607 // (taking into account potential differences in formatting) 2608 const person = Array.from(peopleMap.values()).find(p => 2609 p.name === entry.author || 2610 entry.author.includes(p.name) || 2611 p.name.includes(entry.author) 2612 ); 2613 2614 if (person) { 2615 person.posts.push(entry); 2616 person.postCount++; 2617 2618 // Track most recent post date 2619 const entryDate = new Date(entry.published); 2620 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) { 2621 person.mostRecent = entry; 2622 } 2623 } 2624 }); 2625 2626 // Generate HTML for people cards 2627 let peopleHTML = ''; 2628 Array.from(peopleMap.values()) 2629 .sort((a, b) => b.postCount - a.postCount) // Sort by post count 2630 .forEach(person => { 2631 const recentPosts = person.posts 2632 .sort((a, b) => new Date(b.published) - new Date(a.published)) 2633 .slice(0, 3); // Get top 3 most recent posts 2634 2635 peopleHTML += ` 2636 <div class="person-card"> 2637 <div class="person-name">${person.name}</div> 2638 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div> 2639 2640 <div class="person-stats"> 2641 <div class="person-stat"> 2642 <div class="stat-value">${person.postCount}</div> 2643 <div class="stat-label">Posts</div> 2644 </div> 2645 <div class="person-stat"> 2646 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div> 2647 <div class="stat-label">Latest</div> 2648 </div> 2649 </div> 2650 2651 ${recentPosts.length > 0 ? ` 2652 <div class="person-recent"> 2653 <div class="recent-title">RECENT POSTS</div> 2654 <div class="recent-posts"> 2655 ${recentPosts.map(post => ` 2656 <div class="recent-post"> 2657 <a href="${post.link}" target="_blank">${post.title}</a> 2658 <div class="recent-post-date">${formatDate(post.published)}</div> 2659 </div> 2660 `).join('')} 2661 </div> 2662 </div> 2663 ` : ''} 2664 </div> 2665 `; 2666 }); 2667 2668 peopleContainer.innerHTML = peopleHTML; 2669 2670 // Initialize tabs 2671 setupTabs(); 2672 2673 // Setup link filtering based on checkboxes 2674 setupLinkFilters(); 2675 2676 // Function to handle link filtering 2677 function setupLinkFilters() { 2678 const filterCheckboxes = document.querySelectorAll('.filter-checkbox'); 2679 2680 filterCheckboxes.forEach(checkbox => { 2681 checkbox.addEventListener('change', updateFilter); 2682 }); 2683 2684 function updateFilter() { 2685 // Get all checked filters 2686 const activeFilters = Array.from(document.querySelectorAll('.filter-checkbox:checked')) 2687 .map(checkbox => checkbox.getAttribute('data-filter')); 2688 2689 // Get all link items 2690 const allLinkItems = document.querySelectorAll('.link-item'); 2691 const monthYearHeaders = document.querySelectorAll('.month-year-header'); 2692 2693 // Show all items if no filters are selected 2694 if (activeFilters.length === 0) { 2695 allLinkItems.forEach(item => { 2696 item.style.display = ''; 2697 }); 2698 monthYearHeaders.forEach(header => { 2699 header.style.display = ''; 2700 }); 2701 return; 2702 } 2703 2704 // Track visible items per month/year 2705 const visibleByMonthYear = new Map(); 2706 2707 // Process all items 2708 allLinkItems.forEach(item => { 2709 const linkUrl = item.querySelector('.link-item-url'); 2710 let shouldShow = false; 2711 2712 // Papers filter - check for PDF icon 2713 if (activeFilters.includes('academic')) { 2714 const hasPdfIcon = linkUrl && ( 2715 linkUrl.innerHTML.includes('solid-book-open.svg') || 2716 (linkUrl.getAttribute('data-link-type') === 'academic') 2717 ); 2718 if (hasPdfIcon) shouldShow = true; 2719 } 2720 2721 // Videos filter - check for YouTube icon or video domains 2722 if (activeFilters.includes('youtube')) { 2723 const hasYoutubeIcon = linkUrl && ( 2724 linkUrl.innerHTML.includes('brands-youtube.svg') || 2725 (linkUrl.getAttribute('data-link-type') === 'youtube') 2726 ); 2727 2728 // Also check for specific video site URLs 2729 const url = linkUrl?.getAttribute('href'); 2730 const isVideoSite = url && ( 2731 url.includes('youtube.com') || 2732 url.includes('youtu.be') || 2733 url.includes('watch.eeg.cl.cam.ac.uk') || 2734 url.includes('crank.recoil.org') || 2735 url.includes('watch.ocaml.org') 2736 ); 2737 2738 if (hasYoutubeIcon || isVideoSite) shouldShow = true; 2739 } 2740 2741 // Set visibility 2742 item.style.display = shouldShow ? '' : 'none'; 2743 2744 // Track visible items by month/year 2745 if (shouldShow) { 2746 const year = item.getAttribute('data-year'); 2747 const month = item.getAttribute('data-month'); 2748 const key = `${year}-${month}`; 2749 visibleByMonthYear.set(key, (visibleByMonthYear.get(key) || 0) + 1); 2750 } 2751 }); 2752 2753 // Hide month-year headers with no visible items 2754 monthYearHeaders.forEach(header => { 2755 const year = header.getAttribute('data-year'); 2756 const month = header.getAttribute('data-month'); 2757 const key = `${year}-${month}`; 2758 2759 if (visibleByMonthYear.has(key)) { 2760 header.style.display = ''; // Show if has visible items 2761 } else { 2762 header.style.display = 'none'; // Hide if no visible items 2763 } 2764 }); 2765 2766 // Update timeline sidebar to match visible items 2767 const timelineYears = document.querySelectorAll('.timeline-year'); 2768 const timelineMonths = document.querySelectorAll('.timeline-month'); 2769 2770 // First get all years that have visible items 2771 const visibleYears = new Set(); 2772 visibleByMonthYear.forEach((count, key) => { 2773 const [year] = key.split('-'); 2774 visibleYears.add(year); 2775 }); 2776 2777 // Hide years without visible items 2778 if (activeFilters.length > 0) { 2779 timelineYears.forEach(yearEl => { 2780 const year = yearEl.getAttribute('data-year'); 2781 yearEl.style.display = visibleYears.has(year) ? '' : 'none'; 2782 }); 2783 2784 // Hide months without visible items 2785 timelineMonths.forEach(monthEl => { 2786 const year = monthEl.getAttribute('data-year'); 2787 const month = monthEl.getAttribute('data-month'); 2788 const key = `${year}-${month}`; 2789 monthEl.style.display = visibleByMonthYear.has(key) ? '' : 'none'; 2790 }); 2791 } else { 2792 // Show all timeline elements when no filters 2793 timelineYears.forEach(yearEl => yearEl.style.display = ''); 2794 timelineMonths.forEach(monthEl => monthEl.style.display = ''); 2795 } 2796 } 2797 } 2798 2799 // Make timeline items clickable to scroll to relevant posts or links 2800 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => { 2801 item.addEventListener('click', () => { 2802 const year = item.getAttribute('data-year'); 2803 const month = item.getAttribute('data-month'); 2804 2805 // Store the selected date globally 2806 lastActiveYear = year; 2807 if (month !== null && month !== undefined) { 2808 lastActiveMonth = month; 2809 } 2810 2811 2812 // Find the first element with this date 2813 let selector = `[data-year="${year}"]`; 2814 if (month !== null && month !== undefined) { 2815 selector += `[data-month="${month}"]`; 2816 } 2817 2818 // Get the active tab 2819 const activeTab = document.querySelector('.tab-content.active'); 2820 const activeTabId = activeTab.getAttribute('data-tab'); 2821 2822 // Look for the target within the active tab 2823 const targetItem = activeTab.querySelector(selector); 2824 2825 // If no matching items in this tab or people tab is active, do nothing 2826 if (targetItem && activeTabId !== 'people') { 2827 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' }); 2828 2829 // Highlight the selected timeline period 2830 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 2831 el.classList.remove('active'); 2832 }); 2833 2834 // Set active classes 2835 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 2836 const monthEl = month !== null && month !== undefined ? 2837 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null; 2838 2839 if (yearEl) yearEl.classList.add('active'); 2840 if (monthEl) monthEl.classList.add('active'); 2841 2842 // Month headers are now simple inline elements, no need to toggle visibility 2843 } 2844 }); 2845 }); 2846 2847 } catch (error) { 2848 console.error('Error loading feed:', error); 2849 loadingContainer.style.display = 'none'; 2850 feedItemsContainer.innerHTML = ` 2851 <div class="error-message"> 2852 <h3>Error Loading Feed</h3> 2853 <p>${error.message}</p> 2854 </div> 2855 `; 2856 } 2857 }); 2858 </script> 2859</body> 2860</html>