diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index 04e60932..6dbfa65b 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -21,22 +21,24 @@ export async function GET(req: NextRequest) { ); } - // Fetch user from Supabase const { data, error } = await supabaseAdmin .from("users") - .select("id, github_login, is_public, leaderboard_opt_in") + .select("id, github_login, is_public, leaderboard_opt_in, pinned_repos") .eq("id", user.id) .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, + leaderboard_opt_in: data.leaderboard_opt_in ?? false, + pinned_repos: data.pinned_repos || [], + }); } export async function PATCH(req: NextRequest) { @@ -46,7 +48,6 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Get user ID from Supabase const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) { @@ -56,33 +57,21 @@ export async function PATCH(req: NextRequest) { ); } - // Parse request body - let body: { is_public?: boolean; leaderboard_opt_in?: boolean }; + let body: { is_public?: boolean; leaderboard_opt_in?: 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, leaderboard_opt_in } = body; + const { is_public, leaderboard_opt_in, pinned_repos } = body; - if ( - typeof is_public !== "boolean" && - typeof leaderboard_opt_in !== "boolean" - ) { - return NextResponse.json( - { error: "At least one boolean setting is required" }, - { status: 400 } - ); - } + const updates: { is_public?: boolean; leaderboard_opt_in?: boolean; pinned_repos?: string[] } = {}; - const updates: { is_public?: boolean; leaderboard_opt_in?: boolean } = {}; if (typeof is_public === "boolean") { updates.is_public = is_public; } + if (typeof leaderboard_opt_in === "boolean") { updates.leaderboard_opt_in = leaderboard_opt_in; if (leaderboard_opt_in) { @@ -90,26 +79,34 @@ export async function PATCH(req: NextRequest) { } } + 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 (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }); + } + const { data: updated, error: updateError } = await supabaseAdmin .from("users") .update(updates) .eq("id", user.id) - .select("id, github_login, is_public, leaderboard_opt_in") + .select("id, github_login, is_public, leaderboard_opt_in, pinned_repos") .single(); if (updateError || !updated) { console.error("Error updating settings:", updateError); - return NextResponse.json( - { error: "Failed to update settings" }, - { status: 500 } - ); + 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, leaderboard_opt_in: updated.leaderboard_opt_in ?? false, + pinned_repos: updated.pinned_repos || [], }); } diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 351b872d..12390b9c 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -82,6 +82,49 @@ 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([]); + const [pinError, setPinError] = useState(null); + + 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) { + setPinError("Maximum 3 pins allowed"); + return; + } + newPinsArray = [...pinnedRepos, repoFullName]; + } + + setPinError(null); + + 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); @@ -127,11 +170,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) { @@ -141,8 +184,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(); @@ -155,12 +199,22 @@ 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 (
-

Top Repositories

+
+

Top Repositories

+ {pinError && ( +

{pinError}

+ )} +