Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
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">×</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>