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
feat: add starred repos tab to user profile
  • Loading branch information
AbhiVarde committed Apr 12, 2026
commit 38247753b0a718693cb1bce5ad20e92cc0aed5ae
42 changes: 33 additions & 9 deletions apps/web/src/app/(app)/[owner]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getUserOrgTopRepos,
getContributionData,
getUserEvents,
getUserStarredRepos,
} from "@/lib/github";

/** Session-scoped; must not be statically shared across GitHub users. */
Expand Down Expand Up @@ -119,23 +120,32 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s
let contributionData: Awaited<ReturnType<typeof getContributionData>> = null;
let orgTopRepos: Awaited<ReturnType<typeof getUserOrgTopRepos>> = [];
let activityEvents: Awaited<ReturnType<typeof getUserEvents>> = [];
let starredRepos: Awaited<ReturnType<typeof getUserStarredRepos>> = [];

if (!isBot) {
try {
const [reposResult, orgsResult, contributionsResult, eventsResult] =
await Promise.allSettled([
getUserProfileRepositories(userData.login, 100),
getUserPublicOrgs(userData.login),
getContributionData(userData.login),
getUserEvents(userData.login, 100),
]);
const [
reposResult,
orgsResult,
contributionsResult,
eventsResult,
starredResult,
] = await Promise.allSettled([
getUserProfileRepositories(userData.login, 100),
getUserPublicOrgs(userData.login),
getContributionData(userData.login),
getUserEvents(userData.login, 100),
getUserStarredRepos(userData.login, 100),
]);

if (reposResult.status === "fulfilled") reposData = reposResult.value;
if (orgsResult.status === "fulfilled") orgsData = orgsResult.value;
if (contributionsResult.status === "fulfilled") {
if (contributionsResult.status === "fulfilled")
contributionData = contributionsResult.value;
}
if (eventsResult.status === "fulfilled")
activityEvents = eventsResult.value;
if (starredResult.status === "fulfilled")
starredRepos = starredResult.value;
if (orgsData.length > 0) {
orgTopRepos = await getUserOrgTopRepos(
orgsData.map((o) => o.login),
Expand Down Expand Up @@ -194,6 +204,20 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s
forks_count: r.forks_count,
language: r.language,
}))}
starredRepos={starredRepos.map((repo) => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description ?? null,
language: repo.language ?? null,
stargazers_count: repo.stargazers_count ?? 0,
forks_count: repo.forks_count ?? 0,
updated_at: repo.updated_at ?? null,
owner: {
login: repo.owner.login,
avatar_url: repo.owner.avatar_url,
},
}))}
/>
);
}
40 changes: 32 additions & 8 deletions apps/web/src/app/(app)/users/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getUserOrgTopRepos,
getContributionData,
getUserEvents,
getUserStarredRepos,
} from "@/lib/github";

