Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ type LeaderboardMetric = "streak" | "commits" | "prs";
interface PublicUser {
id: string;
github_login: string;
is_sponsor: boolean;
}

interface LeaderboardEntry {
Expand All @@ -89,6 +90,7 @@ interface LeaderboardEntry {
commits: number;
prs: number;
score: number;
isSponsor: boolean;
}

interface LeaderboardPayload {
Expand Down Expand Up @@ -251,7 +253,7 @@ async function fetchPrCount(username: string, since: string): Promise<number> {
async function buildLeaderboard(): Promise<LeaderboardPayload> {
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);
Expand Down Expand Up @@ -292,6 +294,7 @@ async function buildLeaderboard(): Promise<LeaderboardPayload> {
commits,
prs,
score,
isSponsor: user.is_sponsor ?? false,
};
}
);
Expand Down
134 changes: 134 additions & 0 deletions src/app/api/sponsors/sync/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Comment on lines +6 to +16

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<string>(
(currentSponsors || []).map((u: any) => String(u.github_login))
);
const newLogins = new Set<string>(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 });
}
}
5 changes: 4 additions & 1 deletion src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from "next/link";
import EmptyState from "@/components/EmptyState";
import SponsorBadge from "@/components/SponsorBadge";

type LeaderboardTab = "streak" | "commits" | "prs";

Expand All @@ -12,6 +13,7 @@ interface LeaderboardEntry {
commits: number;
prs: number;
score: number;
isSponsor: boolean;
}

interface LeaderboardPayload {
Expand Down Expand Up @@ -152,9 +154,10 @@ export default async function LeaderboardPage({
<div className="min-w-0">
<div
title={entry.username}
className="block max-w-[120px] truncate font-semibold text-[var(--card-foreground)] sm:max-w-[180px] md:max-w-none"
className="flex items-center gap-2 max-w-[120px] truncate font-semibold text-[var(--card-foreground)] sm:max-w-[180px] md:max-w-none"
>
@{entry.username}
{entry.isSponsor && <SponsorBadge />}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{entry.commits} commits · {entry.prs} PRs · {entry.streak}d
Expand Down
35 changes: 28 additions & 7 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -43,19 +44,39 @@ async function fetchRepoStats(): Promise<RepoStats> {
const contributors = contribRes.ok ? ((await contribRes.json()) as Array<Record<string, unknown>>) : [];
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 {
Expand Down
11 changes: 8 additions & 3 deletions src/app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,6 +51,7 @@ async function fetchPublicProfile(
return {
username: user.github_login,
userId: user.id,
isSponsor: user.is_sponsor ?? false,
repos,
contributions,
streak,
Expand Down Expand Up @@ -148,9 +150,12 @@ export default async function PublicProfilePage({
<div className="min-h-screen bg-[var(--background)] p-4 text-[var(--foreground)] transition-colors md:p-8">
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl md:text-4xl font-bold text-[var(--foreground)]">
@{profile.username}&apos;s Profile
</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--foreground)] flex items-center gap-2">
@{profile.username}&apos;s Profile
{profile.isSponsor && <SponsorBadge />}
</h1>
</div>
<p className="mt-2 text-[var(--muted-foreground)]">
GitHub activity and coding stats
</p>
Expand Down
1 change: 1 addition & 0 deletions src/components/LanguageBreakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function getColor(name: string): string {
function LanguageDot({ color, label }: { color: string; label: string }) {
return (
<svg width="0.75rem" height="0.75rem" viewBox="0 0 8 8" className="shrink-0" aria-label={label}>
<title>{label}</title>
<circle cx="4" cy="4" r="4" fill={color} />
</svg>
);
Expand Down
11 changes: 11 additions & 0 deletions src/components/SponsorBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function SponsorBadge({ className = "" }: { className?: string }) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border border-pink-500/30 bg-pink-500/10 px-2 py-0.5 text-xs font-semibold text-pink-500 shadow-sm ${className}`}
title="GitHub Sponsor — thank you for supporting DevTrack!"
aria-label="GitHub Sponsor"
>
<span aria-hidden="true">💎</span> Sponsor
</span>
);
}
6 changes: 3 additions & 3 deletions src/components/landing/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
};

/* ═══════════════════════════════════════════
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/public-profile-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface StreakData {
export interface PublicProfileData {
username: string;
userId: string;
isSponsor: boolean;
repos: TopRepo[];
contributions: ContributionData;
streak: StreakData;
Expand Down
5 changes: 3 additions & 2 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any, any>;

function createUnavailableSupabaseAdmin(): SupabaseAdminClient {
Expand All @@ -31,6 +31,7 @@ interface User {
is_public: boolean;
created_at: string;
updated_at: string;
is_sponsor?: boolean;
}

/**
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions supabase/migrations/20260527000000_add_is_sponsor.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_sponsor BOOLEAN DEFAULT FALSE;
3 changes: 2 additions & 1 deletion supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion test/github-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
4 changes: 4 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
{
"path": "/api/goals/sync",
"schedule": "0 0 * * *"
},
{
"path": "/api/sponsors/sync",
"schedule": "0 0 * * *"
}
]
}
Loading