From 8e3685fd546c7435a958d98f28b59e3491a6a37d Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:31 -0400 Subject: [PATCH 01/11] feat: allow /search/users and /search/repositories API endpoints --- src/app/api/github/[...path]/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/github/[...path]/route.ts b/src/app/api/github/[...path]/route.ts index afa6455..0e281fe 100644 --- a/src/app/api/github/[...path]/route.ts +++ b/src/app/api/github/[...path]/route.ts @@ -11,6 +11,8 @@ const ALLOWED_PATHS = [ /^\/repos\/[^/]+\/[^/]+\/commits$/, /^\/repos\/[^/]+\/[^/]+\/contributors$/, /^\/repos\/[^/]+\/[^/]+\/languages$/, + /^\/search\/users$/, + /^\/search\/repositories$/, ] as const; function isAllowedPath(pathname: string) { From 03bc9f0961991071603f94d1040b758d6aa06f87 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:33 -0400 Subject: [PATCH 02/11] feat: add GitHub search types and paginated search API functions --- src/lib/github.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/lib/github.ts b/src/lib/github.ts index 97b2383..6942c13 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -83,6 +83,36 @@ export interface GitHubReadme { download_url: string; } +export interface GitHubSearchUser { + login: string; + id: number; + avatar_url: string; + html_url: string; + type: string; + score: number; +} + +export interface GitHubSearchUsersResponse { + total_count: number; + incomplete_results: boolean; + items: GitHubSearchUser[]; +} + +export interface GitHubSearchReposResponse { + total_count: number; + incomplete_results: boolean; + items: GitHubRepository[]; +} + +interface GitHubSearchResponse { + total_count: number; + incomplete_results: boolean; + items: T[]; +} + +const GITHUB_SEARCH_PAGE_SIZE = 100; +const GITHUB_SEARCH_RESULT_LIMIT = 1000; + // Generic fetch function with error handling. // GitHub API access is proxied through a same-origin Route Handler so any // server-only token stays off the client bundle. @@ -111,6 +141,36 @@ async function githubFetch(endpoint: string): Promise { return response.json(); } +async function searchAllPages( + endpoint: string, + queryParams: URLSearchParams +): Promise> { + queryParams.set('per_page', String(GITHUB_SEARCH_PAGE_SIZE)); + queryParams.set('page', '1'); + + const firstPage = await githubFetch>(`${endpoint}?${queryParams.toString()}`); + const resultLimit = Math.min(firstPage.total_count, GITHUB_SEARCH_RESULT_LIMIT); + const totalPages = Math.ceil(resultLimit / GITHUB_SEARCH_PAGE_SIZE); + + if (totalPages <= 1) { + return firstPage; + } + + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, async (_, index) => { + const pageParams = new URLSearchParams(queryParams); + pageParams.set('page', String(index + 2)); + return githubFetch>(`${endpoint}?${pageParams.toString()}`); + }) + ); + + return { + total_count: firstPage.total_count, + incomplete_results: [firstPage, ...remainingPages].some((page) => page.incomplete_results), + items: [firstPage, ...remainingPages].flatMap((page) => page.items).slice(0, resultLimit), + }; +} + export const githubApi = { getUser: async (username: string): Promise => { return githubFetch(`/users/${username}`); @@ -139,4 +199,22 @@ export const githubApi = { getRepoLanguages: async (owner: string, repo: string): Promise> => { return githubFetch>(`/repos/${owner}/${repo}/languages`); }, + + searchUsers: async (query: string): Promise => { + return searchAllPages( + '/search/users', + new URLSearchParams({ q: query }) + ); + }, + + searchRepositories: async (query: string): Promise => { + return searchAllPages( + '/search/repositories', + new URLSearchParams({ + q: query, + sort: 'stars', + order: 'desc', + }) + ); + }, }; From 2596b7989c0798a829dcf9c20448317ec560b5de Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:36 -0400 Subject: [PATCH 03/11] feat: add search state, results view, and history type to store --- src/store/useAppStore.ts | 50 +++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 4649477..93269b9 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -5,25 +5,27 @@ import { GitHubUser, GitHubRepository, GitHubCommit, - GitHubContributor + GitHubContributor, + GitHubSearchUser, } from '@/lib/github'; export interface HistoryItem { - type: 'user' | 'repo'; + type: 'user' | 'repo' | 'search'; query: string; timestamp: number; } export interface NavigationStep { - view: 'search' | 'user' | 'repo'; + view: 'search' | 'results' | 'user' | 'repo'; username?: string; repoOwner?: string; repoName?: string; + searchQuery?: string; } interface AppStore { // Navigation - view: 'search' | 'user' | 'repo'; + view: 'search' | 'results' | 'user' | 'repo'; username: string; repoOwner: string; repoName: string; @@ -38,6 +40,11 @@ interface AppStore { activeRepoContributors: GitHubContributor[]; activeRepoLanguages: Record; + // Search Results + searchUsersResults: GitHubSearchUser[]; + searchReposResults: GitHubRepository[]; + searchQuery: string; + // UI Status loading: boolean; loadingType: 'user' | 'repo' | null; @@ -45,7 +52,7 @@ interface AppStore { searchHistory: HistoryItem[]; // Actions - setView: (view: 'search' | 'user' | 'repo') => void; + setView: (view: 'search' | 'results' | 'user' | 'repo') => void; resetError: () => void; pushNavigation: (step: NavigationStep) => void; popNavigation: () => void; @@ -91,6 +98,10 @@ export const useAppStore = create()( activeRepoContributors: [], activeRepoLanguages: {}, + searchUsersResults: [], + searchReposResults: [], + searchQuery: '', + loading: false, loadingType: null, error: null, @@ -108,7 +119,8 @@ export const useAppStore = create()( lastStep.view === step.view && lastStep.username === step.username && lastStep.repoOwner === step.repoOwner && - lastStep.repoName === step.repoName + lastStep.repoName === step.repoName && + lastStep.searchQuery === step.searchQuery ) { return {}; } @@ -119,6 +131,7 @@ export const useAppStore = create()( username: step.username || '', repoOwner: step.repoOwner || '', repoName: step.repoName || '', + ...(step.view === 'results' ? { searchQuery: step.searchQuery || '' } : {}), }; }), @@ -143,6 +156,7 @@ export const useAppStore = create()( username: prevStep.username || '', repoOwner: prevStep.repoOwner || '', repoName: prevStep.repoName || '', + ...(prevStep.view === 'results' ? { searchQuery: prevStep.searchQuery || '' } : {}), }; }), @@ -156,7 +170,7 @@ export const useAppStore = create()( !cleanQuery.startsWith('/') && !cleanQuery.endsWith('/'); - set({ loading: true, loadingType: isRepo ? 'repo' : 'user', error: null }); + set({ loading: true, loadingType: isRepo ? 'repo' : null, error: null }); try { if (isRepo) { @@ -200,34 +214,34 @@ export const useAppStore = create()( repoName: name, }); } else { - // Perform user details fetch - const [user, repos] = await Promise.all([ - githubApi.getUser(cleanQuery), - githubApi.getUserRepos(cleanQuery).catch(() => []), + // Perform generic search across users and repositories + const [usersResponse, reposResponse] = await Promise.all([ + githubApi.searchUsers(cleanQuery), + githubApi.searchRepositories(cleanQuery), ]); // Add search item to history const historyItem: HistoryItem = { - type: 'user', + type: 'search', query: cleanQuery, timestamp: Date.now(), }; set((state) => { - // Deduplicate history const filteredHistory = state.searchHistory.filter( - (item) => !(item.type === 'user' && item.query.toLowerCase() === cleanQuery.toLowerCase()) + (item) => !(item.type === 'search' && item.query.toLowerCase() === cleanQuery.toLowerCase()) ); return { - activeUser: user, - activeUserRepos: repos, + searchUsersResults: usersResponse.items, + searchReposResults: reposResponse.items, + searchQuery: cleanQuery, searchHistory: [historyItem, ...filteredHistory].slice(0, 10), }; }); get().pushNavigation({ - view: 'user', - username: cleanQuery, + view: 'results', + searchQuery: cleanQuery, }); } } catch (err: unknown) { From 9bb03389693ff54104005cf2517c4ed1db7bdf43 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:38 -0400 Subject: [PATCH 04/11] feat: create ViewSearchResults component with paginated search results --- public/avatar-placeholder.svg | 5 + src/components/ViewSearchResults.tsx | 351 +++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 public/avatar-placeholder.svg create mode 100644 src/components/ViewSearchResults.tsx diff --git a/public/avatar-placeholder.svg b/public/avatar-placeholder.svg new file mode 100644 index 0000000..2a5738d --- /dev/null +++ b/public/avatar-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/ViewSearchResults.tsx b/src/components/ViewSearchResults.tsx new file mode 100644 index 0000000..7d9647e --- /dev/null +++ b/src/components/ViewSearchResults.tsx @@ -0,0 +1,351 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; +import { useAppStore } from '@/store/useAppStore'; +import { + Star, + GitFork, + Users, + BookOpen, + Search, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { motion } from 'framer-motion'; + +const LANGUAGE_COLORS: Record = { + TypeScript: '#3178c6', + JavaScript: '#f1e05a', + Python: '#3572A5', + Go: '#00ADD8', + Rust: '#dea584', + HTML: '#e34c26', + CSS: '#563d7c', + 'C++': '#f34b7d', + C: '#555555', + 'C#': '#178600', + Java: '#b07219', + Ruby: '#701516', + PHP: '#4F5D95', + Shell: '#89e051', + Swift: '#F05138', + Kotlin: '#A97BFF', + Dart: '#00B4AB', +}; + +const DEFAULT_COLOR = '#8b5cf6'; +const AVATAR_FALLBACK_SRC = '/avatar-placeholder.svg'; +const USERS_PER_PAGE = 10; +const REPOS_PER_PAGE = 10; + +function SearchUserCard({ + user, + selectUser, +}: { + user: ReturnType['searchUsersResults'][number]; + selectUser: (username: string) => Promise; +}) { + const [avatarSrc, setAvatarSrc] = React.useState(user.avatar_url); + + return ( + + ); +} + +function PaginationControls({ + currentPage, + totalPages, + onPageChange, + label, +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + label: string; +}) { + if (totalPages <= 1) { + return null; + } + + return ( +
+ + + Page {currentPage} of {totalPages} + + +
+ ); +} + +function SearchResultsContent({ + searchUsersResults, + searchReposResults, + searchQuery, + selectUser, + selectRepo, +}: Pick< + ReturnType, + | 'searchUsersResults' + | 'searchReposResults' + | 'searchQuery' + | 'selectUser' + | 'selectRepo' +>) { + const [usersPage, setUsersPage] = React.useState(1); + const [reposPage, setReposPage] = React.useState(1); + + const hasUsers = searchUsersResults.length > 0; + const hasRepos = searchReposResults.length > 0; + const usersTotalPages = Math.max(1, Math.ceil(searchUsersResults.length / USERS_PER_PAGE)); + const reposTotalPages = Math.max(1, Math.ceil(searchReposResults.length / REPOS_PER_PAGE)); + const safeUsersPage = Math.min(usersPage, usersTotalPages); + const safeReposPage = Math.min(reposPage, reposTotalPages); + const visibleUsers = searchUsersResults.slice( + (safeUsersPage - 1) * USERS_PER_PAGE, + safeUsersPage * USERS_PER_PAGE + ); + const visibleRepos = searchReposResults.slice( + (safeReposPage - 1) * REPOS_PER_PAGE, + safeReposPage * REPOS_PER_PAGE + ); + + return ( + + {/* Header */} +
+
+
+ +
+
+

+ Search Results +

+

+ “{searchQuery}” +

+
+
+
+ + {!hasUsers && !hasRepos ? ( +
+ +

+ No results found +

+

+ No users or repositories matched “{searchQuery}”. Try a different search term. +

+
+ ) : ( + <> + {/* Users Section */} + {hasUsers && ( +
+
+ +

+ Users +

+ + {searchUsersResults.length} + +
+
+ {visibleUsers.map((user) => ( + + ))} +
+ +
+ )} + + {/* Repositories Section */} + {hasRepos && ( +
+
+ +

+ Repositories +

+ + {searchReposResults.length} + +
+
+ {visibleRepos.map((repo) => ( + selectRepo(repo.owner.login, repo.name)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectRepo(repo.owner.login, repo.name); + } + }} + role="button" + tabIndex={0} + aria-label={`${repo.owner.login}/${repo.name}`} + className="border-border cursor-pointer flex flex-col justify-between group" + > + +
+
+ + {repo.owner.login} / + +

+ {repo.name} +

+
+ {repo.fork && ( + + Fork + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} + {repo.topics.length > 0 && ( +
+ {repo.topics.slice(0, 3).map((topic) => ( + + {topic} + + ))} + {repo.topics.length > 3 && ( + + +{repo.topics.length - 3} + + )} +
+ )} +
+ +
+ {repo.language ? ( + <> + + {repo.language} + + ) : ( + No language + )} +
+
+ + + {repo.stargazers_count} + + + + {repo.forks_count} + +
+
+
+ ))} +
+ +
+ )} + + )} +
+ ); +} + +export const ViewSearchResults = () => { + const { + searchUsersResults, + searchReposResults, + searchQuery, + selectUser, + selectRepo, + } = useAppStore(); + + return ( + + ); +}; From dc6e31ff5e1faee08d2c6d1d25aa738cdcb6acb0 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:41 -0400 Subject: [PATCH 05/11] feat: wire up search UI with new results view and placeholder --- src/app/page.tsx | 3 +++ src/components/LayoutHeader.tsx | 3 +++ src/components/ViewSearch.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index c093fb9..15db841 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import React from "react"; import { useAppStore } from "@/store/useAppStore"; import { LayoutHeader } from "@/components/LayoutHeader"; import { ViewSearch } from "@/components/ViewSearch"; +import { ViewSearchResults } from "@/components/ViewSearchResults"; import { ViewUser } from "@/components/ViewUser"; import { ViewRepo } from "@/components/ViewRepo"; import { @@ -26,6 +27,8 @@ export default function Home() { } switch (view) { + case "results": + return ; case "user": return ; case "repo": diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index a4e7f4a..071236e 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -23,6 +23,9 @@ export const LayoutHeader = () => { repoOwner: "", repoName: "", error: null, + searchUsersResults: [], + searchReposResults: [], + searchQuery: "", }); }; diff --git a/src/components/ViewSearch.tsx b/src/components/ViewSearch.tsx index 0f6625e..87fcd74 100644 --- a/src/components/ViewSearch.tsx +++ b/src/components/ViewSearch.tsx @@ -51,7 +51,7 @@ export const ViewSearch = () => { { label: 'Torvalds', value: 'torvalds' }, { label: 'React Project', value: 'facebook/react' }, { label: 'Next.js Project', value: 'vercel/next.js' }, - { label: 'Vite Developer', value: 'yyx990803' }, + { label: 'Machine Learning', value: 'machine learning' }, ]; return ( @@ -96,7 +96,7 @@ export const ViewSearch = () => { }} onKeyDown={handleKeyDown} disabled={loading} - placeholder="Enter a username (e.g. 'torvalds') or repository (e.g. 'facebook/react')..." + placeholder="Search users, repositories, or type 'user/repo'..." className="w-full bg-transparent resize-none outline-none border-none py-2 px-3 text-sm md:text-base text-foreground placeholder-tertiary min-h-[44px] max-h-[180px] scrollbar-none font-sans" /> @@ -213,6 +213,8 @@ export const ViewSearch = () => {
{item.type === 'repo' ? ( + ) : item.type === 'search' ? ( + ) : ( )} From 58b414e570987b13768598527be750b1c6aec97c Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:09:44 -0400 Subject: [PATCH 06/11] chore: bump package-lock.json to version 0.2.3 and add engines field --- package-lock.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc4ea27..d929ff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-explorer-dashboard", - "version": "0.1.0", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-explorer-dashboard", - "version": "0.1.0", + "version": "0.2.3", "dependencies": { "@base-ui/react": "^1.5.0", "class-variance-authority": "^0.7.1", @@ -35,6 +35,10 @@ "eslint-config-next": "16.2.6", "tailwindcss": "^4", "typescript": "^5" + }, + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" } }, "node_modules/@alloc/quick-lru": { From 6763eb9c9e05f65291ae64cc640bd575a5d80edb Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:22:50 -0400 Subject: [PATCH 07/11] fix: back to search now properly resets all store state --- src/components/LayoutHeader.tsx | 16 +--------------- src/store/useAppStore.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 071236e..32afc82 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -9,26 +9,12 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import { motion, AnimatePresence } from "framer-motion"; export const LayoutHeader = () => { - const { view, navigationStack, popNavigation, search } = useAppStore(); + const { view, navigationStack, popNavigation, search, resetToSearch } = useAppStore(); const goToProjectRepo = () => { search("ziguifrido/github-explorer"); }; - const resetToSearch = () => { - useAppStore.setState({ - navigationStack: [{ view: "search" }], - view: "search", - username: "", - repoOwner: "", - repoName: "", - error: null, - searchUsersResults: [], - searchReposResults: [], - searchQuery: "", - }); - }; - // Check if we came from a User dashboard to the Repository dashboard const hasUserInStack = React.useMemo(() => { if (view !== "repo") return false; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 93269b9..c2e1e57 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -54,6 +54,7 @@ interface AppStore { // Actions setView: (view: 'search' | 'results' | 'user' | 'repo') => void; resetError: () => void; + resetToSearch: () => void; pushNavigation: (step: NavigationStep) => void; popNavigation: () => void; clearHistory: () => void; @@ -110,6 +111,26 @@ export const useAppStore = create()( // Actions setView: (view) => set({ view }), resetError: () => set({ error: null }), + resetToSearch: () => set({ + view: 'search', + username: '', + repoOwner: '', + repoName: '', + navigationStack: [{ view: 'search' }], + activeUser: null, + activeUserRepos: [], + activeRepo: null, + activeRepoReadme: null, + activeRepoCommits: [], + activeRepoContributors: [], + activeRepoLanguages: {}, + searchUsersResults: [], + searchReposResults: [], + searchQuery: '', + loading: false, + loadingType: null, + error: null, + }), pushNavigation: (step) => set((state) => { // Prevent duplicate consecutive navigation steps From fa5f703a7d8bd21b3ac43737a947fb08925762b8 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:34:53 -0400 Subject: [PATCH 08/11] chore: bump to v0.3.0, update README and AGENTS for search feature --- AGENTS.md | 20 ++++++++++++++++++++ README.md | 13 ++++++++----- package.json | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed6eb27..e7d274f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,28 @@ npm run lint # ESLint v9 Always run `npm run lint` after making changes. No typecheck script is configured; verify types via `npm run build` or `npx tsc --noEmit`. +Do not commit changes unless explicitly asked to. + All UI text is in English. +## Search API + +GitHub Search API results are fetched one page at a time (100 per page, max 1000 +results). The initial `searchUsers()` / `searchRepositories()` calls fetch only +the first page and return pagination metadata (`total_pages`, `next_page`, +`prev_page`). Use `githubApi.fetchSearchPage(endpoint, query, page, extraParams?)` +to load additional pages on demand. The response type is `GitHubSearchResponse`, +exported from `src/lib/github.ts`. + +```typescript +// First page +const res = await githubApi.searchUsers("torvalds"); +// res.items, res.total_pages, res.next_page + +// Subsequent page +const page2 = await githubApi.fetchSearchPage("/search/users", "torvalds", 2); +``` + ## Node.js - **Required:** `>=22.0.0` (enforced via `package.json` `engines`) diff --git a/README.md b/README.md index 38462db..2ab6ceb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # GitHub Explorer -High-performance dashboard for exploring GitHub profiles and repositories. +High-performance dashboard for exploring GitHub users, repositories, and search results. -**v0.2.0** +**v0.3.0** ## Features -- **Smart search** — auto-detects whether input is a user (`torvalds`) or repository (`facebook/react`) +- **Combined search** — single query searches both users and repositories simultaneously with paginated results +- **Search results** — dedicated results view with user cards and repository cards, each independently paginated - **User dashboard** — full profile, aggregated stats (stars, forks), language distribution chart, repository list with search, language filter, sorting, and pagination - **Repository dashboard** — metadata, rendered README, commit timeline, contributors, language breakdown with percent bar - **Light / Dark theme** — toggles via navbar button, follows system preference by default, persists choice in a cookie (365 days) @@ -65,19 +66,21 @@ docker run -p 3000:3000 github-explorer ``` src/ ├── app/ +│ ├── api/github/[...path]/route.ts # GitHub API proxy (rate-limit, path whitelist) │ ├── layout.tsx # root layout with Geist fonts, theme-color, ThemeProvider │ ├── page.tsx # main page with view switching + footer │ └── globals.css # global styles, GitHub Primer palette, light-dark() variables ├── components/ -│ ├── LayoutHeader.tsx # sticky header with nav, theme toggle, logo → project repo +│ ├── LayoutHeader.tsx # sticky header with nav, theme toggle, logo → search view │ ├── ThemeToggle.tsx # light/dark toggle (Sun/Moon) │ ├── ViewSearch.tsx # initial search screen +│ ├── ViewSearchResults.tsx # combined user/repo results with pagination │ ├── ViewUser.tsx # user profile dashboard │ ├── ViewRepo.tsx # repository dashboard │ ├── DashboardSkeletons.tsx │ └── ui/ # base components (shadcn-style) ├── lib/ -│ ├── github.ts # GitHub REST API client +│ ├── github.ts # GitHub REST API client with paginated search │ ├── theme.tsx # ThemeProvider, useTheme, cookie persistence │ └── utils.ts # cn() utility └── store/ diff --git a/package.json b/package.json index f8e37c0..ae7a0fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-explorer-dashboard", - "version": "0.2.3", + "version": "0.3.0", "private": true, "engines": { "node": ">=22.0.0", From 6d41ead37678d536982f90c989cdf62126dd5713 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:34:57 -0400 Subject: [PATCH 09/11] fix: remove AnimatePresence wrapper causing blank page on view transition --- src/app/page.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 15db841..73920ab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,6 @@ import { RepoDashboardSkeleton, } from "@/components/DashboardSkeletons"; import { GithubIcon } from "@/components/ui/icons"; -import { AnimatePresence, motion } from "framer-motion"; export default function Home() { const { view, loading, loadingType } = useAppStore(); @@ -50,18 +49,9 @@ export default function Home() {
- - - {renderContent()} - - +
+ {renderContent()} +
{/* Modern Minimalist Footer */} From 1ed6ac2ea2d4e002a7b688263d22c5c340665fc0 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:35:00 -0400 Subject: [PATCH 10/11] feat: make logo navigate to main search view --- src/components/LayoutHeader.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 32afc82..dfb6075 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -9,11 +9,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import { motion, AnimatePresence } from "framer-motion"; export const LayoutHeader = () => { - const { view, navigationStack, popNavigation, search, resetToSearch } = useAppStore(); - - const goToProjectRepo = () => { - search("ziguifrido/github-explorer"); - }; + const { view, navigationStack, popNavigation, resetToSearch } = useAppStore(); // Check if we came from a User dashboard to the Repository dashboard const hasUserInStack = React.useMemo(() => { @@ -29,7 +25,7 @@ export const LayoutHeader = () => {
{/* Logo / Title */}
From a4ae38e10116d8e95a39905bd3c543f546ed8a69 Mon Sep 17 00:00:00 2001 From: Marcos Oliveira Date: Thu, 11 Jun 2026 00:35:03 -0400 Subject: [PATCH 11/11] perf: replace eager multi-page search with on-demand paginated fetch --- src/lib/github.ts | 89 ++++++++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/src/lib/github.ts b/src/lib/github.ts index 6942c13..34719ca 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -92,22 +92,17 @@ export interface GitHubSearchUser { score: number; } -export interface GitHubSearchUsersResponse { - total_count: number; - incomplete_results: boolean; - items: GitHubSearchUser[]; -} +export type GitHubSearchUsersResponse = GitHubSearchResponse; +export type GitHubSearchReposResponse = GitHubSearchResponse; -export interface GitHubSearchReposResponse { - total_count: number; - incomplete_results: boolean; - items: GitHubRepository[]; -} - -interface GitHubSearchResponse { +export interface GitHubSearchResponse { total_count: number; incomplete_results: boolean; items: T[]; + page: number; + total_pages: number; + next_page: number | null; + prev_page: number | null; } const GITHUB_SEARCH_PAGE_SIZE = 100; @@ -141,33 +136,63 @@ async function githubFetch(endpoint: string): Promise { return response.json(); } -async function searchAllPages( +async function searchFirstPage( endpoint: string, queryParams: URLSearchParams ): Promise> { queryParams.set('per_page', String(GITHUB_SEARCH_PAGE_SIZE)); queryParams.set('page', '1'); - const firstPage = await githubFetch>(`${endpoint}?${queryParams.toString()}`); - const resultLimit = Math.min(firstPage.total_count, GITHUB_SEARCH_RESULT_LIMIT); - const totalPages = Math.ceil(resultLimit / GITHUB_SEARCH_PAGE_SIZE); + const data = await githubFetch<{ + total_count: number; + incomplete_results: boolean; + items: T[]; + }>(`${endpoint}?${queryParams.toString()}`); - if (totalPages <= 1) { - return firstPage; - } + const totalPages = Math.ceil(Math.min(data.total_count, GITHUB_SEARCH_RESULT_LIMIT) / GITHUB_SEARCH_PAGE_SIZE); + + return { + ...data, + page: 1, + total_pages: totalPages, + next_page: totalPages > 1 ? 2 : null, + prev_page: null, + }; +} - const remainingPages = await Promise.all( - Array.from({ length: totalPages - 1 }, async (_, index) => { - const pageParams = new URLSearchParams(queryParams); - pageParams.set('page', String(index + 2)); - return githubFetch>(`${endpoint}?${pageParams.toString()}`); - }) - ); +function buildSearchParams( + query: string, + page: number, + extraParams?: URLSearchParams +): URLSearchParams { + const params = new URLSearchParams(extraParams); + params.set('q', query); + params.set('per_page', String(GITHUB_SEARCH_PAGE_SIZE)); + params.set('page', String(page)); + return params; +} + +async function fetchSearchPage( + endpoint: string, + query: string, + page: number, + extraParams?: URLSearchParams +): Promise> { + const params = buildSearchParams(query, page, extraParams); + const data = await githubFetch<{ + total_count: number; + incomplete_results: boolean; + items: T[]; + }>(`${endpoint}?${params.toString()}`); + + const totalPages = Math.ceil(Math.min(data.total_count, GITHUB_SEARCH_RESULT_LIMIT) / GITHUB_SEARCH_PAGE_SIZE); return { - total_count: firstPage.total_count, - incomplete_results: [firstPage, ...remainingPages].some((page) => page.incomplete_results), - items: [firstPage, ...remainingPages].flatMap((page) => page.items).slice(0, resultLimit), + ...data, + page, + total_pages: totalPages, + next_page: page < totalPages ? page + 1 : null, + prev_page: page > 1 ? page - 1 : null, }; } @@ -201,14 +226,14 @@ export const githubApi = { }, searchUsers: async (query: string): Promise => { - return searchAllPages( + return searchFirstPage( '/search/users', new URLSearchParams({ q: query }) ); }, searchRepositories: async (query: string): Promise => { - return searchAllPages( + return searchFirstPage( '/search/repositories', new URLSearchParams({ q: query, @@ -217,4 +242,6 @@ export const githubApi = { }) ); }, + + fetchSearchPage, };