diff --git a/src/app/api/public/[username]/route.ts b/src/app/api/public/[username]/route.ts index 8a2a1531..47ce3493 100644 --- a/src/app/api/public/[username]/route.ts +++ b/src/app/api/public/[username]/route.ts @@ -1,10 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { getUserByUsername } from "@/lib/supabase"; -import { - fetchPublicTopRepos, - fetchPublicContributions, - fetchPublicStreak, -} from "@/lib/public-profile-data"; +import { fetchPublicProfile } from "@/lib/public-profile-data"; import { getUpstashConfig, upstashRateLimitFixedWindow } from "@/lib/upstash-rest"; export const dynamic = "force-dynamic"; @@ -92,31 +87,14 @@ export async function GET( ); } - // Look up user in Supabase - const user = await getUserByUsername(username); + const profile = await fetchPublicProfile(username); - if (!user) { + if (!profile) { return NextResponse.json( { error: "User not found or profile is not public" }, { status: 404 } ); } - // Use GITHUB_TOKEN env var if available for higher rate limits - const githubToken = process.env.GITHUB_TOKEN; - - // Fetch all metrics in parallel - const [repos, contributions, streak] = await Promise.all([ - fetchPublicTopRepos(user.github_login, githubToken, 30), - fetchPublicContributions(user.github_login, githubToken, 30), - fetchPublicStreak(user.github_login, githubToken), - ]); - - return NextResponse.json({ - username: user.github_login, - userId: user.id, - repos, - contributions, - streak, - }); + return NextResponse.json(profile); } diff --git a/src/app/compare/[users]/page.tsx b/src/app/compare/[users]/page.tsx new file mode 100644 index 00000000..8d51654a --- /dev/null +++ b/src/app/compare/[users]/page.tsx @@ -0,0 +1,419 @@ +import { Metadata } from "next"; +import { Scale, Trophy } from "lucide-react"; +import { normalizeGitHubUsername } from "@/lib/validate-github-username"; +import { + fetchPublicProfile, + type PublicLanguage, + type PublicProfileData, + type TopRepo, +} from "@/lib/public-profile-data"; + +export const dynamic = "force-dynamic"; + +interface ComparePageProps { + params: { users: string }; +} + +type Winner = "left" | "right" | "tie"; + +function parseUsers(users: string): [string, string] | null { + let decoded: string; + try { + decoded = decodeURIComponent(users); + } catch { + return null; + } + + const match = decoded.match(/^(.+)-vs-(.+)$/); + if (!match) return null; + + const left = normalizeGitHubUsername(match[1]); + const right = normalizeGitHubUsername(match[2]); + + if (!left || !right || left.toLowerCase() === right.toLowerCase()) { + return null; + } + + return [left, right]; +} + +function compareNumbers(left: number, right: number): Winner { + if (left === right) return "tie"; + return left > right ? "left" : "right"; +} + +function topLanguage(languages: PublicLanguage[]): string { + return languages[0]?.name ?? "No public data"; +} + +function repoCommitTotal(repos: TopRepo[]): number { + return repos.reduce((sum, repo) => sum + repo.commits, 0); +} + +export async function generateMetadata({ + params, +}: ComparePageProps): Promise { + const parsed = parseUsers(params.users); + if (!parsed) { + return { + title: "Compare Public Profiles", + description: "Compare public DevTrack profile stats.", + }; + } + + const [left, right] = parsed; + return { + title: `${left} vs ${right} - DevTrack Compare`, + description: `Side-by-side public DevTrack stats comparison for ${left} and ${right}.`, + }; +} + +export default async function PublicProfileComparePage({ + params, +}: ComparePageProps) { + const parsed = parseUsers(params.users); + + if (!parsed) { + return ; + } + + const [leftUsername, rightUsername] = parsed; + const [leftProfile, rightProfile] = await Promise.all([ + fetchPublicProfile(leftUsername), + fetchPublicProfile(rightUsername), + ]); + + if (!leftProfile || !rightProfile) { + return ( + + ); + } + + return ( +
+
+
+
+

+ Public Profile Compare +

+

+ @{leftProfile.username} vs @{rightProfile.username} +

+

+ Shareable comparison built only from publicly visible DevTrack stats. +

