Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat(tangled): add trending repos section

Show repos with most stars in the past week as horizontally
scrollable compact cards below the search bar. Loads in parallel
with main feed to prevent layout flash.

+277 -3
+277 -3
tangled-repos.html
··· 263 263 border-radius: 1rem; 264 264 } 265 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 + 266 377 /* Footer Links */ 267 378 .repo-footer { 268 379 display: flex; ··· 393 504 <input type="text" id="search-input" placeholder="Search... (@user, repo:name, topic:rust)" /> 394 505 <button id="clear-search" class="hidden" title="Clear search">&times;</button> 395 506 </div> 507 + <div id="trending-section" class="trending-section hidden"> 508 + <div class="trending-header"> 509 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 510 + <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline> 511 + <polyline points="17 6 23 6 23 12"></polyline> 512 + </svg> 513 + <span>Trending this week</span> 514 + </div> 515 + <div id="trending-scroll" class="trending-scroll"></div> 516 + </div> 396 517 <div id="result-count"></div> 397 518 <main> 398 519 <div id="repo-feed"></div> ··· 424 545 isLoading: false, 425 546 searchQuery: "", 426 547 totalCount: 0, 548 + trending: [], 427 549 }; 428 550 429 551 // ============================================================================= ··· 466 588 } 467 589 `; 468 590 591 + const TRENDING_QUERY = ` 592 + query TrendingStars($since: String!) { 593 + shTangledFeedStar( 594 + where: { createdAt: { gte: $since } } 595 + first: 100 596 + sortBy: [{ field: createdAt, direction: DESC }] 597 + ) { 598 + edges { 599 + node { 600 + subject 601 + subjectResolved { 602 + ... on ShTangledRepo { 603 + uri 604 + name 605 + actorHandle 606 + appBskyActorProfileByDid { 607 + displayName 608 + avatar { url(preset: "avatar") } 609 + } 610 + } 611 + } 612 + } 613 + } 614 + } 615 + } 616 + `; 617 + 469 618 // ============================================================================= 470 619 // DATA FETCHING 471 620 // ============================================================================= ··· 524 673 if (json.errors) throw new Error(json.errors[0].message); 525 674 526 675 return json.data.shTangledRepo; 676 + } 677 + 678 + async function fetchTrendingRepos() { 679 + // Get date 7 days ago 680 + const since = new Date(); 681 + since.setDate(since.getDate() - 7); 682 + const sinceISO = since.toISOString(); 683 + 684 + const res = await fetch(`${SERVER_URL}/graphql`, { 685 + method: "POST", 686 + headers: { "Content-Type": "application/json" }, 687 + body: JSON.stringify({ 688 + query: TRENDING_QUERY, 689 + variables: { since: sinceISO }, 690 + }), 691 + }); 692 + 693 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 694 + 695 + const json = await res.json(); 696 + if (json.errors) throw new Error(json.errors[0].message); 697 + 698 + // Count stars per repo and dedupe 699 + const starCounts = new Map(); 700 + const repoData = new Map(); 701 + 702 + for (const edge of json.data.shTangledFeedStar.edges) { 703 + const star = edge.node; 704 + if (!star.subjectResolved || !star.subjectResolved.uri) continue; 705 + 706 + const uri = star.subjectResolved.uri; 707 + starCounts.set(uri, (starCounts.get(uri) || 0) + 1); 708 + 709 + if (!repoData.has(uri)) { 710 + repoData.set(uri, star.subjectResolved); 711 + } 712 + } 713 + 714 + // Sort by star count and return top 10 715 + const sorted = [...starCounts.entries()] 716 + .sort((a, b) => b[1] - a[1]) 717 + .slice(0, 10); 718 + 719 + return sorted.map(([uri, count]) => ({ 720 + ...repoData.get(uri), 721 + weeklyStars: count, 722 + })); 527 723 } 528 724 529 725 // ============================================================================= ··· 674 870 `; 675 871 } 676 872 873 + function renderTrendingCard(repo) { 874 + const profile = repo.appBskyActorProfileByDid; 875 + const handle = repo.actorHandle || "unknown"; 876 + const avatar = profile?.avatar?.url || ""; 877 + const tangledUrl = `https://tangled.org/${handle}/${repo.name}`; 878 + 879 + return ` 880 + <a href="${esc(tangledUrl)}" target="_blank" class="trending-card"> 881 + <div class="trending-card-header"> 882 + <div class="trending-avatar"> 883 + ${avatar ? `<img src="${esc(avatar)}" alt="">` : ""} 884 + </div> 885 + <span class="trending-owner">@${esc(handle)}</span> 886 + </div> 887 + <div class="trending-name">${esc(repo.name)}</div> 888 + <div class="trending-stats"> 889 + <span>★ ${repo.weeklyStars}</span> 890 + <span class="trending-new">this week</span> 891 + </div> 892 + </a> 893 + `; 894 + } 895 + 896 + function renderTrending() { 897 + const section = document.getElementById("trending-section"); 898 + const scroll = document.getElementById("trending-scroll"); 899 + 900 + if (state.trending.length === 0 || state.searchQuery) { 901 + section.classList.add("hidden"); 902 + return; 903 + } 904 + 905 + section.classList.remove("hidden"); 906 + scroll.innerHTML = state.trending.map((r) => renderTrendingCard(r)).join(""); 907 + } 908 + 677 909 // ============================================================================= 678 910 // ACTIONS 679 911 // ============================================================================= ··· 703 935 } 704 936 } 705 937 938 + async function loadTrending() { 939 + try { 940 + state.trending = await fetchTrendingRepos(); 941 + renderTrending(); 942 + } catch (err) { 943 + console.error("Trending load failed:", err); 944 + // Silently fail - trending is optional 945 + } 946 + } 947 + 706 948 function handleLoadMore() { 707 949 loadRepos(true); 708 950 } ··· 712 954 state.cursor = null; 713 955 state.repos = []; 714 956 state.hasMore = true; 957 + renderTrending(); // Hide/show trending based on search 715 958 loadRepos(); 716 959 } 717 960 ··· 728 971 // MAIN 729 972 // ============================================================================= 730 973 731 - function main() { 974 + async function main() { 732 975 // Set up search input 733 976 const searchInput = document.getElementById("search-input"); 734 977 const clearBtn = document.getElementById("clear-search"); ··· 741 984 742 985 clearBtn.addEventListener("click", clearSearch); 743 986 744 - // Initial load 745 - loadRepos(); 987 + // Show loading state 988 + document.getElementById("repo-feed").innerHTML = `<div class="loading-container"><div class="spinner"></div><span>Loading repos...</span></div>`; 989 + 990 + // Load both in parallel 991 + const [reposResult, trendingResult] = await Promise.allSettled([ 992 + fetchRepos(null, ""), 993 + fetchTrendingRepos(), 994 + ]); 995 + 996 + // Process repos 997 + if (reposResult.status === "fulfilled") { 998 + const data = reposResult.value; 999 + state.repos = data.edges.map((e) => e.node); 1000 + state.cursor = data.pageInfo.endCursor; 1001 + state.hasMore = data.pageInfo.hasNextPage; 1002 + state.totalCount = data.totalCount; 1003 + } else { 1004 + console.error("Repos load failed:", reposResult.reason); 1005 + showError(`Failed to load: ${reposResult.reason.message}`); 1006 + } 1007 + 1008 + // Process trending 1009 + if (trendingResult.status === "fulfilled") { 1010 + state.trending = trendingResult.value; 1011 + } else { 1012 + console.error("Trending load failed:", trendingResult.reason); 1013 + } 1014 + 1015 + // Render everything together 1016 + renderTrending(); 1017 + renderResultCount(); 1018 + renderFeed(); 1019 + renderLoadMore(); 746 1020 } 747 1021 748 1022 main();