Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
72 changes: 55 additions & 17 deletions apps/web/src/app/(app)/[owner]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import {
getOrg,
getOrgRepos,
getUser,
getUserPublicRepos,
getUserPublicOrgs,
getUserOrgTopRepos,
getUserFollowers,
getUserFollowing,
getContributionData,
} from "@/lib/github";
import { ogImageUrl, ogImages } from "@/lib/og/og-utils";
import { resolveProfileTab } from "@/lib/utils";
import { OrgDetailContent } from "@/components/orgs/org-detail-content";
import { UserProfileContent } from "@/components/users/user-profile-content";

Expand Down Expand Up @@ -48,8 +51,16 @@ export async function generateMetadata({
};
}

export default async function OwnerPage({ params }: { params: Promise<{ owner: string }> }) {
export default async function OwnerPage({
params,
searchParams,
}: {
params: Promise<{ owner: string }>;
searchParams?: Promise<{ tab?: string }>;
}) {
const { owner } = await params;
const rawTab = (await searchParams)?.tab;
const tab = resolveProfileTab(rawTab);

// Resolve actor first to avoid noisy /orgs/:user 404 calls for user handles.
const actorData = await getUser(owner).catch(() => null);
Expand All @@ -63,6 +74,10 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s
if (!orgData) {
notFound();
}

if (tab === "followers" || tab === "following") {
redirect(`/${owner}`);
}
Comment thread
Esubaalew marked this conversation as resolved.
const reposData = await getOrgRepos(owner, {
perPage: 100,
sort: "updated",
Expand Down Expand Up @@ -105,30 +120,50 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s
);
}

// Fall back to user profile
const userData = actorData;
if (!userData) {
notFound();
}

const isBot = (userData as { type?: string }).type === "Bot";
const userActorType = (userData as { type?: string }).type;
const isBot = userActorType === "Bot";
const isStandardUser = userActorType === "User" || !userActorType;

let reposData: Awaited<ReturnType<typeof getUserPublicRepos>> = [];
let orgsData: Awaited<ReturnType<typeof getUserPublicOrgs>> = [];
let contributionData: Awaited<ReturnType<typeof getContributionData>> = null;
let orgTopRepos: Awaited<ReturnType<typeof getUserOrgTopRepos>> = [];
let followersData: Awaited<ReturnType<typeof getUserFollowers>> = [];
let followingData: Awaited<ReturnType<typeof getUserFollowing>> = [];

if (!isBot) {
try {
[reposData, orgsData, contributionData] = await Promise.all([
getUserPublicRepos(userData.login, 100),
getUserPublicOrgs(userData.login),
getContributionData(userData.login),
]);
if (orgsData.length > 0) {
orgTopRepos = await getUserOrgTopRepos(
orgsData.map((o) => o.login),
);
}
} catch {
// Show profile with whatever we have
const [
reposResult,
orgsResult,
contributionResult,
followersResult,
followingResult,
] = await Promise.allSettled([
getUserPublicRepos(userData.login, 100),
isStandardUser ? getUserPublicOrgs(userData.login) : Promise.resolve([]),
isStandardUser
? getContributionData(userData.login)
: Promise.resolve(null),
getUserFollowers(userData.login, 100),
getUserFollowing(userData.login, 100),
]);

reposData = reposResult.status === "fulfilled" ? reposResult.value : [];
orgsData = orgsResult.status === "fulfilled" ? orgsResult.value : [];
contributionData =
contributionResult.status === "fulfilled" ? contributionResult.value : null;
followersData = followersResult.status === "fulfilled" ? followersResult.value : [];
followingData = followingResult.status === "fulfilled" ? followingResult.value : [];
Comment thread
Esubaalew marked this conversation as resolved.
Outdated

if (isStandardUser && orgsData.length > 0) {
orgTopRepos = await getUserOrgTopRepos(orgsData.map((o) => o.login)).catch(
() => [],
);
}
}

Expand Down Expand Up @@ -171,6 +206,9 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s
avatar_url: org.avatar_url,
}))}
contributions={contributionData}
followers={followersData}
following={followingData}
initialTab={tab}
orgTopRepos={orgTopRepos.map((r) => ({
name: r.name,
full_name: r.full_name,
Expand Down
57 changes: 41 additions & 16 deletions apps/web/src/app/(app)/users/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
getUserPublicRepos,
getUserPublicOrgs,
getUserOrgTopRepos,
getUserFollowers,
getUserFollowing,
getContributionData,
} from "@/lib/github";
import { ogImageUrl, ogImages } from "@/lib/og/og-utils";
import { resolveProfileTab } from "@/lib/utils";
import { UserProfileContent } from "@/components/users/user-profile-content";
import { ExternalLink, User } from "lucide-react";

Expand Down Expand Up @@ -62,16 +65,21 @@ export async function generateMetadata({

export default async function UserProfilePage({
params,
searchParams,
}: {
params: Promise<{ username: string }>;
searchParams?: Promise<{ tab?: string }>;
}) {
const { username } = await params;
const tab = resolveProfileTab((await searchParams)?.tab);

let userData: Awaited<ReturnType<typeof getUser>> = null;
let reposData: Awaited<ReturnType<typeof getUserPublicRepos>> = [];
let orgsData: Awaited<ReturnType<typeof getUserPublicOrgs>> = [];
let contributionData: Awaited<ReturnType<typeof getContributionData>> = null;
let orgTopRepos: Awaited<ReturnType<typeof getUserOrgTopRepos>> = [];
let followersData: Awaited<ReturnType<typeof getUserFollowers>> = [];
let followingData: Awaited<ReturnType<typeof getUserFollowing>> = [];

try {
userData = await getUser(username);
Expand All @@ -83,23 +91,37 @@ export default async function UserProfilePage({
return <UnknownUserPage username={username} />;
}

const isBot = (userData as { type?: string }).type === "Bot";
const actorType = (userData as { type?: string }).type;
const isBot = actorType === "Bot";
const isStandardUser = actorType === "User" || !actorType;
if (!isBot) {
try {
const resolvedLogin = userData.login;
[reposData, orgsData, contributionData] = await Promise.all([
getUserPublicRepos(resolvedLogin, 100),
getUserPublicOrgs(resolvedLogin),
getContributionData(resolvedLogin),
]);
// Fetch top repos from the user's orgs (for scoring)
if (orgsData.length > 0) {
orgTopRepos = await getUserOrgTopRepos(
orgsData.map((o) => o.login),
);
}
} catch {
// Show profile with whatever we have
const resolvedLogin = userData.login;
const [
reposResult,
orgsResult,
contributionResult,
followersResult,
followingResult,
] = await Promise.allSettled([
getUserPublicRepos(resolvedLogin, 100),
isStandardUser ? getUserPublicOrgs(resolvedLogin) : Promise.resolve([]),
isStandardUser ? getContributionData(resolvedLogin) : Promise.resolve(null),
getUserFollowers(resolvedLogin, 100),
getUserFollowing(resolvedLogin, 100),
]);

reposData = reposResult.status === "fulfilled" ? reposResult.value : [];
orgsData = orgsResult.status === "fulfilled" ? orgsResult.value : [];
contributionData =
contributionResult.status === "fulfilled" ? contributionResult.value : null;
followersData = followersResult.status === "fulfilled" ? followersResult.value : [];
followingData = followingResult.status === "fulfilled" ? followingResult.value : [];

// Fetch top repos from orgs if org fetch succeeded
if (isStandardUser && orgsData.length > 0) {
orgTopRepos = await getUserOrgTopRepos(orgsData.map((o) => o.login)).catch(
() => [],
);
}
}

Expand Down Expand Up @@ -142,6 +164,9 @@ export default async function UserProfilePage({
avatar_url: org.avatar_url,
}))}
contributions={contributionData}
followers={followersData}
following={followingData}
initialTab={tab}
orgTopRepos={orgTopRepos.map((r) => ({
name: r.name,
full_name: r.full_name,
Expand Down
24 changes: 18 additions & 6 deletions apps/web/src/components/layout/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,27 +224,39 @@ export function AppNavbar({ session, notifications }: AppNavbarProps) {
</div>
{gh && (
<div className="flex items-center gap-3 mt-2.5 pt-2 border-t border-border/40">
<span className="text-[10px] text-muted-foreground font-mono">
<Link
href={`/users/${gh.login}?tab=followers`}
aria-label="View my followers"
className="text-[10px] text-muted-foreground font-mono hover:text-foreground transition-colors"
>
<span className="text-foreground/80 font-medium">
{gh.followers ??
0}
</span>{" "}
followers
</span>
<span className="text-[10px] text-muted-foreground font-mono">
</Link>
<Link
href={`/users/${gh.login}?tab=following`}
aria-label="View people I follow"
className="text-[10px] text-muted-foreground font-mono hover:text-foreground transition-colors"
>
<span className="text-foreground/80 font-medium">
{gh.following ??
0}
</span>{" "}
following
</span>
<span className="text-[10px] text-muted-foreground font-mono">
</Link>
<Link
href={`/users/${gh.login}`}
aria-label="View my repositories"
className="text-[10px] text-muted-foreground font-mono hover:text-foreground transition-colors"
>
<span className="text-foreground/80 font-medium">
{gh.public_repos ??
0}
</span>{" "}
repos
</span>
</Link>
Comment thread
Esubaalew marked this conversation as resolved.
</div>
)}
</div>
Expand Down
24 changes: 19 additions & 5 deletions apps/web/src/components/orgs/org-detail-content.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useMemo } from "react";
import { useQueryState, parseAsStringLiteral, parseAsString } from "nuqs";
import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
import Image from "next/image";
import Link from "next/link";
import {
Expand Down Expand Up @@ -63,11 +63,12 @@ function formatJoinedDate(value: string | null): string | null {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;

return parsed.toLocaleDateString(undefined, {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
timeZone: "UTC",
}).format(parsed);
}

Comment thread
Esubaalew marked this conversation as resolved.
export function OrgDetailContent({ org, repos }: { org: OrgDetails; repos: OrgRepo[] }) {
Expand Down Expand Up @@ -170,11 +171,24 @@ export function OrgDetailContent({ org, repos }: { org: OrgDetails; repos: OrgRe
)}{" "}
repos
</span>
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
<Link
href={`/users/${org.login}?tab=followers`}
aria-label={`View ${org.login} followers list`}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground font-mono hover:text-foreground transition-colors"
>
<Users className="w-3 h-3" />
{formatNumber(org.followers)}{" "}
followers
</span>
</Link>
<Link
href={`/users/${org.login}?tab=following`}
aria-label={`View ${org.login} following list`}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground font-mono hover:text-foreground transition-colors"
>
<Users className="w-3 h-3" />
{formatNumber(org.following)}{" "}
following
</Link>
{org.location && (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
<MapPin className="w-3 h-3" />
Expand Down
Loading