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