/** Session-scoped; must not be statically shared across GitHub users. */
Expand All @@ -24,7 +25,7 @@ function UnknownUserPage({ username }: { username: string }) {
</div>
<div>
<h1 className="text-base font-medium">{username}</h1>
<p className="text-xs text-muted-foreground/60 mt-1 max-w-[240px]">
<p className="text-xs text-muted-foreground/60 mt-1 max-w-60">
This account can&apos;t be viewed here. It may be a bot,
app, or mannequin account.
</p>
Expand Down Expand Up @@ -77,6 +78,7 @@ export default async function UserProfilePage({
let contributionData: Awaited<ReturnType<typeof getContributionData>> = null;
let orgTopRepos: Awaited<ReturnType<typeof getUserOrgTopRepos>> = [];
let activityEvents: Awaited<ReturnType<typeof getUserEvents>> = [];
let starredRepos: Awaited<ReturnType<typeof getUserStarredRepos>> = [];

try {
userData = await getUser(username);
Expand All @@ -92,13 +94,19 @@ export default async function UserProfilePage({
if (!isBot) {
try {
const resolvedLogin = userData.login;
const [reposResult, orgsResult, contributionsResult, eventsResult] =
await Promise.allSettled([
getUserProfileRepositories(resolvedLogin, 100),
getUserPublicOrgs(resolvedLogin),
getContributionData(resolvedLogin),
getUserEvents(resolvedLogin, 100),
]);
const [
reposResult,
orgsResult,
contributionsResult,
eventsResult,
starredResult,
] = await Promise.allSettled([
getUserProfileRepositories(resolvedLogin, 100),
getUserPublicOrgs(resolvedLogin),
getContributionData(resolvedLogin),
getUserEvents(resolvedLogin, 100),
getUserStarredRepos(resolvedLogin, 100),
]);

if (reposResult.status === "fulfilled") reposData = reposResult.value;
if (orgsResult.status === "fulfilled") orgsData = orgsResult.value;
Expand All @@ -107,6 +115,8 @@ export default async function UserProfilePage({
}
if (eventsResult.status === "fulfilled")
activityEvents = eventsResult.value;
if (starredResult.status === "fulfilled")
starredRepos = starredResult.value;

// Fetch top repos from the user's orgs (for scoring)
if (orgsData.length > 0) {
Expand Down Expand Up @@ -167,6 +177,20 @@ export default async function UserProfilePage({
forks_count: r.forks_count,
language: r.language,
}))}
starredRepos={starredRepos.map((repo) => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description ?? null,
language: repo.language ?? null,
stargazers_count: repo.stargazers_count ?? 0,
forks_count: repo.forks_count ?? 0,
updated_at: repo.updated_at ?? null,
owner: {
login: repo.owner.login,
avatar_url: repo.owner.avatar_url,
},
}))}
/>
);
}
138 changes: 131 additions & 7 deletions apps/web/src/components/users/user-profile-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ export interface UserRepo {
pushed_at: string | null;
}

export interface StarredRepo {
id: number;
name: string;
full_name: string;
description: string | null;
language: string | null;
stargazers_count: number;
forks_count: number;
updated_at: string | null;
owner: {
login: string;
avatar_url: string;
};
}

export interface UserOrg {
login: string;
avatar_url: string;
Expand All @@ -91,7 +106,7 @@ const filterTypes = ["all", "sources", "forks", "archived"] as const;

const sortTypes = ["updated", "name", "stars"] as const;

const tabTypes = ["repositories", "activity"] as const;
const tabTypes = ["repositories", "starred", "activity"] as const;

function formatJoinedDate(value: string | null): string | null {
if (!value) return null;
Expand All @@ -118,13 +133,15 @@ export function UserProfileContent({
contributions,
activityEvents = [],
orgTopRepos = [],
starredRepos = [],
}: {
user: UserProfile;
repos: UserRepo[];
orgs: UserOrg[];
contributions: ContributionData | null;
activityEvents?: ActivityEvent[];
orgTopRepos?: OrgTopRepo[];
starredRepos?: StarredRepo[];
}) {
const [tab, setTab] = useQueryState(
"tab",
Expand Down Expand Up @@ -849,32 +866,50 @@ export function UserProfileContent({

{/* Tab switcher */}
<div className="shrink-0 mb-4">
<div className="flex items-center border border-border divide-x divide-border rounded-sm lg:w-fit">
<div className="flex items-center border border-border divide-x divide-border rounded-sm w-full lg:w-fit">
<button
onClick={() => setTab("repositories")}
className={cn(
"flex-1 flex items-center justify-center gap-2 px-4 py-2 text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer lg:rounded-l-md",
"flex-1 lg:flex-none flex items-center justify-center gap-2 px-3 lg:px-4 py-2 text-[10px] sm:text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer rounded-l-sm lg:rounded-l-md",
tab === "repositories"
? "bg-muted/50 dark:bg-white/4 text-foreground"
: "text-muted-foreground hover:text-foreground/60 hover:bg-muted/60 dark:hover:bg-white/3",
)}
>
<FolderGit2 className="w-3.5 h-3.5" />
Repositories
<FolderGit2 className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
<span className="sm:hidden">Repos</span>
<span className="hidden sm:inline">
Repositories
</span>
<span className="text-muted-foreground/50 tabular-nums">
{repos.length}
</span>
</button>
<button
onClick={() => setTab("starred")}
className={cn(
"flex-1 lg:flex-none flex items-center justify-center gap-2 px-3 lg:px-4 py-2 text-[10px] sm:text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer",
tab === "starred"
? "bg-muted/50 dark:bg-white/4 text-foreground"
: "text-muted-foreground hover:text-foreground/60 hover:bg-muted/60 dark:hover:bg-white/3",
)}
>
<Star className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
Starred
<span className="text-muted-foreground/50 tabular-nums">
{starredRepos.length}
</span>
</button>
<button
onClick={() => setTab("activity")}
className={cn(
"flex-1 flex items-center justify-center gap-2 px-4 py-2 text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer lg:rounded-r-md",
"flex-1 lg:flex-none flex items-center justify-center gap-2 px-3 lg:px-4 py-2 text-[10px] sm:text-[11px] font-mono uppercase tracking-wider transition-colors cursor-pointer rounded-r-sm lg:rounded-r-md",
tab === "activity"
? "bg-muted/50 dark:bg-white/4 text-foreground"
: "text-muted-foreground hover:text-foreground/60 hover:bg-muted/60 dark:hover:bg-white/3",
)}
>
<Activity className="w-3.5 h-3.5" />
<Activity className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
Activity
</button>
</div>
Expand Down Expand Up @@ -1397,6 +1432,95 @@ export function UserProfileContent({
</>
)}

{tab === "starred" && (
<div className="flex-1 min-h-[50dvh] lg:min-h-0 overflow-y-auto border border-border rounded-md divide-y divide-border">
{starredRepos.map((repo) => (
<Link
key={repo.id}
href={`/${repo.full_name}`}
className="group flex items-center gap-4 px-4 py-3 hover:bg-muted/60 dark:hover:bg-white/3 transition-colors"
>
<Image
src={repo.owner.avatar_url}
alt={repo.owner.login}
width={20}
height={20}
className="rounded-md shrink-0"
/>

<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1 font-mono">
<span className="text-[11px] text-muted-foreground/50">
{
repo
.owner
.login
}
</span>
<span className="text-[11px] text-muted-foreground/30">
/
</span>
<span className="text-sm text-foreground truncate">
{repo.name}
</span>
</div>
{repo.description && (
<p className="text-[11px] text-muted-foreground/60 mt-0.5 truncate">
{
repo.description
}
</p>
)}
<div className="flex items-center flex-wrap gap-x-3 gap-y-1 mt-1.5">
{repo.language && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60 font-mono">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor:
getLanguageColor(
repo.language,
),
}}
/>
{
repo.language
}
</span>
)}
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
<Star className="w-3 h-3" />
{formatNumber(
repo.stargazers_count,
)}
</span>
{repo.forks_count >
0 && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
<GitFork className="w-3 h-3" />
{formatNumber(
repo.forks_count,
)}
</span>
)}
</div>
</div>

<ChevronRight className="w-3 h-3 text-foreground/10 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
))}

{starredRepos.length === 0 && (
<div className="py-16 text-center">
<Star className="w-6 h-6 text-muted-foreground/20 mx-auto mb-3" />
<p className="text-xs text-muted-foreground/50 font-mono">
No starred repositories
</p>
</div>
)}
</div>
)}

{tab === "activity" && (
<div className="flex-1 min-h-[50dvh] lg:min-h-0 overflow-y-auto pb-4">
<UserProfileActivityTimelineBoundary>
Expand Down
Loading