Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 1035 lines 28 kB view raw
1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta 7 http-equiv="Content-Security-Policy" 8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://quickslice-production-ddc3.up.railway.app; img-src 'self' https: data:;" 9 /> 10 <title>Tangled Repos</title> 11 <style> 12 /* CSS Reset */ 13 *, 14 *::before, 15 *::after { 16 box-sizing: border-box; 17 } 18 * { 19 margin: 0; 20 } 21 body { 22 line-height: 1.5; 23 -webkit-font-smoothing: antialiased; 24 } 25 input, 26 button { 27 font: inherit; 28 } 29 30 /* Catppuccin Latte (Light) */ 31 :root { 32 --bg-base: #eff1f5; 33 --bg-mantle: #e6e9ef; 34 --bg-surface0: #ccd0da; 35 --bg-surface1: #bcc0cc; 36 --text-primary: #4c4f69; 37 --text-secondary: #6c6f85; 38 --text-subtext: #7c7f93; 39 --accent: #1e66f5; 40 --accent-hover: #2a6ff7; 41 --border: #ccd0da; 42 --error-bg: #fce4e6; 43 --error-border: #e64553; 44 --error-text: #d20f39; 45 --star-color: #df8e1d; 46 --topic-bg: #dce0e8; 47 --topic-text: #5c5f77; 48 } 49 50 /* Catppuccin Mocha (Dark) */ 51 @media (prefers-color-scheme: dark) { 52 :root { 53 --bg-base: #1e1e2e; 54 --bg-mantle: #181825; 55 --bg-surface0: #313244; 56 --bg-surface1: #45475a; 57 --text-primary: #cdd6f4; 58 --text-secondary: #a6adc8; 59 --text-subtext: #bac2de; 60 --accent: #89b4fa; 61 --accent-hover: #9cc4fc; 62 --border: #313244; 63 --error-bg: #45293b; 64 --error-border: #f38ba8; 65 --error-text: #f38ba8; 66 --star-color: #f9e2af; 67 --topic-bg: #313244; 68 --topic-text: #bac2de; 69 } 70 } 71 72 body { 73 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 74 background: var(--bg-base); 75 color: var(--text-primary); 76 min-height: 100vh; 77 padding: 2rem 1rem; 78 } 79 80 #app { 81 max-width: 700px; 82 margin: 0 auto; 83 } 84 85 /* Header */ 86 header { 87 text-align: center; 88 margin-bottom: 1.5rem; 89 } 90 91 header h1 { 92 font-size: 2rem; 93 color: var(--accent); 94 margin-bottom: 0.25rem; 95 } 96 97 .tagline { 98 color: var(--text-secondary); 99 font-size: 0.875rem; 100 } 101 102 /* Search */ 103 .search-container { 104 position: relative; 105 margin-bottom: 1rem; 106 } 107 108 #search-input { 109 width: 100%; 110 padding: 0.75rem 2.5rem 0.75rem 1rem; 111 border: 1px solid var(--border); 112 border-radius: 0.5rem; 113 background: var(--bg-mantle); 114 color: var(--text-primary); 115 font-size: 1rem; 116 } 117 118 #search-input::placeholder { 119 color: var(--text-secondary); 120 } 121 122 #search-input:focus { 123 outline: none; 124 border-color: var(--accent); 125 } 126 127 #clear-search { 128 position: absolute; 129 right: 0.5rem; 130 top: 50%; 131 transform: translateY(-50%); 132 background: none; 133 border: none; 134 color: var(--text-secondary); 135 font-size: 1.25rem; 136 cursor: pointer; 137 padding: 0.25rem 0.5rem; 138 line-height: 1; 139 } 140 141 #clear-search:hover { 142 color: var(--text-primary); 143 } 144 145 #result-count { 146 color: var(--text-secondary); 147 font-size: 0.875rem; 148 margin-bottom: 1rem; 149 min-height: 1.25rem; 150 } 151 152 /* Cards */ 153 .card { 154 background: var(--bg-mantle); 155 border-radius: 0.5rem; 156 padding: 1rem; 157 margin-bottom: 0.75rem; 158 border: 1px solid var(--border); 159 } 160 161 .card:hover { 162 border-color: var(--bg-surface1); 163 } 164 165 /* Repo Card Header */ 166 .repo-header { 167 display: flex; 168 align-items: center; 169 gap: 0.75rem; 170 margin-bottom: 0.75rem; 171 } 172 173 .repo-avatar { 174 width: 36px; 175 height: 36px; 176 border-radius: 50%; 177 background: #f8b4d9; 178 overflow: hidden; 179 flex-shrink: 0; 180 } 181 182 .repo-avatar img { 183 width: 100%; 184 height: 100%; 185 object-fit: cover; 186 } 187 188 .repo-meta { 189 flex: 1; 190 min-width: 0; 191 } 192 193 .repo-owner { 194 color: var(--accent); 195 text-decoration: none; 196 font-weight: 500; 197 font-size: 0.875rem; 198 } 199 200 .repo-owner:hover { 201 text-decoration: underline; 202 } 203 204 .repo-time { 205 color: var(--text-secondary); 206 font-size: 0.75rem; 207 } 208 209 /* Repo Name & Stars */ 210 .repo-title-row { 211 display: flex; 212 justify-content: space-between; 213 align-items: baseline; 214 gap: 0.5rem; 215 margin-bottom: 0.5rem; 216 } 217 218 .repo-name { 219 font-size: 1.125rem; 220 font-weight: 600; 221 color: var(--text-primary); 222 text-decoration: none; 223 min-width: 0; 224 overflow: hidden; 225 text-overflow: ellipsis; 226 white-space: nowrap; 227 } 228 229 .repo-name:hover { 230 color: var(--accent); 231 } 232 233 .repo-stars { 234 color: var(--star-color); 235 font-size: 0.875rem; 236 flex-shrink: 0; 237 display: flex; 238 align-items: center; 239 gap: 0.25rem; 240 } 241 242 /* Description */ 243 .repo-description { 244 color: var(--text-secondary); 245 font-size: 0.875rem; 246 margin-bottom: 0.5rem; 247 line-height: 1.4; 248 } 249 250 /* Topics */ 251 .repo-topics { 252 display: flex; 253 flex-wrap: wrap; 254 gap: 0.375rem; 255 margin-bottom: 0.5rem; 256 } 257 258 .topic-tag { 259 background: var(--topic-bg); 260 color: var(--topic-text); 261 font-size: 0.75rem; 262 padding: 0.125rem 0.5rem; 263 border-radius: 1rem; 264 } 265 266 /* Trending Section */ 267 .trending-section { 268 margin-bottom: 1.5rem; 269 } 270 271 .trending-header { 272 display: flex; 273 align-items: center; 274 gap: 0.5rem; 275 margin-bottom: 0.75rem; 276 color: var(--text-secondary); 277 font-size: 0.875rem; 278 font-weight: 500; 279 } 280 281 .trending-header svg { 282 width: 16px; 283 height: 16px; 284 } 285 286 .trending-scroll { 287 display: flex; 288 gap: 0.75rem; 289 overflow-x: auto; 290 padding-bottom: 0.5rem; 291 scrollbar-width: thin; 292 scrollbar-color: var(--bg-surface1) transparent; 293 } 294 295 .trending-scroll::-webkit-scrollbar { 296 height: 6px; 297 } 298 299 .trending-scroll::-webkit-scrollbar-track { 300 background: transparent; 301 } 302 303 .trending-scroll::-webkit-scrollbar-thumb { 304 background: var(--bg-surface1); 305 border-radius: 3px; 306 } 307 308 .trending-card { 309 flex: 0 0 auto; 310 width: 200px; 311 background: var(--bg-mantle); 312 border: 1px solid var(--border); 313 border-radius: 0.5rem; 314 padding: 0.75rem; 315 text-decoration: none; 316 transition: border-color 0.15s; 317 } 318 319 .trending-card:hover { 320 border-color: var(--accent); 321 } 322 323 .trending-card-header { 324 display: flex; 325 align-items: center; 326 gap: 0.5rem; 327 margin-bottom: 0.5rem; 328 } 329 330 .trending-avatar { 331 width: 24px; 332 height: 24px; 333 border-radius: 50%; 334 background: #f8b4d9; 335 overflow: hidden; 336 flex-shrink: 0; 337 } 338 339 .trending-avatar img { 340 width: 100%; 341 height: 100%; 342 object-fit: cover; 343 } 344 345 .trending-owner { 346 color: var(--text-secondary); 347 font-size: 0.75rem; 348 overflow: hidden; 349 text-overflow: ellipsis; 350 white-space: nowrap; 351 } 352 353 .trending-name { 354 color: var(--text-primary); 355 font-weight: 600; 356 font-size: 0.875rem; 357 margin-bottom: 0.25rem; 358 overflow: hidden; 359 text-overflow: ellipsis; 360 white-space: nowrap; 361 } 362 363 .trending-stats { 364 display: flex; 365 align-items: center; 366 gap: 0.25rem; 367 color: var(--star-color); 368 font-size: 0.75rem; 369 } 370 371 .trending-new { 372 color: var(--accent); 373 font-size: 0.625rem; 374 margin-left: 0.25rem; 375 } 376 377 /* Footer Links */ 378 .repo-footer { 379 display: flex; 380 gap: 1rem; 381 padding-top: 0.5rem; 382 border-top: 1px solid var(--border); 383 margin-top: 0.5rem; 384 } 385 386 .repo-link { 387 color: var(--text-secondary); 388 text-decoration: none; 389 font-size: 0.75rem; 390 } 391 392 .repo-link:hover { 393 color: var(--accent); 394 } 395 396 /* Status Messages */ 397 .status-msg { 398 text-align: center; 399 color: var(--text-secondary); 400 padding: 2rem; 401 } 402 403 .load-more { 404 text-align: center; 405 padding: 1rem; 406 } 407 408 /* Buttons */ 409 .btn { 410 padding: 0.75rem 1.5rem; 411 border: none; 412 border-radius: 0.5rem; 413 font-size: 0.875rem; 414 font-weight: 500; 415 cursor: pointer; 416 transition: 417 background-color 0.15s, 418 opacity 0.15s; 419 } 420 421 .btn-primary { 422 background: var(--accent); 423 color: var(--bg-base); 424 } 425 426 .btn-primary:hover { 427 background: var(--accent-hover); 428 } 429 430 .btn-primary:disabled { 431 opacity: 0.5; 432 cursor: not-allowed; 433 } 434 435 /* Error Banner */ 436 #error-banner { 437 position: fixed; 438 top: 1rem; 439 left: 50%; 440 transform: translateX(-50%); 441 background: var(--error-bg); 442 border: 1px solid var(--error-border); 443 color: var(--error-text); 444 padding: 0.75rem 1rem; 445 border-radius: 0.5rem; 446 display: flex; 447 align-items: center; 448 gap: 0.75rem; 449 max-width: 90%; 450 z-index: 100; 451 } 452 453 #error-banner.hidden { 454 display: none; 455 } 456 457 #error-banner button { 458 background: none; 459 border: none; 460 color: var(--error-text); 461 cursor: pointer; 462 font-size: 1.25rem; 463 line-height: 1; 464 } 465 466 .hidden { 467 display: none !important; 468 } 469 470 /* Spinner */ 471 .spinner { 472 width: 32px; 473 height: 32px; 474 border: 3px solid var(--border); 475 border-top-color: var(--accent); 476 border-radius: 50%; 477 animation: spin 0.8s linear infinite; 478 margin: 0 auto; 479 } 480 481 @keyframes spin { 482 to { 483 transform: rotate(360deg); 484 } 485 } 486 487 .loading-container { 488 display: flex; 489 flex-direction: column; 490 align-items: center; 491 gap: 0.75rem; 492 padding: 2rem; 493 color: var(--text-secondary); 494 } 495 </style> 496 </head> 497 <body> 498 <div id="app"> 499 <header> 500 <h1>Tangled Repos</h1> 501 <p class="tagline">Browse repositories from the Atmosphere</p> 502 </header> 503 <div class="search-container"> 504 <input 505 type="text" 506 id="search-input" 507 placeholder="Search... (@user, repo:name, topic:rust)" 508 /> 509 <button id="clear-search" class="hidden" title="Clear search">&times;</button> 510 </div> 511 <div id="trending-section" class="trending-section hidden"> 512 <div class="trending-header"> 513 <svg 514 viewBox="0 0 24 24" 515 fill="none" 516 stroke="currentColor" 517 stroke-width="2" 518 stroke-linecap="round" 519 stroke-linejoin="round" 520 > 521 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline> 522 <polyline points="17 6 23 6 23 12"></polyline> 523 </svg> 524 <span>Trending this week</span> 525 </div> 526 <div id="trending-scroll" class="trending-scroll"></div> 527 </div> 528 <div id="result-count"></div> 529 <main> 530 <div id="repo-feed"></div> 531 <div id="load-more"></div> 532 </main> 533 <div id="error-banner" class="hidden"></div> 534 </div> 535 536 <!-- Quickslice Client SDK --> 537 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 538 539 <script> 540 // ============================================================================= 541 // CONFIGURATION 542 // ============================================================================= 543 544 const SERVER_URL = "https://quickslice-production-ddc3.up.railway.app"; 545 const PAGE_SIZE = 20; 546 const DEBOUNCE_MS = 300; 547 548 // ============================================================================= 549 // STATE 550 // ============================================================================= 551 552 const state = { 553 repos: [], 554 cursor: null, 555 hasMore: true, 556 isLoading: false, 557 searchQuery: "", 558 totalCount: 0, 559 trending: [], 560 }; 561 562 // ============================================================================= 563 // GRAPHQL 564 // ============================================================================= 565 566 const REPOS_QUERY = ` 567 query GetRepos($first: Int!, $after: String, $where: ShTangledRepoWhereInput) { 568 shTangledRepo( 569 first: $first 570 after: $after 571 sortBy: [{ field: createdAt, direction: DESC }] 572 where: $where 573 ) { 574 totalCount 575 edges { 576 node { 577 uri 578 name 579 description 580 knot 581 topics 582 website 583 actorHandle 584 createdAt 585 appBskyActorProfileByDid { 586 displayName 587 avatar { url(preset: "avatar") } 588 } 589 shTangledFeedStarViaSubject { 590 totalCount 591 } 592 } 593 } 594 pageInfo { 595 hasNextPage 596 endCursor 597 } 598 } 599 } 600`; 601 602 const TRENDING_QUERY = ` 603 query TrendingStars($since: String!) { 604 shTangledFeedStar( 605 where: { createdAt: { gte: $since } } 606 first: 100 607 sortBy: [{ field: createdAt, direction: DESC }] 608 ) { 609 edges { 610 node { 611 subject 612 subjectResolved { 613 ... on ShTangledRepo { 614 uri 615 name 616 actorHandle 617 appBskyActorProfileByDid { 618 displayName 619 avatar { url(preset: "avatar") } 620 } 621 } 622 } 623 } 624 } 625 } 626 } 627`; 628 629 // ============================================================================= 630 // DATA FETCHING 631 // ============================================================================= 632 633 function buildWhereClause(query) { 634 if (!query || !query.trim()) return null; 635 const q = query.trim(); 636 637 // @handle syntax - search actorHandle only 638 if (q.startsWith("@")) { 639 const handle = q.slice(1); 640 if (!handle) return null; 641 return { actorHandle: { contains: handle } }; 642 } 643 644 // repo:name syntax - search repo name only 645 if (q.startsWith("repo:")) { 646 const name = q.slice(5); 647 if (!name) return null; 648 return { name: { contains: name } }; 649 } 650 651 // topic:name syntax - search by topic 652 if (q.startsWith("topic:")) { 653 const topic = q.slice(6); 654 if (!topic) return null; 655 return { topics: { contains: topic } }; 656 } 657 658 // Plain text - search all fields 659 return { 660 or: [ 661 { name: { contains: q } }, 662 { description: { contains: q } }, 663 { actorHandle: { contains: q } }, 664 ], 665 }; 666 } 667 668 async function fetchRepos(cursor = null, searchQuery = "") { 669 const variables = { 670 first: PAGE_SIZE, 671 after: cursor, 672 where: buildWhereClause(searchQuery), 673 }; 674 675 const res = await fetch(`${SERVER_URL}/graphql`, { 676 method: "POST", 677 headers: { "Content-Type": "application/json" }, 678 body: JSON.stringify({ query: REPOS_QUERY, variables }), 679 }); 680 681 if (!res.ok) throw new Error(`HTTP ${res.status}`); 682 683 const json = await res.json(); 684 if (json.errors) throw new Error(json.errors[0].message); 685 686 return json.data.shTangledRepo; 687 } 688 689 async function fetchTrendingRepos() { 690 // Get date 7 days ago 691 const since = new Date(); 692 since.setDate(since.getDate() - 7); 693 const sinceISO = since.toISOString(); 694 695 const res = await fetch(`${SERVER_URL}/graphql`, { 696 method: "POST", 697 headers: { "Content-Type": "application/json" }, 698 body: JSON.stringify({ 699 query: TRENDING_QUERY, 700 variables: { since: sinceISO }, 701 }), 702 }); 703 704 if (!res.ok) throw new Error(`HTTP ${res.status}`); 705 706 const json = await res.json(); 707 if (json.errors) throw new Error(json.errors[0].message); 708 709 // Count stars per repo and dedupe 710 const starCounts = new Map(); 711 const repoData = new Map(); 712 713 for (const edge of json.data.shTangledFeedStar.edges) { 714 const star = edge.node; 715 if (!star.subjectResolved || !star.subjectResolved.uri) continue; 716 717 const uri = star.subjectResolved.uri; 718 starCounts.set(uri, (starCounts.get(uri) || 0) + 1); 719 720 if (!repoData.has(uri)) { 721 repoData.set(uri, star.subjectResolved); 722 } 723 } 724 725 // Sort by star count and return top 10 726 const sorted = [...starCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); 727 728 return sorted.map(([uri, count]) => ({ 729 ...repoData.get(uri), 730 weeklyStars: count, 731 })); 732 } 733 734 // ============================================================================= 735 // HELPERS 736 // ============================================================================= 737 738 function showError(msg) { 739 const el = document.getElementById("error-banner"); 740 el.innerHTML = `<span>${esc(msg)}</span><button onclick="hideError()">×</button>`; 741 el.classList.remove("hidden"); 742 } 743 744 function hideError() { 745 document.getElementById("error-banner").classList.add("hidden"); 746 } 747 748 function esc(str) { 749 if (!str) return ""; 750 const d = document.createElement("div"); 751 d.textContent = str; 752 return d.innerHTML; 753 } 754 755 function formatTime(iso) { 756 const d = new Date(iso); 757 const now = new Date(); 758 const diff = Math.floor((now - d) / 1000); 759 760 if (diff < 60) return "just now"; 761 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 762 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 763 if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; 764 765 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 766 } 767 768 function debounce(fn, ms) { 769 let timeout; 770 return (...args) => { 771 clearTimeout(timeout); 772 timeout = setTimeout(() => fn(...args), ms); 773 }; 774 } 775 776 // ============================================================================= 777 // RENDERING 778 // ============================================================================= 779 780 function renderRepoCard(repo) { 781 const profile = repo.appBskyActorProfileByDid; 782 const handle = repo.actorHandle || "unknown"; 783 const avatar = profile?.avatar?.url || ""; 784 const displayName = profile?.displayName || handle; 785 const stars = repo.shTangledFeedStarViaSubject?.totalCount || 0; 786 const topics = repo.topics || []; 787 const tangledUrl = `https://tangled.org/${handle}/${repo.name}`; 788 789 let topicsHtml = ""; 790 if (topics.length > 0) { 791 topicsHtml = ` 792 <div class="repo-topics"> 793 ${topics.map((t) => `<span class="topic-tag">${esc(t)}</span>`).join("")} 794 </div> 795 `; 796 } 797 798 let footerLinks = `<a href="${esc(tangledUrl)}" target="_blank" class="repo-link">View on Tangled →</a>`; 799 if (repo.website) { 800 const websiteDisplay = repo.website.replace(/^https?:\/\//, "").replace(/\/$/, ""); 801 footerLinks = 802 `<a href="${esc(repo.website)}" target="_blank" class="repo-link">${esc(websiteDisplay)}</a>` + 803 footerLinks; 804 } 805 806 return ` 807 <div class="card" data-uri="${esc(repo.uri)}"> 808 <div class="repo-header"> 809 <div class="repo-avatar"> 810 ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""} 811 </div> 812 <div class="repo-meta"> 813 <a href="https://bsky.app/profile/${esc(handle)}" target="_blank" class="repo-owner">@${esc(handle)}</a> 814 <div class="repo-time">${formatTime(repo.createdAt)}</div> 815 </div> 816 </div> 817 <div class="repo-title-row"> 818 <a href="${esc(tangledUrl)}" target="_blank" class="repo-name">${esc(repo.name)}</a> 819 ${stars > 0 ? `<span class="repo-stars">★ ${stars}</span>` : ""} 820 </div> 821 ${repo.description ? `<div class="repo-description">${esc(repo.description)}</div>` : ""} 822 ${topicsHtml} 823 <div class="repo-footer"> 824 ${footerLinks} 825 </div> 826 </div> 827 `; 828 } 829 830 function renderFeed() { 831 const el = document.getElementById("repo-feed"); 832 833 if (state.isLoading && state.repos.length === 0) { 834 el.innerHTML = `<div class="loading-container"><div class="spinner"></div><span>Loading repos...</span></div>`; 835 return; 836 } 837 838 if (state.repos.length === 0) { 839 const msg = state.searchQuery 840 ? `No repos found for "${esc(state.searchQuery)}"` 841 : "No repos yet."; 842 el.innerHTML = `<div class="status-msg">${msg}</div>`; 843 return; 844 } 845 846 el.innerHTML = state.repos.map((r) => renderRepoCard(r)).join(""); 847 } 848 849 function renderResultCount() { 850 const el = document.getElementById("result-count"); 851 if (state.searchQuery && state.repos.length > 0) { 852 el.textContent = `${state.totalCount} results for "${state.searchQuery}"`; 853 } else if (!state.searchQuery && state.totalCount > 0) { 854 el.textContent = `${state.totalCount} repos`; 855 } else { 856 el.textContent = ""; 857 } 858 } 859 860 function renderLoadMore() { 861 const el = document.getElementById("load-more"); 862 863 if (state.repos.length === 0) { 864 el.innerHTML = ""; 865 return; 866 } 867 868 if (!state.hasMore) { 869 el.innerHTML = `<div class="status-msg">No more repos</div>`; 870 return; 871 } 872 873 el.innerHTML = ` 874 <div class="load-more"> 875 <button class="btn btn-primary" onclick="handleLoadMore()" ${state.isLoading ? "disabled" : ""}> 876 ${state.isLoading ? "Loading..." : "Load More"} 877 </button> 878 </div> 879 `; 880 } 881 882 function renderTrendingCard(repo) { 883 const profile = repo.appBskyActorProfileByDid; 884 const handle = repo.actorHandle || "unknown"; 885 const avatar = profile?.avatar?.url || ""; 886 const tangledUrl = `https://tangled.org/${handle}/${repo.name}`; 887 888 return ` 889 <a href="${esc(tangledUrl)}" target="_blank" class="trending-card"> 890 <div class="trending-card-header"> 891 <div class="trending-avatar"> 892 ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""} 893 </div> 894 <span class="trending-owner">@${esc(handle)}</span> 895 </div> 896 <div class="trending-name">${esc(repo.name)}</div> 897 <div class="trending-stats"> 898 <span>★ ${repo.weeklyStars}</span> 899 <span class="trending-new">this week</span> 900 </div> 901 </a> 902 `; 903 } 904 905 function renderTrending() { 906 const section = document.getElementById("trending-section"); 907 const scroll = document.getElementById("trending-scroll"); 908 909 if (state.trending.length === 0 || state.searchQuery) { 910 section.classList.add("hidden"); 911 return; 912 } 913 914 section.classList.remove("hidden"); 915 scroll.innerHTML = state.trending.map((r) => renderTrendingCard(r)).join(""); 916 } 917 918 // ============================================================================= 919 // ACTIONS 920 // ============================================================================= 921 922 async function loadRepos(append = false) { 923 if (state.isLoading) return; 924 state.isLoading = true; 925 renderFeed(); 926 renderLoadMore(); 927 928 try { 929 const data = await fetchRepos(append ? state.cursor : null, state.searchQuery); 930 const newRepos = data.edges.map((e) => e.node); 931 932 state.repos = append ? [...state.repos, ...newRepos] : newRepos; 933 state.cursor = data.pageInfo.endCursor; 934 state.hasMore = data.pageInfo.hasNextPage; 935 state.totalCount = data.totalCount; 936 renderResultCount(); 937 } catch (err) { 938 console.error("Load failed:", err); 939 showError(`Failed to load: ${err.message}`); 940 } finally { 941 state.isLoading = false; 942 renderFeed(); 943 renderLoadMore(); 944 } 945 } 946 947 async function loadTrending() { 948 try { 949 state.trending = await fetchTrendingRepos(); 950 renderTrending(); 951 } catch (err) { 952 console.error("Trending load failed:", err); 953 // Silently fail - trending is optional 954 } 955 } 956 957 function handleLoadMore() { 958 loadRepos(true); 959 } 960 961 function handleSearch(query) { 962 state.searchQuery = query; 963 state.cursor = null; 964 state.repos = []; 965 state.hasMore = true; 966 renderTrending(); // Hide/show trending based on search 967 loadRepos(); 968 } 969 970 const debouncedSearch = debounce(handleSearch, DEBOUNCE_MS); 971 972 function clearSearch() { 973 const input = document.getElementById("search-input"); 974 input.value = ""; 975 document.getElementById("clear-search").classList.add("hidden"); 976 handleSearch(""); 977 } 978 979 // ============================================================================= 980 // MAIN 981 // ============================================================================= 982 983 async function main() { 984 // Set up search input 985 const searchInput = document.getElementById("search-input"); 986 const clearBtn = document.getElementById("clear-search"); 987 988 searchInput.addEventListener("input", (e) => { 989 const value = e.target.value; 990 clearBtn.classList.toggle("hidden", !value); 991 debouncedSearch(value); 992 }); 993 994 clearBtn.addEventListener("click", clearSearch); 995 996 // Show loading state 997 document.getElementById("repo-feed").innerHTML = 998 `<div class="loading-container"><div class="spinner"></div><span>Loading repos...</span></div>`; 999 1000 // Load both in parallel 1001 const [reposResult, trendingResult] = await Promise.allSettled([ 1002 fetchRepos(null, ""), 1003 fetchTrendingRepos(), 1004 ]); 1005 1006 // Process repos 1007 if (reposResult.status === "fulfilled") { 1008 const data = reposResult.value; 1009 state.repos = data.edges.map((e) => e.node); 1010 state.cursor = data.pageInfo.endCursor; 1011 state.hasMore = data.pageInfo.hasNextPage; 1012 state.totalCount = data.totalCount; 1013 } else { 1014 console.error("Repos load failed:", reposResult.reason); 1015 showError(`Failed to load: ${reposResult.reason.message}`); 1016 } 1017 1018 // Process trending 1019 if (trendingResult.status === "fulfilled") { 1020 state.trending = trendingResult.value; 1021 } else { 1022 console.error("Trending load failed:", trendingResult.reason); 1023 } 1024 1025 // Render everything together 1026 renderTrending(); 1027 renderResultCount(); 1028 renderFeed(); 1029 renderLoadMore(); 1030 } 1031 1032 main(); 1033 </script> 1034 </body> 1035</html>