diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 912dd0ed..ab97f969 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -78,6 +78,7 @@ type LeaderboardMetric = "streak" | "commits" | "prs"; interface PublicUser { id: string; github_login: string; + is_sponsor: boolean; } interface LeaderboardEntry { @@ -89,6 +90,7 @@ interface LeaderboardEntry { commits: number; prs: number; score: number; + isSponsor: boolean; } interface LeaderboardPayload { @@ -251,7 +253,7 @@ async function fetchPrCount(username: string, since: string): Promise { async function buildLeaderboard(): Promise { const { data: users, error } = await supabaseAdmin .from("users") - .select("id, github_login") + .select("id, github_login, is_sponsor") .eq("is_public", true) .eq("leaderboard_opt_in", true) .limit(50); @@ -292,6 +294,7 @@ async function buildLeaderboard(): Promise { commits, prs, score, + isSponsor: user.is_sponsor ?? false, }; } ); diff --git a/src/app/api/sponsors/sync/route.ts b/src/app/api/sponsors/sync/route.ts new file mode 100644 index 00000000..aebbba8b --- /dev/null +++ b/src/app/api/sponsors/sync/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const authHeader = req.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 }); + } + + if (authHeader !== `Bearer ${cronSecret}` && process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = process.env.GITHUB_TOKEN; + if (!token) { + return NextResponse.json({ error: "No GitHub token configured" }, { status: 500 }); + } + + const targetOwner = "Priyanshu-byte-coder"; + + try { + const query = ` + query { + user(login: "${targetOwner}") { + sponsorshipsAsMaintainer(first: 100) { + nodes { + sponsorEntity { + ... on User { + login + } + ... on Organization { + login + } + } + } + } + } + } + `; + + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + cache: "no-store", + }); + + if (!res.ok) { + console.error("Failed to fetch sponsors:", res.status); + return NextResponse.json({ error: "GitHub API error" }, { status: 502 }); + } + + const { data, errors } = await res.json(); + + if (errors && errors.length > 0) { + console.error("GraphQL errors:", errors); + return NextResponse.json({ error: "GraphQL query failed" }, { status: 502 }); + } + + if (!data || !data.user) { + console.error("GraphQL returned empty data or null user"); + return NextResponse.json({ error: "GraphQL query returned no user data" }, { status: 502 }); + } + + const sponsorLogins: string[] = []; + + if (data.user.sponsorshipsAsMaintainer?.nodes) { + const nodes = data.user.sponsorshipsAsMaintainer.nodes; + for (const node of nodes) { + if (node.sponsorEntity?.login) { + sponsorLogins.push(node.sponsorEntity.login); + } + } + } + + const { data: currentSponsors, error: fetchErr } = await supabaseAdmin + .from("users") + .select("github_login") + .eq("is_sponsor", true); + + if (fetchErr) { + console.error("Failed to fetch current sponsors:", fetchErr); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + const currentLogins = new Set( + (currentSponsors || []).map((u: any) => String(u.github_login)) + ); + const newLogins = new Set(sponsorLogins); + + const toRemove = [...currentLogins].filter((login: string) => !newLogins.has(login)); + const toAdd = [...newLogins].filter((login: string) => !currentLogins.has(login)); + + if (toRemove.length > 0) { + const { error } = await supabaseAdmin + .from("users") + .update({ is_sponsor: false }) + .in("github_login", toRemove); + + if (error) { + console.error("Failed to remove sponsors:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + } + + if (toAdd.length > 0) { + const { error } = await supabaseAdmin + .from("users") + .update({ is_sponsor: true }) + .in("github_login", toAdd); + + if (error) { + console.error("Failed to add sponsors:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + } + + return NextResponse.json({ + success: true, + sponsorCount: sponsorLogins.length, + sponsors: sponsorLogins + }); + } catch (error) { + console.error("Error in sponsors sync:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index bf33d2b2..f0b2d769 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import EmptyState from "@/components/EmptyState"; +import SponsorBadge from "@/components/SponsorBadge"; type LeaderboardTab = "streak" | "commits" | "prs"; @@ -12,6 +13,7 @@ interface LeaderboardEntry { commits: number; prs: number; score: number; + isSponsor: boolean; } interface LeaderboardPayload { @@ -152,9 +154,10 @@ export default async function LeaderboardPage({
@{entry.username} + {entry.isSponsor && }
{entry.commits} commits ยท {entry.prs} PRs ยท {entry.streak}d diff --git a/src/app/page.tsx b/src/app/page.tsx index 1d3e6d82..504297de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; import LandingPage, { type RepoStats } from "@/components/landing/LandingPage"; +import { supabaseAdmin } from "@/lib/supabase"; const syne = Syne({ subsets: ["latin"], @@ -43,19 +44,39 @@ async function fetchRepoStats(): Promise { const contributors = contribRes.ok ? ((await contribRes.json()) as Array>) : []; const gfiIssues = gfiRes.ok ? ((await gfiRes.json()) as unknown[]) : []; + let mappedContributors = Array.isArray(contributors) + ? contributors.slice(0, 20).map((c) => ({ + login: String(c.login ?? ""), + avatar_url: String(c.avatar_url ?? ""), + html_url: String(c.html_url ?? ""), + isSponsor: false, + })) + : []; + + if (mappedContributors.length > 0 && supabaseAdmin) { + const logins = mappedContributors.map((c) => c.login); + const { data: sponsors } = await supabaseAdmin + .from("users") + .select("github_login") + .in("github_login", logins) + .eq("is_sponsor", true); + + if (sponsors && sponsors.length > 0) { + const sponsorSet = new Set(sponsors.map((s) => s.github_login)); + mappedContributors = mappedContributors.map((c) => ({ + ...c, + isSponsor: sponsorSet.has(c.login), + })); + } + } + return { stars: typeof repo.stargazers_count === "number" ? repo.stargazers_count : 0, forks: typeof repo.forks_count === "number" ? repo.forks_count : 0, openIssues: typeof repo.open_issues_count === "number" ? repo.open_issues_count : 0, contributorCount: Array.isArray(contributors) ? contributors.length : 0, goodFirstIssues: Array.isArray(gfiIssues) ? gfiIssues.length : 0, - contributors: Array.isArray(contributors) - ? contributors.slice(0, 20).map((c) => ({ - login: String(c.login ?? ""), - avatar_url: String(c.avatar_url ?? ""), - html_url: String(c.html_url ?? ""), - })) - : [], + contributors: mappedContributors, }; } catch { return { diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 8b35b395..064b1f01 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -5,6 +5,7 @@ import GitHubAchievements from "@/components/GitHubAchievements"; import StatsCard from "@/components/StatsCard"; import ShareProfileSection from "@/components/ShareProfileSection"; import ThemeToggle from "@/components/ThemeToggle"; +import SponsorBadge from "@/components/SponsorBadge"; import { getUserByUsername } from "@/lib/supabase"; import { syncGitHubAchievementsForUser } from "@/lib/github-achievements"; @@ -50,6 +51,7 @@ async function fetchPublicProfile( return { username: user.github_login, userId: user.id, + isSponsor: user.is_sponsor ?? false, repos, contributions, streak, @@ -148,9 +150,12 @@ export default async function PublicProfilePage({
-

- @{profile.username}'s Profile -

+
+

+ @{profile.username}'s Profile + {profile.isSponsor && } +

+

GitHub activity and coding stats

diff --git a/src/components/LanguageBreakdown.tsx b/src/components/LanguageBreakdown.tsx index 24b8c207..74c9397e 100644 --- a/src/components/LanguageBreakdown.tsx +++ b/src/components/LanguageBreakdown.tsx @@ -31,6 +31,7 @@ function getColor(name: string): string { function LanguageDot({ color, label }: { color: string; label: string }) { return ( + {label} ); diff --git a/src/components/SponsorBadge.tsx b/src/components/SponsorBadge.tsx new file mode 100644 index 00000000..4bba117f --- /dev/null +++ b/src/components/SponsorBadge.tsx @@ -0,0 +1,11 @@ +export default function SponsorBadge({ className = "" }: { className?: string }) { + return ( + + Sponsor + + ); +} diff --git a/src/components/landing/LandingPage.tsx b/src/components/landing/LandingPage.tsx index 2dba147e..ad766c79 100644 --- a/src/components/landing/LandingPage.tsx +++ b/src/components/landing/LandingPage.tsx @@ -11,7 +11,7 @@ export type RepoStats = { openIssues: number; contributorCount: number; goodFirstIssues: number; - contributors: Array<{ login: string; avatar_url: string; html_url: string }>; + contributors: Array<{ login: string; avatar_url: string; html_url: string; isSponsor?: boolean }>; }; /* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -772,10 +772,10 @@ function ContributeSection({ stats }: { stats: RepoStats }) { href={c.html_url} target="_blank" rel="noopener noreferrer" - title={`@${c.login}`} + title={c.isSponsor ? `@${c.login} (Sponsor ๐Ÿ’Ž)` : `@${c.login}`} style={{ width: 38, height: 38, borderRadius: '50%', - border: `2px solid ${BG}`, + border: `2px solid ${c.isSponsor ? '#ec4899' : BG}`, marginLeft: i > 0 ? -11 : 0, overflow: 'hidden', display: 'block', position: 'relative', zIndex: stats.contributors.length - i, diff --git a/src/lib/public-profile-data.ts b/src/lib/public-profile-data.ts index 6ca05c43..9067838b 100644 --- a/src/lib/public-profile-data.ts +++ b/src/lib/public-profile-data.ts @@ -25,6 +25,7 @@ export interface StreakData { export interface PublicProfileData { username: string; userId: string; + isSponsor: boolean; repos: TopRepo[]; contributions: ContributionData; streak: StreakData; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 5b9ac43e..15333b04 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -6,7 +6,7 @@ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; export const SUPABASE_ADMIN_UNAVAILABLE_MESSAGE = "Supabase admin client is unavailable. Check NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY."; -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// eslint-disable-next-line type SupabaseAdminClient = SupabaseClient; function createUnavailableSupabaseAdmin(): SupabaseAdminClient { @@ -31,6 +31,7 @@ interface User { is_public: boolean; created_at: string; updated_at: string; + is_sponsor?: boolean; } /** @@ -43,7 +44,7 @@ export async function getUserByUsername( try { const { data, error } = await supabaseAdmin .from("users") - .select("id,github_id,github_login,is_public,created_at,updated_at") + .select("id,github_id,github_login,is_public,created_at,updated_at,is_sponsor") .ilike("github_login", username) .eq("is_public", true) .single(); diff --git a/supabase/migrations/20260527000000_add_is_sponsor.sql b/supabase/migrations/20260527000000_add_is_sponsor.sql new file mode 100644 index 00000000..3c08d640 --- /dev/null +++ b/supabase/migrations/20260527000000_add_is_sponsor.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_sponsor BOOLEAN DEFAULT FALSE; diff --git a/supabase/schema.sql b/supabase/schema.sql index 248748f7..505f2ec0 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -8,7 +8,8 @@ create table if not exists users ( created_at timestamptz default now(), updated_at timestamptz default now(), wakatime_api_key_encrypted text, - wakatime_api_key_iv text + wakatime_api_key_iv text, + is_sponsor boolean default false ); create table if not exists goals ( diff --git a/test/github-accounts.test.ts b/test/github-accounts.test.ts index 3e15e56e..42578d01 100644 --- a/test/github-accounts.test.ts +++ b/test/github-accounts.test.ts @@ -173,7 +173,7 @@ describe('mergeMetrics', () => { it('returns null for empty results array', async () => { const { mergeMetrics } = await import('../src/lib/github-accounts'); - const merged = mergeMetrics([], (a, b) => ({ count: a.count + b.count })); + const merged = mergeMetrics([], (a: { count: number }, b: { count: number }) => ({ count: a.count + b.count })); expect(merged).toBeNull(); }); diff --git a/vercel.json b/vercel.json index 35b3af73..bff3d114 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/goals/sync", "schedule": "0 0 * * *" + }, + { + "path": "/api/sponsors/sync", + "schedule": "0 0 * * *" } ] }