From 4934bc2cb491f522e6adde95b5209805b9622fcc Mon Sep 17 00:00:00 2001 From: Tyler Hanway Date: Sun, 4 Oct 2026 18:48:08 -0500 Subject: [PATCH 1/3] Migrate from Next.js on Vercel to a static app on Cloudflare Vercel paused the deployment. The app already fetched its data from ClickHouse in the browser, so it does not need a server. Replace Next.js with a Vite single-page app using React Router, served from Cloudflare Workers static assets where requests are not metered. - Keep the same URLs: /org/repo, with deeper GitHub paths redirecting to it. Unknown paths fall back to index.html. - Remove the unused server pieces: /api/stargazers, the server action, unstable_cache and GITHUB_TOKEN. - Replace next-themes with a small theme provider, and apply the theme before first paint from index.html. - Move response headers from next.config.ts to public/_headers. - Replace next lint with a flat ESLint config, and run the build in CI. Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 - .eslintrc.json | 7 - .github/workflows/test.yml | 8 + .gitignore | 12 +- README.md | 23 ++- app/[...githubPath]/page.test.tsx | 245 --------------------------- app/[...githubPath]/page.tsx | 21 --- app/[org]/[repo]/loading.tsx | 34 ---- app/[org]/[repo]/page.test.tsx | 97 ----------- app/[org]/[repo]/page.tsx | 81 --------- app/actions.ts | 7 - app/api/stargazers/route.test.ts | 190 --------------------- app/api/stargazers/route.ts | 40 ----- app/components/Header.tsx | 4 +- app/components/RepoFilter.tsx | 2 - app/components/RepoInput.tsx | 8 +- app/components/RepoTable.tsx | 49 ++---- app/components/ThemeToggle.tsx | 14 +- app/layout.tsx | 34 ++-- app/main.tsx | 17 ++ app/pages/GitHubPathPage.tsx | 17 ++ app/{page.tsx => pages/HomePage.tsx} | 66 ++------ app/pages/RepoPage.tsx | 20 +++ app/providers.tsx | 67 ++++++-- app/routes.test.tsx | 188 ++++++++++++++++++++ app/routes.tsx | 17 ++ app/theme.ts | 55 ++++++ app/vite-env.d.ts | 1 + bun.lockb | Bin 198100 -> 182547 bytes eslint.config.js | 30 ++++ index.html | 84 +++++++++ lib/clickhouse.ts | 2 +- lib/github.test.ts | 188 -------------------- lib/github.ts | 31 ---- lib/repos.client.test.ts | 169 ++++++++++++++++++ lib/repos.test.ts | 228 ------------------------- lib/repos.ts | 26 --- next.config.ts | 28 --- package.json | 37 ++-- public/_headers | 10 ++ tailwind.config.ts | 6 +- tsconfig.json | 11 +- utils/useDocumentTitle.ts | 11 ++ vite.config.ts | 20 +++ vitest.config.ts | 14 -- wrangler.jsonc | 23 +++ 46 files changed, 807 insertions(+), 1440 deletions(-) delete mode 100644 .env.example delete mode 100644 .eslintrc.json delete mode 100644 app/[...githubPath]/page.test.tsx delete mode 100644 app/[...githubPath]/page.tsx delete mode 100644 app/[org]/[repo]/loading.tsx delete mode 100644 app/[org]/[repo]/page.test.tsx delete mode 100644 app/[org]/[repo]/page.tsx delete mode 100644 app/actions.ts delete mode 100644 app/api/stargazers/route.test.ts delete mode 100644 app/api/stargazers/route.ts create mode 100644 app/main.tsx create mode 100644 app/pages/GitHubPathPage.tsx rename app/{page.tsx => pages/HomePage.tsx} (56%) create mode 100644 app/pages/RepoPage.tsx create mode 100644 app/routes.test.tsx create mode 100644 app/routes.tsx create mode 100644 app/theme.ts create mode 100644 app/vite-env.d.ts create mode 100644 eslint.config.js create mode 100644 index.html delete mode 100644 lib/github.test.ts delete mode 100644 lib/github.ts create mode 100644 lib/repos.client.test.ts delete mode 100644 lib/repos.test.ts delete mode 100644 lib/repos.ts delete mode 100644 next.config.ts create mode 100644 public/_headers create mode 100644 utils/useDocumentTitle.ts create mode 100644 vite.config.ts delete mode 100644 vitest.config.ts create mode 100644 wrangler.jsonc diff --git a/.env.example b/.env.example deleted file mode 100644 index 6d8c042..0000000 --- a/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Client-side ClickHouse fetching (optional) -NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE=true - -# GitHub API access token (optional) -GITHUB_TOKEN=your_github_personal_access_token_here diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index e70779c..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"], - "rules": { - "no-unused-vars": "off", - "@typescript-eslint/no-explicit-any": "off" - } -} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3852a1..554e60c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,11 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -27,3 +32,6 @@ jobs: - name: Run lint run: bun run lint + + - name: Run build + run: bun run build diff --git a/.gitignore b/.gitignore index 26c66e4..873cbb0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,8 @@ # testing /coverage -# next.js -/.next/ -/out/ - # production -/build +/dist/ # misc .DS_Store @@ -33,9 +29,9 @@ yarn-error.log* .env* !.env.example -# vercel -.vercel +# cloudflare +.wrangler +.dev.vars* # typescript *.tsbuildinfo -next-env.d.ts diff --git a/README.md b/README.md index e2ed026..8e3c465 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Getting Started -Install node v20+, bun v1.2+, then install package dependencies: +Install node v22+, bun v1.2+, then install package dependencies: ```bash bun install @@ -12,24 +12,23 @@ Run the development server: bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:5173](http://localhost:5173) with your browser to see the result. -## Environment Variables +## Deployment -Create a `.env.local` file with the following variables: +The site is a static single-page app (Vite + React Router) served from Cloudflare Workers static assets. There is no server code: the browser queries ClickHouse directly, and paths like `/org/repo` fall back to `index.html` so the client-side router can handle them. Routing and domains are configured in `wrangler.jsonc`, response headers in `public/_headers`. -```bash -# Client-side ClickHouse fetching (optional) -NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE=true +Preview the production build locally in the Workers runtime: -# GitHub API access (optional - for rate limiting) -GITHUB_TOKEN=your_github_personal_access_token_here +```bash +bun run preview ``` -### Environment Variable Details: +Deploy to Cloudflare (run `bunx wrangler login` once first): -- `NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE`: Set to `true` to fetch ClickHouse data directly from the browser instead of the server. This reduces server load and provides faster navigation between pages. -- `GITHUB_TOKEN`: Optional GitHub personal access token to increase API rate limits when fetching repository metadata. +```bash +bun run deploy +``` ## Update node packages diff --git a/app/[...githubPath]/page.test.tsx b/app/[...githubPath]/page.test.tsx deleted file mode 100644 index 686fdfb..0000000 --- a/app/[...githubPath]/page.test.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { redirect } from "next/navigation"; -import GitHubPathPage from "./page"; - -vi.mock("next/navigation", () => ({ - redirect: vi.fn(), -})); - -vi.mock("@/utils/github", () => ({ - parseGitHubURL: vi.fn(), -})); - -import { parseGitHubURL } from "@/utils/github"; - -const mockedRedirect = vi.mocked(redirect); -const mockedParseGitHubURL = vi.mocked(parseGitHubURL); - -describe("GitHubPathPage", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe("when GitHub path is valid", () => { - it("redirects to correct org/repo route for simple repository", async () => { - mockedParseGitHubURL.mockReturnValue("facebook/react"); - - const params = Promise.resolve({ githubPath: ["facebook", "react"] }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/facebook/react" - ); - expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react"); - }); - - it("redirects for repository with special characters", async () => { - mockedParseGitHubURL.mockReturnValue("vercel/next.js"); - - const params = Promise.resolve({ githubPath: ["vercel", "next.js"] }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/vercel/next.js" - ); - expect(mockedRedirect).toHaveBeenCalledWith("/vercel/next.js"); - }); - - it("redirects for repository with hyphens", async () => { - mockedParseGitHubURL.mockReturnValue("facebook/react-native"); - - const params = Promise.resolve({ - githubPath: ["facebook", "react-native"], - }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/facebook/react-native" - ); - expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react-native"); - }); - - it("handles complex paths with additional segments", async () => { - mockedParseGitHubURL.mockReturnValue("microsoft/typescript"); - - const params = Promise.resolve({ - githubPath: ["microsoft", "typescript", "tree", "main", "src"], - }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/microsoft/typescript/tree/main/src" - ); - expect(mockedRedirect).toHaveBeenCalledWith("/microsoft/typescript"); - }); - - it("handles single segment paths", async () => { - mockedParseGitHubURL.mockReturnValue("nodejs/node"); - - const params = Promise.resolve({ githubPath: ["nodejs", "node"] }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/nodejs/node" - ); - expect(mockedRedirect).toHaveBeenCalledWith("/nodejs/node"); - }); - }); - - describe("when GitHub path is invalid", () => { - it("returns error message when parseGitHubURL returns null", async () => { - mockedParseGitHubURL.mockReturnValue(null); - - const params = Promise.resolve({ githubPath: ["invalid"] }); - - const result = await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/invalid" - ); - expect(mockedRedirect).not.toHaveBeenCalled(); - // Check that it returns JSX with the error message - expect(result).toBeDefined(); - expect(result.type).toBe("div"); - expect(result.props.children).toBe( - "Invalid GitHub URL. Please enter a valid GitHub repository." - ); - }); - - it("returns error message for empty path", async () => { - mockedParseGitHubURL.mockReturnValue(null); - - const params = Promise.resolve({ githubPath: [] }); - - const result = await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith("https://github.com/"); - expect(mockedRedirect).not.toHaveBeenCalled(); - // Check that it returns JSX with the error message - expect(result).toBeDefined(); - expect(result.type).toBe("div"); - expect(result.props.children).toBe( - "Invalid GitHub URL. Please enter a valid GitHub repository." - ); - }); - - it("returns error message for malformed repository names", async () => { - mockedParseGitHubURL.mockReturnValue(null); - - const params = Promise.resolve({ - githubPath: ["not-a-valid-repo-format"], - }); - - const result = await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/not-a-valid-repo-format" - ); - expect(mockedRedirect).not.toHaveBeenCalled(); - // Check that it returns JSX with the error message - expect(result).toBeDefined(); - expect(result.type).toBe("div"); - expect(result.props.children).toBe( - "Invalid GitHub URL. Please enter a valid GitHub repository." - ); - }); - }); - - describe("edge cases", () => { - it("handles URLs with encoded characters", async () => { - mockedParseGitHubURL.mockReturnValue("user/repo-with-special-chars"); - - const params = Promise.resolve({ - githubPath: ["user", "repo%20with%20spaces"], - }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/user/repo%20with%20spaces" - ); - expect(mockedRedirect).toHaveBeenCalledWith( - "/user/repo-with-special-chars" - ); - }); - - it("handles very long paths", async () => { - mockedParseGitHubURL.mockReturnValue("org/repo"); - - const longPath = [ - "org", - "repo", - "tree", - "main", - "src", - "components", - "ui", - "button", - ]; - const params = Promise.resolve({ githubPath: longPath }); - - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - `https://github.com/${longPath.join("/")}` - ); - expect(mockedRedirect).toHaveBeenCalledWith("/org/repo"); - }); - - it("handles path with only organization name", async () => { - mockedParseGitHubURL.mockReturnValue(null); - - const params = Promise.resolve({ githubPath: ["facebook"] }); - - const result = await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith( - "https://github.com/facebook" - ); - expect(mockedRedirect).not.toHaveBeenCalled(); - // Check that it returns JSX with the error message - expect(result).toBeDefined(); - expect(result.type).toBe("div"); - expect(result.props.children).toBe( - "Invalid GitHub URL. Please enter a valid GitHub repository." - ); - }); - }); - - describe("parseGitHubURL integration", () => { - it("properly constructs GitHub URLs for parsing", async () => { - const testCases = [ - { - input: ["microsoft", "typescript"], - expected: "https://github.com/microsoft/typescript", - }, - { - input: ["vercel", "next.js", "blob", "main", "README.md"], - expected: "https://github.com/vercel/next.js/blob/main/README.md", - }, - { - input: ["facebook", "react", "issues"], - expected: "https://github.com/facebook/react/issues", - }, - ]; - - for (const testCase of testCases) { - mockedParseGitHubURL.mockReturnValue("owner/repo"); - - const params = Promise.resolve({ githubPath: testCase.input }); - await GitHubPathPage({ params }); - - expect(mockedParseGitHubURL).toHaveBeenCalledWith(testCase.expected); - } - }); - }); -}); diff --git a/app/[...githubPath]/page.tsx b/app/[...githubPath]/page.tsx deleted file mode 100644 index 209b3c9..0000000 --- a/app/[...githubPath]/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { redirect } from "next/navigation"; -import { parseGitHubURL } from "@/utils/github"; - -export default async function GitHubPathPage({ - params, -}: { - params: Promise<{ githubPath: string[] }>; -}) { - const githubPath = (await params).githubPath; - const fullPath = githubPath.join("/"); - const repoName = parseGitHubURL(`https://github.com/${fullPath}`); - - if (repoName) { - // Split the repo name into org and repo components and redirect to the correct route - const [org, repo] = repoName.split("/"); - return redirect(`/${org}/${repo}`); - } - - return
Invalid GitHub URL. Please enter a valid GitHub repository.
; -} diff --git a/app/[org]/[repo]/loading.tsx b/app/[org]/[repo]/loading.tsx deleted file mode 100644 index 3f75c3a..0000000 --- a/app/[org]/[repo]/loading.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Skeleton } from "@/app/components/Skeleton"; - -export default function Loading() { - return ( -
- -
-
-
-
- {[...Array(4)].map((_, i) => ( - - ))} -
-
-
- {[...Array(10)].map((_, i) => ( -
-
- {[...Array(4)].map((_, j) => ( - - ))} -
-
- ))} -
-
-
-
- ); -} diff --git a/app/[org]/[repo]/page.test.tsx b/app/[org]/[repo]/page.test.tsx deleted file mode 100644 index 59cb97c..0000000 --- a/app/[org]/[repo]/page.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { generateMetadata } from "./page"; - -describe("RepoPage", () => { - describe("generateMetadata", () => { - it("generates correct metadata for repository", async () => { - const params = Promise.resolve({ org: "facebook", repo: "react" }); - - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: "facebook/react Related Repos - GitRelate(d)", - }); - }); - - it("generates metadata for different repository names", async () => { - const testCases = [ - { - org: "microsoft", - repo: "typescript", - expected: "microsoft/typescript Related Repos - GitRelate(d)", - }, - { - org: "vercel", - repo: "next.js", - expected: "vercel/next.js Related Repos - GitRelate(d)", - }, - { - org: "nodejs", - repo: "node", - expected: "nodejs/node Related Repos - GitRelate(d)", - }, - { - org: "facebook", - repo: "react-native", - expected: "facebook/react-native Related Repos - GitRelate(d)", - }, - ]; - - for (const { org, repo, expected } of testCases) { - const params = Promise.resolve({ org, repo }); - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: expected, - }); - } - }); - - it("handles special characters in repository names", async () => { - const params = Promise.resolve({ - org: "user", - repo: "repo-with-special.chars_123", - }); - - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: "user/repo-with-special.chars_123 Related Repos - GitRelate(d)", - }); - }); - - it("handles repository names with dots", async () => { - const params = Promise.resolve({ org: "vercel", repo: "next.js" }); - - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: "vercel/next.js Related Repos - GitRelate(d)", - }); - }); - - it("handles repository names with underscores and hyphens", async () => { - const params = Promise.resolve({ org: "some-org", repo: "my_repo" }); - - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: "some-org/my_repo Related Repos - GitRelate(d)", - }); - }); - - it("handles long repository names", async () => { - const params = Promise.resolve({ - org: "organization-with-long-name", - repo: "repository-with-very-long-name-containing-multiple-words", - }); - - const metadata = await generateMetadata({ params }); - - expect(metadata).toEqual({ - title: - "organization-with-long-name/repository-with-very-long-name-containing-multiple-words Related Repos - GitRelate(d)", - }); - }); - }); -}); diff --git a/app/[org]/[repo]/page.tsx b/app/[org]/[repo]/page.tsx deleted file mode 100644 index 7f17f65..0000000 --- a/app/[org]/[repo]/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { getRelatedReposCached as getRelatedRepos } from "@/lib/repos"; -import RepoTable from "@/app/components/RepoTable"; - -export default async function RepoPage({ - params, -}: { - params: Promise<{ org: string; repo: string }>; -}) { - const { org, repo } = await params; - const repoName = `${org}/${repo}`; - - const useClient = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true"; - - if (useClient) { - return ( -
-

- Related Repositories for {repoName} -

- -
- ); - } - - try { - const relatedRepos = await getRelatedRepos(repoName, 0); - - if (!relatedRepos.length) { - return ( -
-

- No Related Repositories Found -

-

- We couldn't find any related repositories for {repoName}. This - could be because: -

-
    -
  • The repository is too new
  • -
  • It has very few stargazers
  • -
  • The data is still being processed
  • -
-
- ); - } - - return ( -
-

- Related Repositories for {repoName} -

- -
- ); - } catch (error) { - const err = error as Error; - console.error("Error fetching related repos:", err); - return ( -
-

Error Loading Data

-

- {err.message === "Request timed out. Please try again." - ? "The request took too long to complete. Please try again or try a different repository." - : "There was an error loading the related repositories. Please try again later."} -

-
- ); - } -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ org: string; repo: string }>; -}) { - const { org, repo } = await params; - const repoName = `${org}/${repo}`; - return { - title: `${repoName} Related Repos - GitRelate(d)`, - }; -} diff --git a/app/actions.ts b/app/actions.ts deleted file mode 100644 index 1e9f127..0000000 --- a/app/actions.ts +++ /dev/null @@ -1,7 +0,0 @@ -"use server"; - -import { getRelatedReposCached } from "@/lib/repos"; - -export async function fetchMoreRelatedRepos(repoName: string, offset: number) { - return await getRelatedReposCached(repoName, offset); -} diff --git a/app/api/stargazers/route.test.ts b/app/api/stargazers/route.test.ts deleted file mode 100644 index 8c20851..0000000 --- a/app/api/stargazers/route.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GET } from "./route"; - -vi.mock("@/lib/github", () => ({ - getStargazers: vi.fn(), -})); - -import { getStargazers } from "@/lib/github"; - -const mockedGetStargazers = vi.mocked(getStargazers); - -describe("Stargazers API Route", () => { - const originalEnv = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - // Restore original environment variable - if (originalEnv !== undefined) { - process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = originalEnv; - } else { - delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; - } - vi.resetAllMocks(); - }); - - describe("when client-side fetching is enabled", () => { - beforeEach(() => { - process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "true"; - }); - - it("returns informational message about client-side fetching", async () => { - const request = new NextRequest( - "http://localhost:3000/api/stargazers?repo=owner/repo" - ); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - message: - "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.", - suggestion: - "This API endpoint is available but not recommended when client-side fetching is enabled.", - clientFetchingEnabled: true, - }); - - // Should not call the actual API function - expect(mockedGetStargazers).not.toHaveBeenCalled(); - }); - - it("returns the same message regardless of query parameters", async () => { - const request = new NextRequest("http://localhost:3000/api/stargazers"); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.clientFetchingEnabled).toBe(true); - expect(mockedGetStargazers).not.toHaveBeenCalled(); - }); - }); - - describe("when client-side fetching is disabled", () => { - beforeEach(() => { - process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "false"; - }); - - it("returns stargazers data when repo parameter is provided", async () => { - const mockStargazers = [ - { login: "user1", avatar_url: "https://github.com/user1.png" }, - { login: "user2", avatar_url: "https://github.com/user2.png" }, - ]; - - mockedGetStargazers.mockResolvedValue(mockStargazers); - - const request = new NextRequest( - "http://localhost:3000/api/stargazers?repo=facebook/react" - ); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual(mockStargazers); - expect(mockedGetStargazers).toHaveBeenCalledWith("facebook/react"); - }); - - it("returns 400 error when repo parameter is missing", async () => { - const request = new NextRequest("http://localhost:3000/api/stargazers"); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toEqual({ error: "Repo parameter is required" }); - expect(mockedGetStargazers).not.toHaveBeenCalled(); - }); - - it("returns 400 error when repo parameter is empty", async () => { - const request = new NextRequest( - "http://localhost:3000/api/stargazers?repo=" - ); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toEqual({ error: "Repo parameter is required" }); - expect(mockedGetStargazers).not.toHaveBeenCalled(); - }); - - it("handles errors from getStargazers function", async () => { - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - const error = new Error("GitHub API error"); - mockedGetStargazers.mockRejectedValue(error); - - const request = new NextRequest( - "http://localhost:3000/api/stargazers?repo=owner/repo" - ); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data).toEqual({ error: "Failed to fetch stargazers" }); - expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo"); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error fetching stargazers:", - error - ); - - consoleErrorSpy.mockRestore(); - }); - - it("handles various repo name formats", async () => { - const mockStargazers = [ - { login: "user1", avatar_url: "https://github.com/user1.png" }, - ]; - mockedGetStargazers.mockResolvedValue(mockStargazers); - - const testCases = [ - "microsoft/typescript", - "vercel/next.js", - "facebook/react-native", - "nodejs/node", - ]; - - for (const repo of testCases) { - const request = new NextRequest( - `http://localhost:3000/api/stargazers?repo=${repo}` - ); - const response = await GET(request); - - expect(response.status).toBe(200); - expect(mockedGetStargazers).toHaveBeenCalledWith(repo); - } - }); - }); - - describe("when client-side fetching environment variable is not set", () => { - beforeEach(() => { - delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; - }); - - it("falls back to server-side implementation", async () => { - const mockStargazers = [ - { login: "user1", avatar_url: "https://github.com/user1.png" }, - ]; - mockedGetStargazers.mockResolvedValue(mockStargazers); - - const request = new NextRequest( - "http://localhost:3000/api/stargazers?repo=owner/repo" - ); - - const response = await GET(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual(mockStargazers); - expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo"); - }); - }); -}); diff --git a/app/api/stargazers/route.ts b/app/api/stargazers/route.ts deleted file mode 100644 index 4c10016..0000000 --- a/app/api/stargazers/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getStargazers } from "@/lib/github"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(req: NextRequest) { - // Check if client-side fetching is enabled - if (process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true") { - return NextResponse.json( - { - message: - "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.", - suggestion: - "This API endpoint is available but not recommended when client-side fetching is enabled.", - clientFetchingEnabled: true, - }, - { status: 200 } - ); - } - - // Client-side fetching is disabled, use original API implementation - const { searchParams } = new URL(req.url); - const repo = searchParams.get("repo"); - - if (!repo) { - return NextResponse.json( - { error: "Repo parameter is required" }, - { status: 400 } - ); - } - - try { - const stargazers = await getStargazers(repo); - return NextResponse.json(stargazers); - } catch (error) { - console.error("Error fetching stargazers:", error); - return NextResponse.json( - { error: "Failed to fetch stargazers" }, - { status: 500 } - ); - } -} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 283855b..aea1f09 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,4 +1,4 @@ -import Link from "next/link"; +import { Link } from "react-router"; export default function Header() { return ( @@ -7,7 +7,7 @@ export default function Header() {

GitRelate(d) diff --git a/app/components/RepoFilter.tsx b/app/components/RepoFilter.tsx index ea76680..039ba8d 100644 --- a/app/components/RepoFilter.tsx +++ b/app/components/RepoFilter.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { Switch } from "@headlessui/react"; diff --git a/app/components/RepoInput.tsx b/app/components/RepoInput.tsx index a1b85f0..1aa72fe 100644 --- a/app/components/RepoInput.tsx +++ b/app/components/RepoInput.tsx @@ -1,15 +1,13 @@ -"use client"; - import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useNavigate } from "react-router"; import { parseGitHubURL } from "@/utils/github"; export default function RepoInput() { const [repo, setRepo] = useState(""); - const router = useRouter(); + const navigate = useNavigate(); const handleSearch = () => { - if (repo) router.push(`/${repo}`); + if (repo) navigate(`/${repo}`); }; const handleInputChange = (e: React.ChangeEvent) => { diff --git a/app/components/RepoTable.tsx b/app/components/RepoTable.tsx index 6bfc1a1..570fe32 100644 --- a/app/components/RepoTable.tsx +++ b/app/components/RepoTable.tsx @@ -1,15 +1,12 @@ -"use client"; - import { useState, useEffect } from "react"; import { ArrowUpDown, Search } from "lucide-react"; -import Link from "next/link"; +import { Link } from "react-router"; import type { RelatedRepo } from "@/types/github"; -import { fetchMoreRelatedRepos } from "@/app/actions"; import { getRelatedReposClient, clearExpiredCache } from "@/lib/repos.client"; import { Skeleton } from "@/app/components/Skeleton"; // import RepoFilter from './RepoFilter'; -// Loading skeleton component for the repo table that matches the server-side loading +// Loading skeleton component for the repo table function RepoTableSkeleton() { return ( //
@@ -42,41 +39,24 @@ function RepoTableSkeleton() { ); } -export default function RepoTable({ - initialRelatedRepos = [], - repoName = "", -}: { - initialRelatedRepos?: RelatedRepo[]; - repoName?: string; -}) { - const [relatedRepos, setRelatedRepos] = - useState(initialRelatedRepos); - // const [filteredRepos, setFilteredRepos] = useState(initialRelatedRepos); - const [offset, setOffset] = useState( - initialRelatedRepos?.length || 0 - ); +export default function RepoTable({ repoName = "" }: { repoName?: string }) { + const [relatedRepos, setRelatedRepos] = useState([]); + // const [filteredRepos, setFilteredRepos] = useState([]); + const [offset, setOffset] = useState(0); const [initialLoading, setInitialLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(""); - const useClient = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true"; - // Determine if we should show loading initially const shouldShowInitialLoading = - useClient && - repoName && - initialRelatedRepos.length === 0 && - relatedRepos.length === 0 && - !error; + repoName && relatedRepos.length === 0 && !error; useEffect(() => { - // Clear expired cache entries on component mount when using client-side fetching - if (useClient) { - clearExpiredCache(); - } + // Clear expired cache entries on component mount + clearExpiredCache(); - if (useClient && repoName && relatedRepos.length === 0) { + if (repoName && relatedRepos.length === 0) { setInitialLoading(true); setError(""); getRelatedReposClient(repoName, 0) @@ -93,7 +73,7 @@ export default function RepoTable({ setInitialLoading(false); }); } - }, [useClient, repoName, relatedRepos.length]); + }, [repoName, relatedRepos.length]); const [sortColumn, setSortColumn] = useState("stargazers"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); @@ -153,9 +133,7 @@ export default function RepoTable({ const handleShowMore = async () => { setLoadingMore(true); try { - const moreRepos = useClient - ? await getRelatedReposClient(repoName, offset) - : await fetchMoreRelatedRepos(repoName, offset); + const moreRepos = await getRelatedReposClient(repoName, offset); if (moreRepos.length === 0 || moreRepos.length < 100) { setHasMore(false); } @@ -261,8 +239,7 @@ export default function RepoTable({ {repo.repoName} diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx index 2ffee67..01f1969 100644 --- a/app/components/ThemeToggle.tsx +++ b/app/components/ThemeToggle.tsx @@ -1,21 +1,9 @@ -"use client"; - import { Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { useTheme } from "@/app/theme"; export function ThemeToggle() { - const [mounted, setMounted] = useState(false); const { resolvedTheme, setTheme } = useTheme(); - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return
; // placeholder to prevent layout shift - } - return (