From a9afc8901f25a477f09b681481447eacb5f07376 Mon Sep 17 00:00:00 2001 From: pawanakapkv Date: Mon, 18 May 2026 01:07:58 +0530 Subject: [PATCH 1/2] feat: add repo pinning in TopRepos (#221) --- src/app/api/user/settings/route.ts | 71 ++++++++-------- src/components/TopRepos.tsx | 85 +++++++++++++++++-- ...260518000000_add_pinned_repos_to_users.sql | 10 +++ 3 files changed, 126 insertions(+), 40 deletions(-) create mode 100644 supabase/migrations/20260518000000_add_pinned_repos_to_users.sql diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index dc016c0c..d95fc9f6 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -1,7 +1,7 @@ import { getServerSession } from "next-auth"; import { NextRequest, NextResponse } from "next/server"; import { authOptions } from "@/lib/auth"; -import { supabaseAdmin, updateUserPublicFlag } from "@/lib/supabase"; +import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -12,22 +12,23 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Fetch user from Supabase const { data, error } = await supabaseAdmin .from("users") - .select("id, github_login, is_public") + .select("id, github_login, is_public, pinned_repos") .eq("github_id", session.githubId) .single(); if (error) { console.error("Error fetching user:", error); - return NextResponse.json( - { error: "Failed to fetch user settings" }, - { status: 500 } - ); + return NextResponse.json({ error: "Failed to fetch user settings" }, { status: 500 }); } - return NextResponse.json(data); + return NextResponse.json({ + id: data.id, + github_login: data.github_login, + is_public: data.is_public, + pinned_repos: data.pinned_repos || [], + }); } export async function PATCH(req: NextRequest) { @@ -37,7 +38,6 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Get user ID from Supabase const { data: user, error: fetchError } = await supabaseAdmin .from("users") .select("id") @@ -45,47 +45,50 @@ export async function PATCH(req: NextRequest) { .single(); if (fetchError || !user) { - console.error("Error fetching user:", fetchError); - return NextResponse.json( - { error: "User not found" }, - { status: 404 } - ); + return NextResponse.json({ error: "User not found" }, { status: 404 }); } - // Parse request body - let body: { is_public?: boolean }; + let body: { is_public?: boolean; pinned_repos?: string[] }; try { body = await req.json(); } catch { - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 } - ); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { is_public, pinned_repos } = body; + const updates: Record = {}; + + if (typeof is_public === "boolean") { + updates.is_public = is_public; } - const { is_public } = body; + if (Array.isArray(pinned_repos)) { + if (pinned_repos.length > 3) { + return NextResponse.json({ error: "Maximum 3 pins allowed" }, { status: 400 }); + } + updates.pinned_repos = pinned_repos; + } - if (typeof is_public !== "boolean") { - return NextResponse.json( - { error: "is_public must be a boolean" }, - { status: 400 } - ); + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }); } - // Update user public flag - const updated = await updateUserPublicFlag(user.id, is_public); + const { data: updated, error: updateError } = await supabaseAdmin + .from("users") + .update(updates) + .eq("id", user.id) + .select("id, github_login, is_public, pinned_repos") + .single(); - if (!updated) { - return NextResponse.json( - { error: "Failed to update settings" }, - { status: 500 } - ); + if (updateError || !updated) { + console.error("Update error:", updateError); + return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); } - // Return updated user (only safe fields) return NextResponse.json({ id: updated.id, github_login: updated.github_login, is_public: updated.is_public, + pinned_repos: updated.pinned_repos || [], }); } diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index cbe89f31..4d2ce105 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -22,6 +22,46 @@ export default function TopRepos() { const [healthLoading, setHealthLoading] = useState(true); const [sortColumn, setSortColumn] = useState<"commits" | "name">("commits"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [pinnedRepos, setPinnedRepos] = useState([]); + + useEffect(() => { + fetch("/api/user/settings") + .then((r) => r.json()) + .then((d) => setPinnedRepos(d.pinned_repos || [])) + .catch((err) => console.error("Failed to load pinned repos", err)); + }, []); + + const togglePin = async (repoFullName: string) => { + const isPinned = pinnedRepos.includes(repoFullName); + let newPinsArray: string[]; + + if (isPinned) { + newPinsArray = pinnedRepos.filter(name => name !== repoFullName); + } else { + if (pinnedRepos.length >= 3) { + alert("Maximum 3 pins allowed"); + return; + } + newPinsArray = [...pinnedRepos, repoFullName]; + } + + const prevPins = [...pinnedRepos]; + setPinnedRepos(newPinsArray); + + try { + const res = await fetch("/api/user/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ pinned_repos: newPinsArray }), + }); + if (!res.ok) throw new Error("Failed to update pins"); + } catch (err) { + console.error(err); + setPinnedRepos(prevPins); + } + }; const fetchRepos = useCallback(() => { setLoading(true); @@ -67,11 +107,11 @@ export default function TopRepos() { return () => clearInterval(interval); }, [lastUpdated]); - useEffect(() => { fetchRepos(); fetchHealthScores(); }, [fetchRepos, fetchHealthScores, selectedAccount]); + // toggle sort: same column flips direction, new column resets to desc const handleSort = (column: "commits" | "name") => { if (sortColumn === column) { @@ -81,8 +121,9 @@ export default function TopRepos() { setSortDirection("desc"); } }; + // sort repos based on selected column and direction before rendering - const sortedRepos = [...repos].sort((a, b) => { + const baseSortedRepos = [...repos].sort((a, b) => { if (sortColumn === "name") { const nameA = (a.name.split("/")[1] ?? a.name).toLowerCase(); const nameB = (b.name.split("/")[1] ?? b.name).toLowerCase(); @@ -95,7 +136,12 @@ export default function TopRepos() { : b.commits - a.commits; }); - const maxCommits = sortedRepos[0]?.commits ?? 1; + const sortedRepos = [ + ...pinnedRepos.map(pin => repos.find(r => r.name === pin)).filter(Boolean) as Repo[], + ...baseSortedRepos.filter(r => !pinnedRepos.includes(r.name)) + ]; + + const maxCommits = repos.reduce((max, r) => Math.max(max, r.commits), 1); return (
@@ -130,10 +176,8 @@ export default function TopRepos() {
) : repos.length === 0 ? ( -

No commits in the last {days} days.

) : ( - /* column headers — clicking sorts the list */ <>
@@ -227,7 +300,7 @@ export default function TopRepos() { ); })} - + )} {lastUpdated && (

diff --git a/supabase/migrations/20260518000000_add_pinned_repos_to_users.sql b/supabase/migrations/20260518000000_add_pinned_repos_to_users.sql new file mode 100644 index 00000000..c4ff68f1 --- /dev/null +++ b/supabase/migrations/20260518000000_add_pinned_repos_to_users.sql @@ -0,0 +1,10 @@ +-- Migration: Add pinned_repos column to users table +-- Stores up to 3 pinned repository full_names (e.g. "owner/repo") + +alter table users + add column if not exists pinned_repos text[] not null default '{}'; + +-- Optional: enforce the 3-pin limit at the DB level as a safety net +alter table users + add constraint pinned_repos_max_3 + check (array_length(pinned_repos, 1) is null or array_length(pinned_repos, 1) <= 3); From 19308a25db96aed32849b188bd9f53137ed57246 Mon Sep 17 00:00:00 2001 From: pawanakapkv Date: Tue, 19 May 2026 23:06:19 +0530 Subject: [PATCH 2/2] fix: address maintainer review comments for pinning --- src/app/api/user/settings/route.ts | 2 +- src/components/TopRepos.tsx | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index d95fc9f6..93c8a953 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -56,7 +56,7 @@ export async function PATCH(req: NextRequest) { } const { is_public, pinned_repos } = body; - const updates: Record = {}; + const updates: { is_public?: boolean; pinned_repos?: string[] } = {}; if (typeof is_public === "boolean") { updates.is_public = is_public; diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 4d2ce105..f6d48893 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -23,6 +23,7 @@ export default function TopRepos() { const [sortColumn, setSortColumn] = useState<"commits" | "name">("commits"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [pinnedRepos, setPinnedRepos] = useState([]); + const [pinError, setPinError] = useState(null); useEffect(() => { fetch("/api/user/settings") @@ -39,11 +40,13 @@ export default function TopRepos() { newPinsArray = pinnedRepos.filter(name => name !== repoFullName); } else { if (pinnedRepos.length >= 3) { - alert("Maximum 3 pins allowed"); + setPinError("Maximum 3 pins allowed"); return; } newPinsArray = [...pinnedRepos, repoFullName]; } + + setPinError(null); const prevPins = [...pinnedRepos]; setPinnedRepos(newPinsArray); @@ -146,7 +149,12 @@ export default function TopRepos() { return (

-

Top Repositories

+
+

Top Repositories

+ {pinError && ( +

{pinError}

+ )} +