Skip to content
Open
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
59 changes: 28 additions & 31 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -56,60 +57,56 @@ 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) {
updates.is_public = true;
}
}

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 || [],
});
}
95 changes: 88 additions & 7 deletions src/components/TopRepos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
const [pinError, setPinError] = useState<string | null>(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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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 (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">Top Repositories</h2>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">Top Repositories</h2>
{pinError && (
<p className="text-xs text-[var(--destructive)]">{pinError}</p>
)}
</div>
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
Expand Down Expand Up @@ -200,10 +254,8 @@ export default function TopRepos() {
</button>
</div>
) : repos.length === 0 ? (

<p className="text-sm text-[var(--muted-foreground)]">No commits in the last {days} days.</p>
) : (
/* column headers — clicking sorts the list */
<>
<div className="flex items-center justify-between text-xs text-[var(--muted-foreground)] mb-2 px-0">
<button
Expand Down Expand Up @@ -231,6 +283,7 @@ export default function TopRepos() {
</div>
<ul className="space-y-3">
{sortedRepos.map((repo, idx) => {
const isPinned = pinnedRepos.includes(repo.name);
const barWidth = Math.max(
Math.round((repo.commits / maxCommits) * 100),
4
Expand Down Expand Up @@ -263,6 +316,11 @@ export default function TopRepos() {
>
<span className="mr-1 text-[var(--muted-foreground)]">#{idx + 1}</span>
{shortName}
{isPinned && (
<span className="ml-2 inline-flex items-center rounded-md bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)] ring-1 ring-inset ring-[color-mix(in_srgb,var(--accent)_20%,transparent)] align-middle">
Pinned
</span>
)}
</a>
<span className="shrink-0 flex items-center gap-2">
{healthLoading ? (
Expand All @@ -285,6 +343,29 @@ export default function TopRepos() {
<span className="text-[var(--muted-foreground)]">
{repo.commits} commit{repo.commits !== 1 ? "s" : ""}
</span>
<button
type="button"
onClick={() => togglePin(repo.name)}
className="ml-1 p-1 hover:bg-[var(--card-muted)] rounded-md transition-colors"
title={isPinned ? "Unpin repository" : "Pin repository"}
aria-label={isPinned ? `Unpin ${repo.name}` : `Pin ${repo.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill={isPinned ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={isPinned ? "text-[var(--accent)]" : "text-[var(--muted-foreground)]"}
>
<path d="M12 17v5" />
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
</svg>
</button>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--control)]">
Expand Down Expand Up @@ -317,7 +398,7 @@ export default function TopRepos() {
);
})}
</ul>
</>
</>
)}
{lastUpdated && (
<p className="text-xs text-[var(--muted-foreground)] mt-2 text-right">
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Loading