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-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": { 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", 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/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) { diff --git a/src/app/page.tsx b/src/app/page.tsx index c093fb9..73920ab 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 { @@ -11,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(); @@ -26,6 +26,8 @@ export default function Home() { } switch (view) { + case "results": + return ; case "user": return ; case "repo": @@ -47,18 +49,9 @@ export default function Home() {
- - - {renderContent()} - - +
+ {renderContent()} +
{/* Modern Minimalist Footer */} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index a4e7f4a..dfb6075 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -9,22 +9,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import { motion, AnimatePresence } from "framer-motion"; export const LayoutHeader = () => { - const { view, navigationStack, popNavigation, search } = useAppStore(); - - const goToProjectRepo = () => { - search("ziguifrido/github-explorer"); - }; - - const resetToSearch = () => { - useAppStore.setState({ - navigationStack: [{ view: "search" }], - view: "search", - username: "", - repoOwner: "", - repoName: "", - error: null, - }); - }; + const { view, navigationStack, popNavigation, resetToSearch } = useAppStore(); // Check if we came from a User dashboard to the Repository dashboard const hasUserInStack = React.useMemo(() => { @@ -40,7 +25,7 @@ export const LayoutHeader = () => {
{/* Logo / Title */}
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' ? ( + ) : ( )} 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 ( + + ); +}; diff --git a/src/lib/github.ts b/src/lib/github.ts index 97b2383..34719ca 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -83,6 +83,31 @@ export interface GitHubReadme { download_url: string; } +export interface GitHubSearchUser { + login: string; + id: number; + avatar_url: string; + html_url: string; + type: string; + score: number; +} + +export type GitHubSearchUsersResponse = GitHubSearchResponse; +export type GitHubSearchReposResponse = 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; +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 +136,66 @@ async function githubFetch(endpoint: string): Promise { return response.json(); } +async function searchFirstPage( + endpoint: string, + queryParams: URLSearchParams +): Promise> { + queryParams.set('per_page', String(GITHUB_SEARCH_PAGE_SIZE)); + queryParams.set('page', '1'); + + const data = await githubFetch<{ + total_count: number; + incomplete_results: boolean; + items: T[]; + }>(`${endpoint}?${queryParams.toString()}`); + + 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, + }; +} + +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 { + ...data, + page, + total_pages: totalPages, + next_page: page < totalPages ? page + 1 : null, + prev_page: page > 1 ? page - 1 : null, + }; +} + export const githubApi = { getUser: async (username: string): Promise => { return githubFetch(`/users/${username}`); @@ -139,4 +224,24 @@ export const githubApi = { getRepoLanguages: async (owner: string, repo: string): Promise> => { return githubFetch>(`/repos/${owner}/${repo}/languages`); }, + + searchUsers: async (query: string): Promise => { + return searchFirstPage( + '/search/users', + new URLSearchParams({ q: query }) + ); + }, + + searchRepositories: async (query: string): Promise => { + return searchFirstPage( + '/search/repositories', + new URLSearchParams({ + q: query, + sort: 'stars', + order: 'desc', + }) + ); + }, + + fetchSearchPage, }; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 4649477..c2e1e57 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,8 +52,9 @@ interface AppStore { searchHistory: HistoryItem[]; // Actions - setView: (view: 'search' | 'user' | 'repo') => void; + setView: (view: 'search' | 'results' | 'user' | 'repo') => void; resetError: () => void; + resetToSearch: () => void; pushNavigation: (step: NavigationStep) => void; popNavigation: () => void; clearHistory: () => void; @@ -91,6 +99,10 @@ export const useAppStore = create()( activeRepoContributors: [], activeRepoLanguages: {}, + searchUsersResults: [], + searchReposResults: [], + searchQuery: '', + loading: false, loadingType: null, error: null, @@ -99,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 @@ -108,7 +140,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 +152,7 @@ export const useAppStore = create()( username: step.username || '', repoOwner: step.repoOwner || '', repoName: step.repoName || '', + ...(step.view === 'results' ? { searchQuery: step.searchQuery || '' } : {}), }; }), @@ -143,6 +177,7 @@ export const useAppStore = create()( username: prevStep.username || '', repoOwner: prevStep.repoOwner || '', repoName: prevStep.repoName || '', + ...(prevStep.view === 'results' ? { searchQuery: prevStep.searchQuery || '' } : {}), }; }), @@ -156,7 +191,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 +235,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) {