+
+ + View profile + +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
@{leftProfile.username}
+
+ Metric +
+
@{rightProfile.username}
+
+ +
+ + + + + + +
+
+ +
+ + + + +
+
+
+ ); +} + +function CompareUnavailable({ + title, + message = "Use /compare/user1-vs-user2 with two public DevTrack profiles.", +}: { + title: string; + message?: string; +}) { + return ( +
+
+

{title}

+

{message}

+ + Back to Home + +
+
+ ); +} + +function ProfileHeader({ + profile, + align = "left", +}: { + profile: PublicProfileData; + align?: "left" | "right"; +}) { + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + @{profile.username} + +

+ {profile.streak.current} day streak - {profile.contributions.total} commits +

+
+
+
+ ); +} + +function MetricRow({ + label, + left, + right, + suffix = "", +}: { + label: string; + left: number; + right: number; + suffix?: string; +}) { + const winner = compareNumbers(left, right); + + return ( +
+ +
+ {label} +
+ +
+ ); +} + +function LanguageRow({ left, right }: { left: string; right: string }) { + const tied = left === right; + + return ( +
+ +
+ Top language +
+ +
+ ); +} + +function Value({ + value, + state, + align = "left", +}: { + value: string; + state: "win" | "tie" | "neutral"; + align?: "left" | "right"; +}) { + const className = + state === "win" + ? "border-[var(--success)]/30 bg-[var(--success)]/10 text-[var(--success)]" + : state === "tie" + ? "border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)]" + : "border-transparent text-[var(--foreground)]"; + + return ( +
+ + {state === "win" && +
+ ); +} + +function LanguageCard({ + username, + languages, +}: { + username: string; + languages: PublicLanguage[]; +}) { + const total = languages.reduce((sum, language) => sum + language.count, 0); + + return ( +
+

+ @{username} Top Languages +

+ {languages.length === 0 ? ( +

+ No public language data available. +

+ ) : ( +
    + {languages.map((language) => ( +
  • +
    + + {language.name} + + + {language.count} repo{language.count !== 1 ? "s" : ""} + +
    +
    +
    +
    +
  • + ))} +
+ )} +
+ ); +} + +function ReposCard({ username, repos }: { username: string; repos: TopRepo[] }) { + const maxCommits = repos[0]?.commits ?? 1; + + return ( +
+

+ @{username} Top Repositories +

+ {repos.length === 0 ? ( +

+ No public repository data available. +

+ ) : ( +
    + {repos.map((repo, index) => { + const shortName = repo.name.split("/")[1] ?? repo.name; + const width = Math.max(Math.round((repo.commits / maxCommits) * 100), 4); + + return ( +
  • +
    + + + #{index + 1} + + {shortName} + + + {repo.commits} commit{repo.commits !== 1 ? "s" : ""} + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 3ac6d8e1..69c4dedb 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -2,68 +2,32 @@ export const dynamic = "force-dynamic"; import { Metadata } from "next"; import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; import BadgeSection from "@/components/BadgeSection"; 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"; import PinnedReposWidget from "@/components/PinnedReposWidget"; -import { fetchPinnedRepoDetails } from "@/lib/pinned-repos"; +import CopyLinkButton from "@/components/CopyLinkButton"; +import { authOptions } from "@/lib/auth"; +import { fetchPublicProfile } from "@/lib/public-profile-data"; +import { getUserByGithubId, getUserByUsername } from "@/lib/supabase"; +async function getLoggedInGitHubUsername() { + const session = await getServerSession(authOptions); - - -import { - fetchPublicTopRepos, - fetchPublicContributions, - fetchPublicStreak, - type PublicProfileData, -} from "@/lib/public-profile-data"; - -async function fetchPublicProfile( - username: string, - options: { includeAchievements?: boolean } = {} -): Promise { - const user = await getUserByUsername(username); - - if (!user) return null; - - const canonicalUsername = user.github_login.toLowerCase(); - - if (username !== canonicalUsername) { - redirect(`/u/${canonicalUsername}`); + if (typeof session?.githubLogin === "string" && session.githubLogin.trim()) { + return session.githubLogin; } - const githubToken = process.env.GITHUB_TOKEN || ""; - - const [repos, contributions, streak, achievementsCache, spotlight] = await Promise.all([ - fetchPublicTopRepos(user.github_login, githubToken, 30), - fetchPublicContributions(user.github_login, githubToken, 30), - fetchPublicStreak(user.github_login, githubToken), - options.includeAchievements - ? syncGitHubAchievementsForUser({ - userId: user.id, - githubLogin: user.github_login, - token: githubToken, - }) - : Promise.resolve({ achievements: [], syncedAt: null, error: null }), - fetchPinnedRepoDetails(user.github_login, user.pinned_repos || [], githubToken), - ]); + if (typeof session?.githubId === "string" && session.githubId.trim()) { + const user = await getUserByGithubId(session.githubId); + return user?.github_login ?? null; + } - return { - username: user.github_login, - userId: user.id, - isSponsor: user.is_sponsor ?? false, - repos, - contributions, - streak, - achievements: achievementsCache.achievements, - achievementsError: achievementsCache.error, - spotlightRepos: spotlight, - }; + return null; } function getProfileUrl(username: string) { @@ -116,7 +80,10 @@ export default async function PublicProfilePage({ params: { username: string }; }) { const { username } = params; - const profile = await fetchPublicProfile(username, { includeAchievements: true }); + const [profile, loggedInUsername] = await Promise.all([ + fetchPublicProfile(username, { includeAchievements: true }), + getLoggedInGitHubUsername(), + ]); const profileUrl = getProfileUrl(username); if (!profile) { @@ -150,8 +117,22 @@ export default async function PublicProfilePage({ ); } + const canonicalUsername = profile.username.toLowerCase(); + if (username !== canonicalUsername) { + redirect(`/u/${canonicalUsername}`); + } + const avatarUrl = `https://avatars.githubusercontent.com/${profile.username}`; const topRepo = profile.repos[0]?.name ?? ""; + const showCompareButton = + loggedInUsername !== null && + loggedInUsername.toLowerCase() !== profile.username.toLowerCase(); + const compareHref = showCompareButton + ? `/compare/${encodeURIComponent(loggedInUsername)}-vs-${encodeURIComponent(profile.username)}` + : null; + const signInToCompareHref = `/auth/signin?callbackUrl=${encodeURIComponent( + `/u/${profile.username}` + )}`; return (
@@ -162,13 +143,30 @@ export default async function PublicProfilePage({ @{profile.username}'s Profile {profile.isSponsor && } +

GitHub activity and coding stats

+ {compareHref && ( + + Compare with me + + )} + {!loggedInUsername && ( + + Log in to compare + + )} {/* Download stats card button — client component */} -
+
{ + const res = await ghFetch( + `${GITHUB_API}/users/${username}/repos?sort=updated&per_page=30`, + token + ); + + if (!res.ok) return []; + + const repos = (await res.json()) as Array<{ language: string | null }>; + const counts: Record = {}; + + for (const repo of repos) { + if (repo.language) { + counts[repo.language] = (counts[repo.language] ?? 0) + 1; + } + } + + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + if (total === 0) return []; + + return Object.entries(counts) + .map(([name, count]) => ({ + name, + count, + percentage: Math.round((count / total) * 1000) / 10, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); +} + +export async function fetchPublicPullRequests( + username: string, + token?: string +): Promise { + const res = await ghFetch( + `${GITHUB_API}/search/issues?q=type:pr+author:${username}&per_page=1`, + token + ); + + if (!res.ok) return 0; + + const data = (await res.json()) as { total_count?: number }; + return data.total_count ?? 0; +} + +export async function fetchPublicProfile( + username: string, + options: { includeAchievements?: boolean } = {} +): Promise { + const user = await getUserByUsername(username); + if (!user) return null; + + const githubToken = process.env.GITHUB_TOKEN; + const [ + repos, + contributions, + streak, + topLanguages, + pullRequests, + achievementsCache, + spotlight, + ] = await Promise.all([ + fetchPublicTopRepos(user.github_login, githubToken, 30), + fetchPublicContributions(user.github_login, githubToken, 30), + fetchPublicStreak(user.github_login, githubToken), + fetchPublicTopLanguages(user.github_login, githubToken), + fetchPublicPullRequests(user.github_login, githubToken), + options.includeAchievements + ? syncGitHubAchievementsForUser({ + userId: user.id, + githubLogin: user.github_login, + token: githubToken, + }) + : Promise.resolve({ achievements: [], syncedAt: null, error: null }), + fetchPinnedRepoDetails( + user.github_login, + user.pinned_repos || [], + githubToken || "" + ), + ]); + + return { + username: user.github_login, + userId: user.id, + isSponsor: user.is_sponsor ?? false, + repos, + contributions, + streak, + topLanguages, + pullRequests, + achievements: achievementsCache.achievements, + achievementsError: achievementsCache.error, + spotlightRepos: spotlight, + }; +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 0c157b1b..57cfcdfa 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -82,6 +82,37 @@ export async function getUserByUsername( } } +/** + * Look up a user by GitHub id. Used for authenticated server-rendered pages + * where the session has an id but may not have the login claim populated. + */ +export async function getUserByGithubId( + githubId: string +): Promise { + if (!supabaseAdmin) return null; + + try { + const { data, error } = await supabaseAdmin + .from("users") + .select("id,github_id,github_login,is_public,created_at,updated_at") + .eq("github_id", githubId) + .single(); + + if (error) { + if (error.code === "PGRST116") { + return null; + } + console.error("Error fetching user by GitHub id:", error); + return null; + } + + return data as User; + } catch (err) { + console.error("Unexpected error fetching user by GitHub id:", err); + return null; + } +} + /** * Update the is_public flag for a user. */