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 --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 @media (max-width: 600px) { 984 .feed-item-author { 985 min-width: 50px; 986 margin-right: 10px; 987 } 988 989 .feed-item-date { 990 min-width: auto; 991 width: 100%; 992 margin-bottom: 5px; 993 } 994 995 .feed-item-row { 996 flex-direction: column; 997 align-items: flex-start; 998 } 999 1000 .tabs { 1001 gap: 2px; 1002 width: 100%; 1003 justify-content: space-between; 1004 } 1005 1006 .tab-button { 1007 padding: 6px 8px; 1008 font-size: 0.75rem; 1009 flex-grow: 1; 1010 text-align: center; 1011 } 1012 1013 .people-container { 1014 grid-template-columns: 1fr; 1015 } 1016 1017 1018 main { 1019 margin-top: 150px; 1020 } 1021 1022 .timeline-sidebar { 1023 top: 150px; 1024 height: calc(100vh - 150px); 1025 width: 50px; /* Even narrower on very small screens */ 1026 } 1027 1028 .content { 1029 padding-left: 50px; /* Match the sidebar width on small screens */ 1030 } 1031 } 1032 </style> 1033</head> 1034<body> 1035 <canvas id="matrix-background"></canvas> 1036 <header> 1037 <div class="header-container"> 1038 <div class="header-left"> 1039 <a href="https://www.cst.cam.ac.uk/research/eeg" target="_blank" style="text-decoration: none;"> 1040 <div class="logo">Atomic<span>EEG</span></div> 1041 </a> 1042 <div class="tagline">musings from the Energy & Environment Group at the University of Cambridge</div> 1043 </div> 1044 <div class="tabs"> 1045 <button class="tab-button active" data-tab="posts">Posts</button> 1046 <button class="tab-button" data-tab="links">Links</button> 1047 <button class="tab-button" data-tab="people">Vibes</button> 1048 </div> 1049 </div> 1050 </header> 1051 1052 <main> 1053 <section class="content"> 1054 <div id="loading"> 1055 <div class="loading-spinner"></div> 1056 <p class="loading-text">Growing Content...</p> 1057 </div> 1058 <div id="feed-items" class="tab-content active feed-container" data-tab="posts"></div> 1059 <div id="link-items" class="tab-content feed-container" data-tab="links"></div> 1060 <div id="people-items" class="tab-content" data-tab="people"> 1061 <h2 class="people-header">EEG Sources</h2> 1062 <div class="people-container"></div> 1063 </div> 1064 </section> 1065 <aside class="timeline-sidebar" id="timeline-sidebar"> 1066 <!-- Timeline will be populated via JavaScript --> 1067 </aside> 1068 </main> 1069 1070 <script> 1071 document.addEventListener('DOMContentLoaded', async () => { 1072 // Matrix background effect 1073 const canvas = document.getElementById('matrix-background'); 1074 const ctx = canvas.getContext('2d'); 1075 1076 // Set canvas size to match window 1077 function resizeCanvas() { 1078 canvas.width = window.innerWidth; 1079 canvas.height = window.innerHeight; 1080 } 1081 resizeCanvas(); 1082 window.addEventListener('resize', resizeCanvas); 1083 1084 // Vine/plant-related characters and elements 1085 const vineChars = '┃┃│┋┇┊┆╽╿┴┬╵╷└┕┖┗┘┙┚┛╘╙╚╛╯╰╱╲⌠⌡╎▏▕⏐▌▐░▒▓◥◤◢◣⎸⎹│'; 1086 const leafChars = '☘❀✿❁❃❇❈❉❊❋✣✤✥✦✧✩✪✫✬✭✮✾✿❀❁❂❃❄⚘♠♣⚜⚘☘'; 1087 const branchChars = '┌┐┘└├┬┴┤┼─┄┈┉┊┋╱╲╳☂⚢⌒~∞≈≋⋆✧✦✫'; 1088 const fontSize = 14; 1089 const columns = Math.floor(canvas.width / fontSize * 0.7); // Fewer columns for sparser vines 1090 1091 // Drop positions for each column 1092 const drops = []; 1093 1094 // Initialize drops at random positions 1095 for (let i = 0; i < columns; i++) { 1096 // Random starting position 1097 drops[i] = Math.random() * -canvas.height; 1098 } 1099 1100 // Set up column types - some will be vines, some will have leaves 1101 const columnTypes = []; 1102 for (let i = 0; i < columns; i++) { 1103 // 70% of columns are vines, 25% are leaves, 5% are cross-connections 1104 const rand = Math.random(); 1105 if (rand < 0.7) { 1106 columnTypes[i] = 'vine'; 1107 } else if (rand < 0.95) { 1108 columnTypes[i] = 'leaf'; 1109 } else { 1110 columnTypes[i] = 'branch'; 1111 } 1112 } 1113 1114 // Store connections between vines 1115 const connections = []; 1116 1117 // Helper function to find nearby columns 1118 function findNearbyColumns(columnIndex, maxDistance = 3) { 1119 const nearby = []; 1120 for (let i = 0; i < columns; i++) { 1121 if (i !== columnIndex && Math.abs(i - columnIndex) <= maxDistance) { 1122 nearby.push(i); 1123 } 1124 } 1125 return nearby; 1126 } 1127 1128 // Last time random chars were changed 1129 const lastCharChangeTime = []; 1130 // The current characters displayed 1131 const currentChars = []; 1132 // Width/thickness of vines 1133 const vineThickness = []; 1134 1135 for (let i = 0; i < columns; i++) { 1136 lastCharChangeTime[i] = []; 1137 currentChars[i] = []; 1138 1139 // Random vine thickness between 1-3 1140 vineThickness[i] = Math.floor(Math.random() * 3) + 1; 1141 1142 for (let j = 0; j < canvas.height / fontSize; j++) { 1143 lastCharChangeTime[i][j] = 0; 1144 1145 if (columnTypes[i] === 'vine') { 1146 // Choose vine characters based on position and thickness 1147 if (j === 0) { 1148 // Top of vine - might be a leaf or flower 1149 currentChars[i][j] = Math.random() < 0.6 ? 1150 leafChars.charAt(Math.floor(Math.random() * leafChars.length)) : 1151 vineChars.charAt(Math.floor(Math.random() * vineChars.length)); 1152 } else { 1153 // Main vine character 1154 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1); 1155 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex)); 1156 } 1157 } else if (columnTypes[i] === 'leaf') { 1158 // Leaf character - only at top or occasional spots along the vine 1159 if (j === 0 || Math.random() < 0.2) { 1160 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1161 } else { 1162 // Connecting vine 1163 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * 5)); // Thin vine characters 1164 } 1165 } else if (columnTypes[i] === 'branch') { 1166 // This is a branching column - will form connections between vines 1167 if (j === 0) { 1168 // Top of branch might be a leaf or flower 1169 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1170 } else { 1171 // Branch characters - horizontal or diagonal connectors 1172 currentChars[i][j] = branchChars.charAt(Math.floor(Math.random() * branchChars.length)); 1173 } 1174 } 1175 } 1176 } 1177 1178 // Time when animation started 1179 const startTime = Date.now(); 1180 1181 // Track connections between vines 1182 const crossConnections = []; 1183 1184 // Draw the rainforest vine effect 1185 function drawVineEffect() { 1186 // Semi-transparent background to create fade effect 1187 ctx.fillStyle = 'rgba(10, 23, 15, 0.05)'; 1188 ctx.fillRect(0, 0, canvas.width, canvas.height); 1189 1190 const now = Date.now(); 1191 1192 // Set font 1193 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; 1194 ctx.textAlign = 'center'; 1195 1196 // First, create cross-connections 1197 // Create new cross-connections occasionally 1198 if (Math.random() < 0.01) { 1199 // Find a source vine that's grown enough 1200 const sourceIndex = Math.floor(Math.random() * columns); 1201 if (drops[sourceIndex] > 100 && columnTypes[sourceIndex] === 'vine') { 1202 // Find a nearby column to connect to 1203 const nearby = findNearbyColumns(sourceIndex, 3); 1204 if (nearby.length > 0) { 1205 const targetIndex = nearby[Math.floor(Math.random() * nearby.length)]; 1206 if (drops[targetIndex] > 80) { 1207 // The height should be somewhere between the two vines 1208 const sourceHeight = drops[sourceIndex]; 1209 const targetHeight = drops[targetIndex]; 1210 const connectionHeight = Math.min(sourceHeight, targetHeight) * 0.8; 1211 1212 // Create the connection 1213 crossConnections.push({ 1214 source: sourceIndex, 1215 target: targetIndex, 1216 height: connectionHeight, 1217 character: branchChars.charAt(Math.floor(Math.random() * branchChars.length)), 1218 created: now 1219 }); 1220 } 1221 } 1222 } 1223 } 1224 1225 // For each column 1226 for (let i = 0; i < columns; i++) { 1227 // Calculate current position of this vine 1228 const x = i * fontSize * 1.5; // Space vines further apart 1229 1230 // For each character in this column 1231 for (let j = 0; j < Math.ceil(drops[i] / fontSize); j++) { 1232 const y = j * fontSize; 1233 1234 // Skip rendering some characters to create gaps in vines 1235 if (Math.random() < 0.05 && j > 3) continue; 1236 1237 // Calculate age of this character 1238 const charAge = now - lastCharChangeTime[i][j]; 1239 1240 // Randomly change some characters over time - slower rate for natural movement 1241 if (j === 0 && (Math.random() < 0.005 || charAge > 8000)) { 1242 // Top character might change between leaves/flowers 1243 if (columnTypes[i] === 'leaf' || Math.random() < 0.6) { 1244 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1245 } else { 1246 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineChars.length)); 1247 } 1248 lastCharChangeTime[i][j] = now; 1249 } else if (j > 0 && Math.random() < 0.001) { 1250 // Occasionally grow new leaves along the vine 1251 if (Math.random() < 0.2) { 1252 currentChars[i][j] = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1253 } else { 1254 const vineIndex = Math.min(vineThickness[i] * 3, vineChars.length - 1); 1255 currentChars[i][j] = vineChars.charAt(Math.floor(Math.random() * vineIndex)); 1256 } 1257 lastCharChangeTime[i][j] = now; 1258 } 1259 1260 // Calculate distance from head of the vine 1261 const distanceFromHead = (drops[i] - y); 1262 1263 // Determine color based on position and type 1264 if (j === 0 && (currentChars[i][j] === '❀' || currentChars[i][j] === '✿' || 1265 currentChars[i][j] === '❁' || currentChars[i][j] === '✾')) { 1266 // Flowers are more colorful - pinkish 1267 ctx.fillStyle = 'rgba(255, 180, 220, 0.9)'; 1268 ctx.shadowColor = 'rgba(255, 150, 200, 0.6)'; 1269 ctx.shadowBlur = 5; 1270 } else if (currentChars[i][j] === '☘' || leafChars.includes(currentChars[i][j])) { 1271 // Leaf characters are brightest with different green 1272 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)'; 1273 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)'; 1274 ctx.shadowBlur = 3; 1275 } else if (distanceFromHead < fontSize) { 1276 // Growing tip of vine is brightest 1277 ctx.fillStyle = 'rgba(120, 255, 150, 0.9)'; 1278 ctx.shadowColor = 'rgba(77, 250, 123, 0.5)'; 1279 ctx.shadowBlur = 5; 1280 } else if (distanceFromHead < fontSize * 8) { 1281 // Newer part of vine is brighter 1282 const opacity = 0.8 - (distanceFromHead / (fontSize * 10)); 1283 ctx.fillStyle = `rgba(77, 180, 100, ${opacity.toFixed(2)})`; 1284 ctx.shadowColor = 'transparent'; 1285 ctx.shadowBlur = 0; 1286 } else { 1287 // Older parts of vine are darker 1288 const opacity = Math.max(0, 0.4 - (distanceFromHead / (canvas.height * 2))); 1289 // Darker green for older vines 1290 ctx.fillStyle = `rgba(40, 120, 60, ${opacity.toFixed(2)})`; 1291 ctx.shadowColor = 'transparent'; 1292 ctx.shadowBlur = 0; 1293 } 1294 1295 // Add slight random swaying to vines 1296 const swayAmount = Math.sin((now / 2000) + i) * 2; // Gentle swaying effect 1297 const adjustedX = x + swayAmount; 1298 1299 // Draw the character 1300 if (y < canvas.height) { 1301 // Adjust size for special characters 1302 if (leafChars.includes(currentChars[i][j])) { 1303 ctx.font = `${fontSize * 1.2}px 'JetBrains Mono', monospace`; 1304 ctx.fillText(currentChars[i][j], adjustedX, y); 1305 ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Reset font 1306 } else { 1307 ctx.fillText(currentChars[i][j], adjustedX, y); 1308 } 1309 } 1310 } 1311 1312 // Move the vine down - slower for natural growth 1313 drops[i] += fontSize * (0.02 + Math.random() * 0.03); 1314 1315 // Reset vine when it reaches bottom or randomly (much less frequent) 1316 if (drops[i] > canvas.height * 2 || (Math.random() < 0.0005 && drops[i] > canvas.height * 0.6)) { 1317 drops[i] = Math.random() * -30; 1318 // Maybe change vine type 1319 if (Math.random() < 0.3) { 1320 columnTypes[i] = Math.random() < 0.7 ? 'vine' : 'leaf'; 1321 vineThickness[i] = Math.floor(Math.random() * 3) + 1; 1322 } 1323 } 1324 } 1325 1326 // Draw cross connections between vines 1327 crossConnections.forEach((connection, index) => { 1328 const sourceX = connection.source * fontSize * 1.5; 1329 const targetX = connection.target * fontSize * 1.5; 1330 const y = connection.height; 1331 const heightIndex = Math.floor(y / fontSize); 1332 1333 // Calculate a safe display Y - make sure it's within the grown vines 1334 const safeY = Math.min( 1335 Math.min(drops[connection.source], drops[connection.target]), 1336 connection.height 1337 ); 1338 1339 // Convert to display coords 1340 const displayY = Math.floor(safeY / fontSize) * fontSize; 1341 1342 // Only draw if connection is within visible area 1343 if (displayY < 0 || displayY > canvas.height) return; 1344 1345 // Connection age effect 1346 const age = now - connection.created; 1347 const maxAge = 20000; // 20 seconds lifetime for connections 1348 1349 // Remove old connections 1350 if (age > maxAge) { 1351 crossConnections.splice(index, 1); 1352 return; 1353 } 1354 1355 // Fade in/out effect 1356 let opacity = 1.0; 1357 if (age < 1000) { 1358 // Fade in 1359 opacity = age / 1000; 1360 } else if (age > maxAge - 2000) { 1361 // Fade out 1362 opacity = (maxAge - age) / 2000; 1363 } 1364 1365 // Draw connection 1366 const connectionWidth = Math.abs(targetX - sourceX); 1367 const steps = Math.ceil(connectionWidth / (fontSize * 0.8)); 1368 1369 // Lighter green for branches 1370 ctx.fillStyle = `rgba(120, 255, 150, ${opacity.toFixed(2)})`; 1371 ctx.shadowColor = 'rgba(77, 250, 123, 0.4)'; 1372 ctx.shadowBlur = 2; 1373 1374 // Draw branch character at each step 1375 let branchChar; 1376 1377 if (sourceX < targetX) { 1378 // Left to right 1379 branchChar = '─'; 1380 } else { 1381 // Right to left 1382 branchChar = '─'; 1383 } 1384 1385 for (let s = 0; s <= steps; s++) { 1386 // Calculate position 1387 const progress = s / steps; 1388 const stepX = sourceX + (targetX - sourceX) * progress; 1389 const wiggle = Math.sin(progress * Math.PI) * 5; 1390 1391 // Choose appropriate connection character 1392 let connChar = branchChar; 1393 1394 // Special characters for start, middle and end 1395 if (s === 0) { 1396 connChar = '├'; 1397 } else if (s === steps) { 1398 connChar = '┤'; 1399 } else if (s === Math.floor(steps/2)) { 1400 // Add a leaf or flower in the middle sometimes 1401 if (Math.random() < 0.3) { 1402 connChar = leafChars.charAt(Math.floor(Math.random() * leafChars.length)); 1403 } else { 1404 connChar = s % 2 === 0 ? '┼' : '┴'; 1405 } 1406 } else { 1407 // Occasional decorative elements 1408 if (Math.random() < 0.1) { 1409 connChar = '·'; 1410 } 1411 } 1412 1413 ctx.fillText(connChar, stepX, displayY + wiggle); 1414 } 1415 }); 1416 1417 // Schedule next frame 1418 requestAnimationFrame(drawVineEffect); 1419 } 1420 1421 // Start the animation 1422 drawVineEffect(); 1423 // Add hover event listeners after DOM content is loaded 1424 function setupHoverEffects() { 1425 // Keep track of the currently active item 1426 let currentHoveredItem = null; 1427 1428 document.querySelectorAll('.feed-item').forEach(item => { 1429 item.addEventListener('mouseenter', () => { 1430 // Set this as current hovered item 1431 currentHoveredItem = item; 1432 }); 1433 1434 // Track mouse position for the ripple effect 1435 item.addEventListener('mousemove', (e) => { 1436 // Get position relative to the element 1437 const rect = item.getBoundingClientRect(); 1438 const x = ((e.clientX - rect.left) / rect.width) * 100; 1439 const y = ((e.clientY - rect.top) / rect.height) * 100; 1440 1441 // Set custom properties for the radial gradient 1442 item.style.setProperty('--mouse-x', `${x}%`); 1443 item.style.setProperty('--mouse-y', `${y}%`); 1444 }); 1445 }); 1446 } 1447 1448 // Tab switching functionality 1449 // Create global variables to store state 1450 let globalFeedObserver = null; 1451 let lastActiveYear = null; 1452 let lastActiveMonth = null; 1453 1454 function setupObserver(options) { 1455 // Create a new intersection observer for handling timeline scrolling 1456 return new IntersectionObserver((entries) => { 1457 entries.forEach(entry => { 1458 if (entry.isIntersecting) { 1459 const year = entry.target.getAttribute('data-year'); 1460 const month = entry.target.getAttribute('data-month'); 1461 1462 // Get the active tab 1463 const activeTab = document.querySelector('.tab-content.active'); 1464 const activeTabId = activeTab.getAttribute('data-tab'); 1465 1466 // Only process if we're on posts or links tab 1467 if ((activeTabId === 'posts' || activeTabId === 'links') && year && month) { 1468 // Clear all active classes 1469 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 1470 el.classList.remove('active'); 1471 }); 1472 1473 // Set active classes 1474 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 1475 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`); 1476 1477 if (yearEl) { 1478 yearEl.classList.add('active'); 1479 // Store the last active year globally 1480 lastActiveYear = year; 1481 } 1482 if (monthEl) { 1483 monthEl.classList.add('active'); 1484 // Store the last active month globally 1485 lastActiveMonth = month; 1486 } 1487 1488 // Month headers are now simple inline elements, no need to toggle visibility 1489 } 1490 } 1491 }); 1492 }, options); 1493 } 1494 1495 function setupTabs() { 1496 const tabButtons = document.querySelectorAll('.tab-button'); 1497 const tabContents = document.querySelectorAll('.tab-content'); 1498 const timeline = document.getElementById('timeline-sidebar'); 1499 1500 tabButtons.forEach(button => { 1501 button.addEventListener('click', () => { 1502 const tabName = button.getAttribute('data-tab'); 1503 1504 // Deactivate all tabs 1505 tabButtons.forEach(btn => btn.classList.remove('active')); 1506 tabContents.forEach(content => content.classList.remove('active')); 1507 1508 // Activate selected tab 1509 button.classList.add('active'); 1510 const tabContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`); 1511 tabContent.classList.add('active'); 1512 1513 // Month headers are now simple inline elements, no need to toggle visibility 1514 1515 // Show or hide timeline sidebar based on active tab 1516 if (tabName === 'people') { 1517 timeline.style.display = 'none'; 1518 document.querySelector('.content').style.paddingLeft = '0'; 1519 } else { 1520 timeline.style.display = 'flex'; 1521 document.querySelector('.content').style.paddingLeft = 'var(--sidebar-width)'; 1522 1523 // Reset timeline highlighting 1524 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 1525 el.classList.remove('active'); 1526 }); 1527 1528 // Disconnect and recreate the observer to ensure proper tracking 1529 if (globalFeedObserver) { 1530 globalFeedObserver.disconnect(); 1531 } 1532 1533 // Setup a new observer 1534 globalFeedObserver = setupObserver({ 1535 root: null, 1536 rootMargin: '-80px 0px', 1537 threshold: 0.1 1538 }); 1539 1540 // Observe all items in the active tab 1541 observeAllDateItems(); 1542 1543 // Always scroll to top when switching tabs 1544 window.scrollTo({ top: 0, behavior: 'smooth' }); 1545 } 1546 }); 1547 }); 1548 } 1549 const feedItemsContainer = document.getElementById('feed-items'); 1550 const loadingContainer = document.getElementById('loading'); 1551 1552 // Function to format date (only date, no time) 1553 function formatDate(dateString) { 1554 const date = new Date(dateString); 1555 return date.toLocaleDateString('en-US', { 1556 year: 'numeric', 1557 month: 'short', 1558 day: 'numeric' 1559 }); 1560 } 1561 1562 // We no longer need preview processing functions 1563 // since we're displaying content as-is with HTML tags 1564 1565 // Function removed - we no longer toggle full content 1566 1567 // Removed the external links toggle function as it's no longer needed 1568 1569 // Reference toggle function removed - references are now shown with CSS on hover 1570 1571 try { 1572 // Fetch the Atom feed and threads data in parallel 1573 const [feedResponse, threadsResponse] = await Promise.all([ 1574 fetch('eeg.xml'), 1575 fetch('threads.json') 1576 ]); 1577 1578 if (!feedResponse.ok) { 1579 throw new Error('Failed to fetch feed'); 1580 } 1581 1582 if (!threadsResponse.ok) { 1583 throw new Error('Failed to fetch threads data'); 1584 } 1585 1586 const xmlText = await feedResponse.text(); 1587 const threadsData = await threadsResponse.json(); 1588 1589 const parser = new DOMParser(); 1590 const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); 1591 1592 // Process feed entries 1593 const entries = xmlDoc.getElementsByTagName('entry'); 1594 const sources = new Set(); 1595 1596 // No longer updating the entry count element since it's been removed 1597 1598 // Map to store entries by ID for easy lookup 1599 const entriesById = {}; 1600 1601 // First pass: extract all entries and build the ID map 1602 for (let i = 0; i < entries.length; i++) { 1603 const entry = entries[i]; 1604 1605 // Extract entry data 1606 const id = entry.getElementsByTagName('id')[0]?.textContent || ''; 1607 const title = entry.getElementsByTagName('title')[0]?.textContent || 'No Title'; 1608 const link = entry.getElementsByTagName('link')[0]?.getAttribute('href') || '#'; 1609 const contentElement = entry.getElementsByTagName('summary')[0] || entry.getElementsByTagName('content')[0]; 1610 const contentText = contentElement?.textContent || ''; 1611 const contentType = contentElement?.getAttribute('type') || 'text'; 1612 const published = entry.getElementsByTagName('published')[0]?.textContent || 1613 entry.getElementsByTagName('updated')[0]?.textContent || ''; 1614 const author = entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || 'Unknown'; 1615 const categories = entry.getElementsByTagName('category'); 1616 1617 // Extract source from category (we're using category to store source name) 1618 let source = 'Unknown Source'; 1619 if (categories.length > 0) { 1620 source = categories[0].getAttribute('term'); 1621 sources.add(source); 1622 } 1623 1624 // Properly handle the content based on content type 1625 let contentHtml; 1626 if (contentType === 'html' || contentType === 'text/html') { 1627 // For HTML content, create a div and set innerHTML 1628 contentHtml = contentText; 1629 } else { 1630 // For text content, escape it and preserve newlines 1631 contentHtml = contentText 1632 .replace(/&/g, '&amp;') 1633 .replace(/</g, '&lt;') 1634 .replace(/>/g, '&gt;') 1635 .replace(/\n/g, '<br>'); 1636 } 1637 1638 // Store the entry data 1639 entriesById[id] = { 1640 id, 1641 articleId: `article-${i}`, 1642 title, 1643 link, 1644 contentHtml, // Use the content as-is with HTML tags 1645 published, 1646 author, 1647 source, 1648 threadGroup: null, 1649 isThreadParent: false, 1650 threadParentId: null, 1651 inThread: false, 1652 threadPosition: 0, 1653 externalLinks: [], 1654 }; 1655 } 1656 1657 // Process reference relationships and external links 1658 for (const entryId in entriesById) { 1659 if (threadsData[entryId]) { 1660 const threadInfo = threadsData[entryId]; 1661 const entry = entriesById[entryId]; 1662 1663 // Track external links for this entry 1664 entry.externalLinks = []; 1665 if (threadInfo.external_links && threadInfo.external_links.length > 0) { 1666 entry.externalLinks = threadInfo.external_links.map(link => ({ 1667 url: link.url, 1668 normalized_url: link.normalized_url 1669 })); 1670 } 1671 1672 // Track references to other posts (outgoing links) 1673 entry.referencesTo = []; 1674 if (threadInfo.references && threadInfo.references.length > 0) { 1675 // Filter for only in-feed references 1676 threadInfo.references.forEach(ref => { 1677 if (ref.in_feed === true && entriesById[ref.id]) { 1678 entry.referencesTo.push({ 1679 id: ref.id, 1680 title: ref.title, 1681 link: ref.link, 1682 author: entriesById[ref.id].author 1683 }); 1684 } 1685 }); 1686 } 1687 1688 // Track posts that reference this one (incoming links) 1689 entry.referencedBy = []; 1690 if (threadInfo.referenced_by && threadInfo.referenced_by.length > 0) { 1691 // Filter for only in-feed references 1692 threadInfo.referenced_by.forEach(ref => { 1693 if (ref.in_feed === true && entriesById[ref.id]) { 1694 entry.referencedBy.push({ 1695 id: ref.id, 1696 title: ref.title, 1697 link: ref.link, 1698 author: entriesById[ref.id].author 1699 }); 1700 } 1701 }); 1702 } 1703 } 1704 } 1705 1706 // Sort by date and create HTML 1707 const entriesArray = Object.values(entriesById); 1708 entriesArray.sort((a, b) => new Date(b.published) - new Date(a.published)); 1709 1710 // Create a timeline structure by year/month 1711 const timeline = new Map(); 1712 const monthNames = [ 1713 'January', 'February', 'March', 'April', 'May', 'June', 1714 'July', 'August', 'September', 'October', 'November', 'December' 1715 ]; 1716 const shortMonthNames = [ 1717 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 1718 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' 1719 ]; 1720 1721 // Group entries by year and month for the timeline 1722 entriesArray.forEach(entry => { 1723 const date = new Date(entry.published); 1724 const year = date.getFullYear(); 1725 const month = date.getMonth(); 1726 1727 if (!timeline.has(year)) { 1728 timeline.set(year, new Map()); 1729 } 1730 1731 const yearMap = timeline.get(year); 1732 if (!yearMap.has(month)) { 1733 yearMap.set(month, []); 1734 } 1735 1736 yearMap.get(month).push(entry); 1737 }); 1738 1739 // Process all entries in strict date order 1740 let entriesHTML = ''; 1741 const processedArticleIds = new Set(); 1742 1743 // Create a copy of entriesArray to process strictly by date 1744 const entriesByDate = [...entriesArray]; 1745 1746 // Track current month/year for date headers 1747 let currentYear = null; 1748 let currentMonth = null; 1749 1750 // Process each entry in date order 1751 for (const entry of entriesByDate) { 1752 // Skip entries already processed 1753 if (processedArticleIds.has(entry.articleId)) continue; 1754 1755 const date = new Date(entry.published); 1756 const year = date.getFullYear(); 1757 const month = date.getMonth(); 1758 const dateAttr = `data-year="${year}" data-month="${month}"`; 1759 1760 // Check if we need to add a new month/year header 1761 if (currentYear !== year || currentMonth !== month) { 1762 currentYear = year; 1763 currentMonth = month; 1764 1765 entriesHTML += ` 1766 <div class="month-year-header" ${dateAttr}> 1767 <div class="month-year-label">${monthNames[month]} ${year}</div> 1768 </div>`; 1769 } 1770 1771 // Function to get day with ordinal suffix 1772 function getDayWithOrdinal(date) { 1773 const day = date.getDate(); 1774 let suffix = "th"; 1775 if (day % 10 === 1 && day !== 11) { 1776 suffix = "st"; 1777 } else if (day % 10 === 2 && day !== 12) { 1778 suffix = "nd"; 1779 } else if (day % 10 === 3 && day !== 13) { 1780 suffix = "rd"; 1781 } 1782 return day + suffix; 1783 } 1784 1785 // Add entry 1786 entriesHTML += ` 1787 <article id="${entry.articleId}" class="feed-item" ${dateAttr}> 1788 <div class="feed-item-row"> 1789 <div class="feed-item-date">${getDayWithOrdinal(date)} ${shortMonthNames[month]} ${year}</div> 1790 <div class="feed-item-author">${entry.author}</div> 1791 <div class="feed-item-content-wrapper"> 1792 <div class="feed-item-title"><a href="${entry.link}" target="_blank">${entry.title}</a></div><div class="feed-item-preview">${entry.contentHtml}</div> 1793 1794 ${entry.externalLinks && entry.externalLinks.length > 0 ? ` 1795 <div class="preview-links"> 1796 ${Array.from(new Set(entry.externalLinks.map(link => link.url))).map(uniqueUrl => { 1797 // Find the first link object with this URL 1798 const link = entry.externalLinks.find(l => l.url === uniqueUrl); 1799 const url = new URL(link.url); 1800 let displayText = url.hostname.replace('www.', ''); 1801 1802 // Special handling for GitHub links 1803 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') { 1804 // Extract the parts from pathname (remove leading slash) 1805 const parts = url.pathname.substring(1).split('/').filter(part => part); 1806 if (parts.length >= 2) { 1807 displayText = `<img src="brands-github.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}/${parts[1]}`; 1808 } 1809 } 1810 1811 // Special handling for Wikipedia links 1812 else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) { 1813 const titlePart = url.pathname.split('/').pop(); 1814 if (titlePart) { 1815 const title = decodeURIComponent(titlePart).replace(/_/g, ' '); 1816 displayText = `<img src="brands-wikipedia-w.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${title}`; 1817 } 1818 } 1819 1820 // Special handling for Twitter/X links 1821 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') { 1822 const parts = url.pathname.substring(1).split('/').filter(part => part); 1823 if (parts.length >= 1) { 1824 displayText = `<img src="brands-x-twitter.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`; 1825 } 1826 } 1827 1828 // Special handling for LinkedIn links 1829 else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) { 1830 const parts = url.pathname.substring(1).split('/').filter(part => part); 1831 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> LinkedIn`; 1832 if (parts.length >= 2 && parts[0] === 'in') { 1833 displayText = `<img src="brands-linkedin.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[1]}`; 1834 } 1835 } 1836 1837 // Special handling for YouTube links 1838 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') { 1839 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> YouTube`; 1840 // Try to get video title from URL parameters 1841 const videoId = url.searchParams.get('v'); 1842 if (url.pathname.includes('watch') && videoId) { 1843 displayText = `<img src="brands-youtube.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Video`; 1844 } 1845 } 1846 1847 // Special handling for OCaml package links 1848 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) { 1849 const parts = url.pathname.substring(1).split('/').filter(part => part); 1850 if (parts.length >= 2) { 1851 const packageName = parts[1]; 1852 displayText = `${packageName} (OCaml)`; 1853 } 1854 } 1855 1856 // Special handling for Medium links 1857 else if (url.hostname.includes('medium.com')) { 1858 const parts = url.pathname.substring(1).split('/').filter(part => part); 1859 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Medium`; 1860 if (parts.length >= 1) { 1861 displayText = `<img src="brands-medium.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`; 1862 } 1863 } 1864 1865 // Special handling for Stack Overflow links 1866 else if (url.hostname.includes('stackoverflow.com')) { 1867 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Stack Overflow`; 1868 if (url.pathname.includes('questions')) { 1869 const parts = url.pathname.split('/'); 1870 const questionId = parts.find(part => /^\d+$/.test(part)); 1871 displayText = `<img src="brands-stack-overflow.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Q${questionId || ''}`; 1872 } 1873 } 1874 1875 // Special handling for Dev.to links 1876 else if (url.hostname === 'dev.to') { 1877 const parts = url.pathname.substring(1).split('/').filter(part => part); 1878 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> DEV`; 1879 if (parts.length >= 1) { 1880 displayText = `<img src="brands-dev.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> ${parts[0]}`; 1881 } 1882 } 1883 1884 // Special handling for Reddit links 1885 else if (url.hostname.includes('reddit.com')) { 1886 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Reddit`; 1887 if (url.pathname.includes('/r/')) { 1888 const parts = url.pathname.split('/'); 1889 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r'); 1890 if (subreddit) { 1891 displayText = `<img src="brands-reddit.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> r/${subreddit}`; 1892 } 1893 } 1894 } 1895 1896 // Special handling for Hacker News links 1897 else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) { 1898 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Hacker News`; 1899 if (url.pathname.includes('item')) { 1900 const itemId = url.searchParams.get('id'); 1901 if (itemId) { 1902 displayText = `<img src="brands-hacker-news.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> HN:${itemId}`; 1903 } 1904 } 1905 } 1906 1907 // Special handling for Bluesky links 1908 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') { 1909 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Bluesky`; 1910 // Try to extract handle or post info 1911 const parts = url.pathname.substring(1).split('/').filter(part => part); 1912 if (parts.length >= 1) { 1913 if (parts[0] === 'profile') { 1914 // This is a profile link 1915 if (parts.length >= 2) { 1916 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[1]}`; 1917 } 1918 } else if (parts[0] === 'post') { 1919 // This is a post link 1920 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> Post`; 1921 } else { 1922 // Assume it's a handle 1923 displayText = `<img src="brands-bluesky.svg" width="14" height="14" style="vertical-align: middle; margin-right: 4px;"> @${parts[0]}`; 1924 } 1925 } 1926 } 1927 1928 // Determine link type for styling and future reference 1929 let linkType = ''; 1930 if (url.hostname.includes('github')) linkType = 'github'; 1931 else if (url.hostname.includes('wikipedia')) linkType = 'wikipedia'; 1932 else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') linkType = 'twitter'; 1933 else if (url.hostname.includes('linkedin.com')) linkType = 'linkedin'; 1934 else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') linkType = 'youtube'; 1935 else if (url.hostname.includes('medium.com')) linkType = 'medium'; 1936 else if (url.hostname.includes('stackoverflow.com')) linkType = 'stackoverflow'; 1937 else if (url.hostname === 'dev.to') linkType = 'dev'; 1938 else if (url.hostname.includes('reddit.com')) linkType = 'reddit'; 1939 else if (url.hostname.includes('news.ycombinator.com')) linkType = 'hackernews'; 1940 else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') linkType = 'bluesky'; 1941 else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) linkType = 'ocaml'; 1942 1943 return `<a href="${link.url}" target="_blank" class="external-link-item" title="${link.url}" data-link-type="${linkType}">${displayText}</a>`; 1944 }).join(' ')} 1945 </div> 1946 ` : ''} 1947 1948 ${entry.referencesTo && entry.referencesTo.length > 0 ? ` 1949 <div class="preview-references"> 1950 ${entry.referencesTo.map(ref => ` 1951 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">→ ${ref.title}</a> 1952 `).join(' ')} 1953 </div> 1954 ` : ''} 1955 1956 ${entry.referencedBy && entry.referencedBy.length > 0 ? ` 1957 <div class="preview-references"> 1958 ${entry.referencedBy.map(ref => ` 1959 <a href="${ref.link}" target="_blank" class="external-link-item" title="${ref.title} by ${ref.author}">← ${ref.title}</a> 1960 `).join(' ')} 1961 </div> 1962 ` : ''} 1963 </div> 1964 </div> 1965 </article> 1966 `; 1967 1968 processedArticleIds.add(entry.articleId); 1969 } 1970 1971 // All articles have been processed in the main loop above 1972 1973 // No longer updating the source count element since it's been removed 1974 1975 // No toggle functions needed anymore 1976 1977 // Build timeline sidebar 1978 const timelineSidebar = document.getElementById('timeline-sidebar'); 1979 let timelineHTML = ''; 1980 1981 // Sort years in descending order 1982 const sortedYears = Array.from(timeline.keys()).sort((a, b) => b - a); 1983 1984 sortedYears.forEach(year => { 1985 const yearMap = timeline.get(year); 1986 timelineHTML += `<div class="timeline-year" data-year="${year}">${year}</div>`; 1987 1988 // Sort months in descending order (Dec to Jan) 1989 const sortedMonths = Array.from(yearMap.keys()).sort((a, b) => b - a); 1990 1991 sortedMonths.forEach(month => { 1992 const entries = yearMap.get(month); 1993 timelineHTML += `<div class="timeline-month" data-year="${year}" data-month="${month}">${shortMonthNames[month]}</div>`; 1994 }); 1995 }); 1996 1997 timelineSidebar.innerHTML = timelineHTML; 1998 1999 // Set up scroll observer to highlight timeline items 2000 const observerOptions = { 2001 root: null, 2002 rootMargin: '-80px 0px', 2003 threshold: 0.1 2004 }; 2005 2006 // Skip adding data attributes - we've already done this during HTML generation 2007 2008 // Create observer to track which period is in view 2009 globalFeedObserver = setupObserver(observerOptions); 2010 2011 // Hide loading, show content 2012 loadingContainer.style.display = 'none'; 2013 feedItemsContainer.innerHTML = entriesHTML; 2014 2015 // Month headers are now all visible 2016 2017 // If we have entries, set the most recent (first) entry's date as active in the timeline 2018 if (entriesArray.length > 0) { 2019 const mostRecentEntry = entriesArray[0]; 2020 const date = new Date(mostRecentEntry.published); 2021 const year = date.getFullYear(); 2022 const month = date.getMonth(); 2023 2024 // Set most recent date as the active period in the timeline 2025 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 2026 const monthEl = document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`); 2027 2028 // Clear all active classes first 2029 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 2030 el.classList.remove('active'); 2031 }); 2032 2033 // Add active classes to the appropriate year/month 2034 if (yearEl) { 2035 yearEl.classList.add('active'); 2036 lastActiveYear = year; 2037 } 2038 if (monthEl) { 2039 monthEl.classList.add('active'); 2040 lastActiveMonth = month; 2041 } 2042 2043 // Month headers are now simple inline elements, no need to toggle visibility 2044 } 2045 2046 // Helper function to observe all items with date attributes 2047 function observeAllDateItems() { 2048 // Observe all feed items for scroll tracking 2049 document.querySelectorAll('.feed-item').forEach(item => { 2050 globalFeedObserver.observe(item); 2051 }); 2052 2053 // Also observe link items for timeline highlighting 2054 document.querySelectorAll('.link-item').forEach(item => { 2055 globalFeedObserver.observe(item); 2056 }); 2057 } 2058 2059 // Initial observation of all items 2060 observeAllDateItems(); 2061 2062 // Set initial display state for timeline based on initial active tab 2063 const initialActiveTab = document.querySelector('.tab-button.active').getAttribute('data-tab'); 2064 if (initialActiveTab === 'people') { 2065 document.getElementById('timeline-sidebar').style.display = 'none'; 2066 document.querySelector('.content').style.paddingLeft = '0'; 2067 } else { 2068 // Initialize the last active date from the first visible item 2069 const selector = initialActiveTab === 'posts' ? '.feed-item' : '.link-item'; 2070 const visibleItems = Array.from(document.querySelectorAll(selector)) 2071 .filter(item => { 2072 const rect = item.getBoundingClientRect(); 2073 return rect.top >= 0 && rect.bottom <= window.innerHeight; 2074 }); 2075 2076 if (visibleItems.length > 0) { 2077 lastActiveYear = visibleItems[0].getAttribute('data-year'); 2078 lastActiveMonth = visibleItems[0].getAttribute('data-month'); 2079 } 2080 } 2081 2082 // Set up hover effects and ripple animations 2083 setupHoverEffects(); 2084 2085 // Create a ripple effect that travels across the content area 2086 const feedContainer = document.querySelector('.feed-container'); 2087 feedContainer.addEventListener('mousemove', (e) => { 2088 // Ripple between items as mouse moves 2089 const items = document.querySelectorAll('.feed-item, .link-item'); 2090 items.forEach(item => { 2091 const rect = item.getBoundingClientRect(); 2092 const centerX = rect.left + rect.width / 2; 2093 const centerY = rect.top + rect.height / 2; 2094 2095 // Calculate distance from mouse to center of item 2096 const dx = e.clientX - centerX; 2097 const dy = e.clientY - centerY; 2098 const distance = Math.sqrt(dx * dx + dy * dy); 2099 2100 // Calculate fade based on distance 2101 const maxDistance = 400; // max distance for effect 2102 const intensity = Math.max(0, 1 - (distance / maxDistance)); 2103 2104 if (intensity > 0.05) { 2105 // Extremely subtle glow - minimized for optimal text readability 2106 item.style.boxShadow = `0 0 ${intensity * 8}px var(--hover-glow)`; 2107 item.style.transform = `scale(${1 + intensity * 0.005})`; 2108 item.style.transition = 'box-shadow 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; 2109 } else { 2110 item.style.boxShadow = 'none'; 2111 item.style.transform = 'scale(1)'; 2112 } 2113 }); 2114 }); 2115 2116 // Add hover tracking for link items too 2117 document.querySelectorAll('.link-item').forEach(item => { 2118 item.addEventListener('mousemove', (e) => { 2119 // Get position relative to the element 2120 const rect = item.getBoundingClientRect(); 2121 const x = ((e.clientX - rect.left) / rect.width) * 100; 2122 const y = ((e.clientY - rect.top) / rect.height) * 100; 2123 2124 // Set custom properties for the radial gradient 2125 item.style.setProperty('--mouse-x', `${x}%`); 2126 item.style.setProperty('--mouse-y', `${y}%`); 2127 }); 2128 }); 2129 2130 // Process all external links from entries 2131 const linksContainer = document.getElementById('link-items'); 2132 const allExternalLinks = []; 2133 2134 // Collect all external links from all entries with metadata 2135 Object.values(entriesById).forEach(entry => { 2136 if (entry.externalLinks && entry.externalLinks.length > 0) { 2137 entry.externalLinks.forEach(link => { 2138 // Only process if it's a valid URL 2139 if (link.url) { 2140 try { 2141 const url = new URL(link.url); 2142 2143 // Create a link object with metadata 2144 allExternalLinks.push({ 2145 url: link.url, 2146 normalized_url: link.normalized_url, 2147 source: entry.author, 2148 date: new Date(entry.published), 2149 sourceFeed: entry.source, 2150 sourceTitle: entry.title, 2151 sourceLink: entry.link 2152 }); 2153 } catch (e) { 2154 // Skip invalid URLs 2155 console.warn("Invalid URL:", link.url); 2156 } 2157 } 2158 }); 2159 } 2160 }); 2161 2162 // Sort links by date (newest first) 2163 allExternalLinks.sort((a, b) => b.date - a.date); 2164 2165 // Deduplicate links (keeping most recent occurrence) 2166 const dedupedLinks = []; 2167 const seenUrls = new Set(); 2168 2169 allExternalLinks.forEach(link => { 2170 // Deduplicate based on normalized URL 2171 if (!seenUrls.has(link.normalized_url)) { 2172 seenUrls.add(link.normalized_url); 2173 dedupedLinks.push(link); 2174 } 2175 }); 2176 2177 // Generate HTML for links view 2178 let linksHTML = ''; 2179 2180 // Track current month/year for date headers in links view 2181 let currentLinkYear = null; 2182 let currentLinkMonth = null; 2183 2184 dedupedLinks.forEach(link => { 2185 const date = link.date; 2186 const year = date.getFullYear(); 2187 const month = date.getMonth(); 2188 const dateFormatted = formatDate(date); 2189 const url = new URL(link.url); 2190 let displayText = url.hostname.replace('www.', ''); 2191 let iconPath = ''; 2192 2193 // Check if we need to add a new month/year header 2194 if (currentLinkYear !== year || currentLinkMonth !== month) { 2195 currentLinkYear = year; 2196 currentLinkMonth = month; 2197 2198 linksHTML += ` 2199 <div class="month-year-header" data-year="${year}" data-month="${month}"> 2200 <div class="month-year-label">${monthNames[month]} ${year}</div> 2201 </div>`; 2202 } 2203 2204 // Platform-specific display logic (same as in the main feed) 2205 if (url.hostname === 'github.com' || url.hostname === 'gist.github.com') { 2206 const parts = url.pathname.substring(1).split('/').filter(part => part); 2207 if (parts.length >= 2) { 2208 displayText = `${parts[0]}/${parts[1]}`; 2209 iconPath = 'brands-github.svg'; 2210 } 2211 } else if (url.hostname === 'en.wikipedia.org' || url.hostname === 'wikipedia.org' || url.hostname.endsWith('.wikipedia.org')) { 2212 const titlePart = url.pathname.split('/').pop(); 2213 if (titlePart) { 2214 displayText = decodeURIComponent(titlePart).replace(/_/g, ' '); 2215 iconPath = 'brands-wikipedia-w.svg'; 2216 } 2217 } else if (url.hostname === 'twitter.com' || url.hostname === 'x.com') { 2218 const parts = url.pathname.substring(1).split('/').filter(part => part); 2219 if (parts.length >= 1) { 2220 displayText = `@${parts[0]}`; 2221 iconPath = 'brands-x-twitter.svg'; 2222 } 2223 } else if (url.hostname === 'linkedin.com' || url.hostname.includes('linkedin.com')) { 2224 iconPath = 'brands-linkedin.svg'; 2225 displayText = 'LinkedIn'; 2226 const parts = url.pathname.substring(1).split('/').filter(part => part); 2227 if (parts.length >= 2 && parts[0] === 'in') { 2228 displayText = parts[1]; 2229 } 2230 } else if (url.hostname.includes('youtube.com') || url.hostname === 'youtu.be') { 2231 iconPath = 'brands-youtube.svg'; 2232 displayText = 'YouTube Video'; 2233 } else if (url.hostname === 'ocaml.org' && url.pathname.startsWith('/p/')) { 2234 const parts = url.pathname.substring(1).split('/').filter(part => part); 2235 if (parts.length >= 2) { 2236 const packageName = parts[1]; 2237 displayText = `${packageName} (OCaml)`; 2238 } 2239 } else if (url.hostname.includes('medium.com')) { 2240 iconPath = 'brands-medium.svg'; 2241 displayText = 'Medium'; 2242 const parts = url.pathname.substring(1).split('/').filter(part => part); 2243 if (parts.length >= 1) { 2244 displayText = parts[0]; 2245 } 2246 } else if (url.hostname.includes('stackoverflow.com')) { 2247 iconPath = 'brands-stack-overflow.svg'; 2248 displayText = 'Stack Overflow'; 2249 if (url.pathname.includes('questions')) { 2250 const parts = url.pathname.split('/'); 2251 const questionId = parts.find(part => /^\d+$/.test(part)); 2252 if (questionId) { 2253 displayText = `Q${questionId}`; 2254 } 2255 } 2256 } else if (url.hostname === 'dev.to') { 2257 iconPath = 'brands-dev.svg'; 2258 displayText = 'DEV'; 2259 const parts = url.pathname.substring(1).split('/').filter(part => part); 2260 if (parts.length >= 1) { 2261 displayText = parts[0]; 2262 } 2263 } else if (url.hostname.includes('reddit.com')) { 2264 iconPath = 'brands-reddit.svg'; 2265 displayText = 'Reddit'; 2266 if (url.pathname.includes('/r/')) { 2267 const parts = url.pathname.split('/'); 2268 const subreddit = parts.find((part, index) => index > 0 && parts[index-1] === 'r'); 2269 if (subreddit) { 2270 displayText = `r/${subreddit}`; 2271 } 2272 } 2273 } else if (url.hostname.includes('news.ycombinator.com') || url.hostname.includes('hackernews.com')) { 2274 iconPath = 'brands-hacker-news.svg'; 2275 displayText = 'Hacker News'; 2276 if (url.pathname.includes('item')) { 2277 const itemId = url.searchParams.get('id'); 2278 if (itemId) { 2279 displayText = `HN:${itemId}`; 2280 } 2281 } 2282 } else if (url.hostname === 'bsky.app' || url.hostname === 'bsky.social') { 2283 iconPath = 'brands-bluesky.svg'; 2284 displayText = 'Bluesky'; 2285 const parts = url.pathname.substring(1).split('/').filter(part => part); 2286 if (parts.length >= 1) { 2287 if (parts[0] === 'profile' && parts.length >= 2) { 2288 displayText = `@${parts[1]}`; 2289 } else if (parts[0] === 'post') { 2290 displayText = 'Post'; 2291 } else { 2292 displayText = `@${parts[0]}`; 2293 } 2294 } 2295 } 2296 2297 // Function to get day with ordinal suffix (reused) 2298 function getLinkDayWithOrdinal(date) { 2299 const day = date.getDate(); 2300 let suffix = "th"; 2301 if (day % 10 === 1 && day !== 11) { 2302 suffix = "st"; 2303 } else if (day % 10 === 2 && day !== 12) { 2304 suffix = "nd"; 2305 } else if (day % 10 === 3 && day !== 13) { 2306 suffix = "rd"; 2307 } 2308 return day + suffix; 2309 } 2310 2311 // Create link item HTML 2312 linksHTML += ` 2313 <div class="link-item" data-year="${date.getFullYear()}" data-month="${date.getMonth()}"> 2314 <div class="link-item-date">${getLinkDayWithOrdinal(date)} ${shortMonthNames[date.getMonth()]} ${date.getFullYear()}</div> 2315 <div class="link-item-source" title="From: ${link.sourceTitle}"> 2316 <a href="${link.sourceLink}" target="_blank" style="color: inherit; text-decoration: none;"> 2317 ${link.source} 2318 </a> 2319 </div> 2320 <div class="link-item-content"> 2321 <div class="link-item-url-container"> 2322 <a href="${link.url}" class="link-item-url" target="_blank"> 2323 ${iconPath ? `<img src="${iconPath}" class="link-item-icon" alt="">` : ''} 2324 ${displayText} 2325 <span class="link-item-path">${url.pathname.length > 30 ? url.pathname.substring(0, 30) + '...' : url.pathname}</span> 2326 </a> 2327 <a href="${link.sourceLink}" class="link-source-reference" title="${link.sourceTitle}" target="_blank"> 2328 <span class="link-source-icon">↗</span> ${link.sourceTitle} 2329 </a> 2330 </div> 2331 </div> 2332 </div> 2333 `; 2334 }); 2335 2336 // Update the links container 2337 linksContainer.innerHTML = linksHTML; 2338 2339 // Month headers in links view are now all visible 2340 2341 // If we have links, set the most recent link's date as active in the timeline for the links tab 2342 if (dedupedLinks.length > 0) { 2343 const mostRecentLink = dedupedLinks[0]; 2344 const linkDate = mostRecentLink.date; 2345 const linkYear = linkDate.getFullYear(); 2346 const linkMonth = linkDate.getMonth(); 2347 2348 // Add a flag to remember we've set a most recent link 2349 window.mostRecentLinkSet = { 2350 year: linkYear, 2351 month: linkMonth 2352 }; 2353 } 2354 2355 // Process people data 2356 const peopleContainer = document.querySelector('.people-container'); 2357 const peopleMap = new Map(); // Map to store people data 2358 2359 // Fetch the mapping.json file to get author information 2360 const mappingResponse = await fetch('mapping.json'); 2361 if (!mappingResponse.ok) { 2362 throw new Error('Failed to fetch mapping data'); 2363 } 2364 const mappingData = await mappingResponse.json(); 2365 2366 // Process author information from mapping data 2367 Object.entries(mappingData).forEach(([feedUrl, info]) => { 2368 const { name, site } = info; 2369 if (!peopleMap.has(name)) { 2370 peopleMap.set(name, { 2371 name: name, 2372 site: site, 2373 feedUrl: feedUrl, 2374 posts: [], 2375 postCount: 0, 2376 mostRecent: null 2377 }); 2378 } 2379 }); 2380 2381 // Associate entries with authors 2382 entriesArray.forEach(entry => { 2383 // Find the person who matches this entry's author 2384 // (taking into account potential differences in formatting) 2385 const person = Array.from(peopleMap.values()).find(p => 2386 p.name === entry.author || 2387 entry.author.includes(p.name) || 2388 p.name.includes(entry.author) 2389 ); 2390 2391 if (person) { 2392 person.posts.push(entry); 2393 person.postCount++; 2394 2395 // Track most recent post date 2396 const entryDate = new Date(entry.published); 2397 if (!person.mostRecent || entryDate > new Date(person.mostRecent.published)) { 2398 person.mostRecent = entry; 2399 } 2400 } 2401 }); 2402 2403 // Generate HTML for people cards 2404 let peopleHTML = ''; 2405 Array.from(peopleMap.values()) 2406 .sort((a, b) => b.postCount - a.postCount) // Sort by post count 2407 .forEach(person => { 2408 const recentPosts = person.posts 2409 .sort((a, b) => new Date(b.published) - new Date(a.published)) 2410 .slice(0, 3); // Get top 3 most recent posts 2411 2412 peopleHTML += ` 2413 <div class="person-card"> 2414 <div class="person-name">${person.name}</div> 2415 <div class="person-site"><a href="${person.feedUrl}" target="_blank" rel="noopener">${person.site}</a></div> 2416 2417 <div class="person-stats"> 2418 <div class="person-stat"> 2419 <div class="stat-value">${person.postCount}</div> 2420 <div class="stat-label">Posts</div> 2421 </div> 2422 <div class="person-stat"> 2423 <div class="stat-value">${person.mostRecent ? formatDate(person.mostRecent.published) : 'N/A'}</div> 2424 <div class="stat-label">Latest</div> 2425 </div> 2426 </div> 2427 2428 ${recentPosts.length > 0 ? ` 2429 <div class="person-recent"> 2430 <div class="recent-title">RECENT POSTS</div> 2431 <div class="recent-posts"> 2432 ${recentPosts.map(post => ` 2433 <div class="recent-post"> 2434 <a href="${post.link}" target="_blank">${post.title}</a> 2435 <div class="recent-post-date">${formatDate(post.published)}</div> 2436 </div> 2437 `).join('')} 2438 </div> 2439 </div> 2440 ` : ''} 2441 </div> 2442 `; 2443 }); 2444 2445 peopleContainer.innerHTML = peopleHTML; 2446 2447 // Initialize tabs 2448 setupTabs(); 2449 2450 // Make timeline items clickable to scroll to relevant posts or links 2451 document.querySelectorAll('.timeline-year, .timeline-month').forEach(item => { 2452 item.addEventListener('click', () => { 2453 const year = item.getAttribute('data-year'); 2454 const month = item.getAttribute('data-month'); 2455 2456 // Store the selected date globally 2457 lastActiveYear = year; 2458 if (month !== null && month !== undefined) { 2459 lastActiveMonth = month; 2460 } 2461 2462 2463 // Find the first element with this date 2464 let selector = `[data-year="${year}"]`; 2465 if (month !== null && month !== undefined) { 2466 selector += `[data-month="${month}"]`; 2467 } 2468 2469 // Get the active tab 2470 const activeTab = document.querySelector('.tab-content.active'); 2471 const activeTabId = activeTab.getAttribute('data-tab'); 2472 2473 // Look for the target within the active tab 2474 const targetItem = activeTab.querySelector(selector); 2475 2476 // If no matching items in this tab or people tab is active, do nothing 2477 if (targetItem && activeTabId !== 'people') { 2478 targetItem.scrollIntoView({ behavior: 'smooth', block: 'start' }); 2479 2480 // Highlight the selected timeline period 2481 document.querySelectorAll('.timeline-year.active, .timeline-month.active').forEach(el => { 2482 el.classList.remove('active'); 2483 }); 2484 2485 // Set active classes 2486 const yearEl = document.querySelector(`.timeline-year[data-year="${year}"]`); 2487 const monthEl = month !== null && month !== undefined ? 2488 document.querySelector(`.timeline-month[data-year="${year}"][data-month="${month}"]`) : null; 2489 2490 if (yearEl) yearEl.classList.add('active'); 2491 if (monthEl) monthEl.classList.add('active'); 2492 2493 // Month headers are now simple inline elements, no need to toggle visibility 2494 } 2495 }); 2496 }); 2497 2498 } catch (error) { 2499 console.error('Error loading feed:', error); 2500 loadingContainer.style.display = 'none'; 2501 feedItemsContainer.innerHTML = ` 2502 <div class="error-message"> 2503 <h3>Error Loading Feed</h3> 2504 <p>${error.message}</p> 2505 </div> 2506 `; 2507 } 2508 }); 2509 </script> 2510</body> 2511</html>