Skip to content

Commit 5c6b1e0

Browse files
committed
prerender profile pages
1 parent 8ceb141 commit 5c6b1e0

File tree

15 files changed

+258
-147
lines changed

15 files changed

+258
-147
lines changed

app/api/graphql/route.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

3+
import { request } from '@/lib/graphql/request';
34
import { appRouteReject } from '@/utils/app-route-reject';
45

56
export async function POST(req: NextRequest) {
@@ -9,19 +10,9 @@ export async function POST(req: NextRequest) {
910

1011
try {
1112
const { query, variables } = await req.json();
13+
const { data, status } = await request(query, variables);
1214

13-
const graphqlRes = await fetch(process.env.GRAPHQL_URI!, {
14-
method: 'POST',
15-
headers: {
16-
'Content-Type': 'application/json',
17-
'x-api-key': process.env.GRAPHQL_SECRET_KEY!,
18-
},
19-
body: JSON.stringify({ query, variables }),
20-
});
21-
22-
// Stream or parse the response back to the client
23-
const data = await graphqlRes.json();
24-
return NextResponse.json(data, { status: graphqlRes.status });
15+
return NextResponse.json(data, { status });
2516
} catch {
2617
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
2718
}

app/badge/[[...login]]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Page } from '@/components/page/page';
22
import { Separator } from '@/components/ui/separator';
3-
import { graphqlRequest } from '@/lib/graphql-request';
3+
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
44
import { IdByLoginDocument } from '@/types/generated/graphql';
55

66
import { BadgeForm } from './components/badge-form';
@@ -14,7 +14,7 @@ export default async function Badge({ params }: { params: Promise<{ login?: stri
1414
let githubId: string | undefined;
1515

1616
if (githubLogin) {
17-
const data = await graphqlRequest(IdByLoginDocument, { login: githubLogin });
17+
const data = await graphqlDirect(IdByLoginDocument, { login: githubLogin });
1818
githubId = data.rankByLogin?.githubId;
1919
}
2020

app/by/[rankingType]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
PaginationPrevious,
1010
} from '@/components/ui/pagination';
1111
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
12-
import { graphqlRequest } from '@/lib/graphql-request';
12+
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
1313
import { RankingsDocument, RankOrder } from '@/types/generated/graphql';
1414
import { getInitials } from '@/utils/get-initials';
1515

@@ -64,7 +64,7 @@ export default async function GlobalRanking({
6464
const [queryOrder, rankPropName, title, subtitle, rankingBaseEntity] = getConfigByRankingType(rankingType);
6565
const page = Number((await searchParams)?.page) || 1;
6666
const offset = (page - 1) * ITEMS_PER_PAGE;
67-
const data = await graphqlRequest(RankingsDocument, { order: queryOrder, offset });
67+
const data = await graphqlDirect(RankingsDocument, { order: queryOrder, offset });
6868

6969
return (
7070
<Page className="max-w-5xl gap-6">

app/components/search-profiile.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { toast } from 'sonner';
99

1010
import { Button } from '@/components/ui/button';
1111
import { Input } from '@/components/ui/input';
12-
import { graphqlRequest } from '@/lib/graphql-request';
12+
import { graphqlClient } from '@/lib/graphql/graphql-client';
1313
import { IdByLoginDocument } from '@/types/generated/graphql';
1414

1515
export const SearchProfile = () => {
@@ -24,7 +24,7 @@ export const SearchProfile = () => {
2424
}
2525

2626
setLoading(true);
27-
const data = await graphqlRequest(IdByLoginDocument, { login });
27+
const data = await graphqlClient(IdByLoginDocument, { login });
2828

2929
const profileFound = data.rankByLogin?.githubId;
3030

app/profile/[login]/layout.tsx

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,152 @@
1+
import { formatDistanceToNow } from 'date-fns';
2+
import { BriefcaseBusiness, ExternalLink, Hourglass, Link2, Mail, MapPin, Timer, UsersRound } from 'lucide-react';
3+
import Image from 'next/image';
4+
import Link from 'next/link';
5+
16
import { Header } from '@/components/header/header';
7+
import { Page } from '@/components/page/page';
8+
import { AspectRatio } from '@/components/ui/aspect-ratio';
9+
import { Avatar, AvatarImage } from '@/components/ui/avatar';
10+
import { Button } from '@/components/ui/button';
11+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12+
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
13+
import { TopRanksDocument } from '@/types/generated/graphql';
14+
15+
import { ProfileListItem } from './components/profile-list-item';
16+
import { RefreshButton } from './components/refresh-button';
17+
import { fetchProfileData } from './utils/fetch-profile-data';
18+
import { getSocialIcon } from './utils/get-social-icon';
19+
20+
type ProfileLayoutProps = Readonly<{ children: React.ReactNode; params: Promise<{ login: string }> }>;
21+
22+
// Next.js will invalidate the cache when a
23+
// request comes in, at most once every 3600 seconds.
24+
export const revalidate = 3600;
25+
26+
// We'll prerender only the params from `generateStaticParams` at build time.
27+
// If a request comes in for a path that hasn't been generated,
28+
// Next.js will server-render the page on-demand.
29+
export const dynamicParams = true;
30+
31+
export async function generateStaticParams() {
32+
const { byStars, byContribution, byFollowers } = (await graphqlDirect(TopRanksDocument)) ?? {};
33+
const mergedRanks = [...byStars, ...byContribution, ...byFollowers];
34+
35+
const uniqueLogins = new Set<string>();
36+
37+
mergedRanks.forEach((rank) => {
38+
if (rank.user) {
39+
uniqueLogins.add(rank.user.login);
40+
}
41+
});
42+
43+
return [...uniqueLogins].map((login) => ({ login }));
44+
}
45+
46+
export default async function ProfileLayout({ params, children }: ProfileLayoutProps) {
47+
const { login } = await params;
48+
const { user } = await fetchProfileData(login);
49+
50+
if (!user) {
51+
return (
52+
<Page className="gap-6 flex-col md:flex-row">
53+
<div className="flex-grow flex flex-col gap-6">
54+
<h1 className="text-2xl font-semibold">User not found</h1>
55+
<p className="text-muted-foreground">The user you are looking for does not exist.</p>
56+
</div>
57+
</Page>
58+
);
59+
}
60+
61+
const showContact = !!user?.email || !!user?.websiteUrl || !!user?.socialAccounts?.nodes?.length;
262

3-
export default function ProfileLayout({ children }: Readonly<{ children: React.ReactNode }>) {
463
return (
564
<>
665
<Header />
7-
{children}
66+
<Page className="gap-6 flex-col md:flex-row">
67+
<div className="w-full md:w-3xs xl:w-2xs flex flex-col shrink-0 gap-4">
68+
<div className="flex flex-row md:flex-col items-center md:items-start gap-4">
69+
<div className="w-[64] sm:w-[128] md:w-full">
70+
<AspectRatio ratio={1}>
71+
<Avatar className="w-full h-full rounded-full" asChild>
72+
<AvatarImage src={user.avatarUrl!} />
73+
</Avatar>
74+
</AspectRatio>
75+
</div>
76+
<div>
77+
<h1 className="font-semibold text-2xl">{user.name}</h1>
78+
<h2 className="text-muted-foreground">@{user.login}</h2>
79+
</div>
80+
</div>
81+
<div className="flex flex-row md:flex-col gap-4">
82+
<RefreshButton />
83+
<Button size="sm" variant="secondary" className="flex-grow" asChild>
84+
<Link href={`https://github.com/${user.login}`} target="_blank" rel="noopener noreferrer">
85+
Open GitHub
86+
<ExternalLink className="size-4" />
87+
</Link>
88+
</Button>
89+
</div>
90+
<div className="flex flex-col gap-6">
91+
<div className="flex flex-col gap-1.5">
92+
<ProfileListItem value={user.location} Icon={MapPin} />
93+
<ProfileListItem value={user.company} Icon={BriefcaseBusiness} />
94+
<ProfileListItem value={'Profile age: ' + formatDistanceToNow(user.githubCreatedAt)} Icon={Hourglass} />
95+
<ProfileListItem
96+
value={'Updated ' + formatDistanceToNow(user.githubFetchedAt, { addSuffix: true })}
97+
Icon={Timer}
98+
/>
99+
<ProfileListItem
100+
value={`${user.followersCount?.toLocaleString(
101+
'en-US',
102+
)} followers • ${user.followingCount?.toLocaleString('en-US')} following`}
103+
Icon={UsersRound}
104+
/>
105+
</div>
106+
{showContact && (
107+
<div className="flex flex-col gap-1.5">
108+
<h4 className="text-lg font-semibold">Contacts</h4>
109+
<ProfileListItem value={user.email} url={`mailto:${user.email}`} Icon={Mail} />
110+
<ProfileListItem value={user.websiteUrl} url={user.websiteUrl!} Icon={Link2} />
111+
{user.socialAccounts?.nodes?.map((account) => (
112+
<ProfileListItem
113+
key={account.displayName}
114+
value={account.displayName}
115+
url={account.url}
116+
Icon={getSocialIcon(account.provider, account.url)}
117+
/>
118+
))}
119+
</div>
120+
)}
121+
{!!user.organizations?.length && (
122+
<div className="flex flex-col gap-1.5">
123+
<h4 className="text-lg font-semibold">Organizations</h4>
124+
<div className="flex gap-1 flex-wrap">
125+
{user.organizations?.map((org) => (
126+
<TooltipProvider key={org.login}>
127+
<Tooltip>
128+
<TooltipTrigger asChild>
129+
<Link href={`https://github.com/${org.login}`} target="_blank" rel="noopener noreferrer">
130+
<Image
131+
src={org.avatarUrl!}
132+
alt={org.login}
133+
width={32}
134+
height={32}
135+
className="rounded-sm bg-secondary"
136+
/>
137+
</Link>
138+
</TooltipTrigger>
139+
<TooltipContent>{org.login}</TooltipContent>
140+
</Tooltip>
141+
</TooltipProvider>
142+
))}
143+
</div>
144+
</div>
145+
)}
146+
</div>
147+
</div>
148+
{children}
149+
</Page>
8150
</>
9151
);
10152
}

app/profile/[login]/page.tsx

Lines changed: 20 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
1-
import { formatDistanceToNow } from 'date-fns';
2-
import { BriefcaseBusiness, ExternalLink, Hourglass, Link2, Mail, MapPin, Timer, UsersRound } from 'lucide-react';
31
import type { Metadata } from 'next';
4-
import Image from 'next/image';
5-
import Link from 'next/link';
62

7-
import { Page } from '@/components/page/page';
8-
import { AspectRatio } from '@/components/ui/aspect-ratio';
9-
import { Avatar, AvatarImage } from '@/components/ui/avatar';
10-
import { Button } from '@/components/ui/button';
11-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12-
import { graphqlRequest } from '@/lib/graphql-request';
13-
import { ProfileForMetadataDocument, UserDocument } from '@/types/generated/graphql';
3+
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
4+
import { ProfileForMetadataDocument } from '@/types/generated/graphql';
145

15-
import { ProfileListItem } from './components/profile-list-item';
166
import { ProfileTimeline } from './components/profile-timeline';
177
import { RanksOverview } from './components/ranks-overview';
18-
import { RefreshButton } from './components/refresh-button';
198
import { RepositoriesOverview } from './components/repositories-overiview';
20-
import { getSocialIcon } from './utils/get-social-icon';
9+
import { fetchProfileData } from './utils/fetch-profile-data';
2110

2211
type Props = {
2312
params: Promise<{ login: string }>;
2413
};
2514

2615
export async function generateMetadata({ params }: Props): Promise<Metadata> {
2716
const { login } = await params;
28-
const { rankByLogin } = (await graphqlRequest(ProfileForMetadataDocument, { login })) ?? {};
17+
const { rankByLogin } = (await graphqlDirect(ProfileForMetadataDocument, { login })) ?? {};
2918

3019
if (!rankByLogin?.user) {
3120
return { title: 'GitHub Profile Analytics & Rankings · GitRanks' };
@@ -41,107 +30,26 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
4130

4231
export default async function Profile({ params }: { params: Promise<{ login: string }> }) {
4332
const { login } = await params;
44-
const { user } = (await graphqlRequest(UserDocument, { login })) ?? {};
33+
const { user } = await fetchProfileData(login);
4534

46-
const showContact = !!user?.email || !!user?.websiteUrl || !!user?.socialAccounts?.nodes?.length;
35+
if (!user) {
36+
return null;
37+
}
4738

4839
return (
49-
<Page className="gap-6 flex-col md:flex-row">
50-
<div className="w-full md:w-3xs xl:w-2xs flex flex-col shrink-0 gap-4">
51-
<div className="flex flex-row md:flex-col items-center md:items-start gap-4">
52-
<div className="w-[64] sm:w-[128] md:w-full">
53-
<AspectRatio ratio={1}>
54-
<Avatar className="w-full h-full rounded-full" asChild>
55-
<AvatarImage src={user.avatarUrl!} />
56-
</Avatar>
57-
</AspectRatio>
58-
</div>
59-
<div>
60-
<h1 className="font-semibold text-2xl">{user.name}</h1>
61-
<h2 className="text-muted-foreground">@{user.login}</h2>
62-
</div>
63-
</div>
64-
<div className="flex flex-row md:flex-col gap-4">
65-
<RefreshButton />
66-
<Button size="sm" variant="secondary" className="flex-grow" asChild>
67-
<Link href={`https://github.com/${user.login}`} target="_blank" rel="noopener noreferrer">
68-
Open GitHub
69-
<ExternalLink className="size-4" />
70-
</Link>
71-
</Button>
72-
</div>
73-
<div className="flex flex-col gap-6">
74-
<div className="flex flex-col gap-1.5">
75-
<ProfileListItem value={user.location} Icon={MapPin} />
76-
<ProfileListItem value={user.company} Icon={BriefcaseBusiness} />
77-
<ProfileListItem value={'Profile age: ' + formatDistanceToNow(user.githubCreatedAt)} Icon={Hourglass} />
78-
<ProfileListItem
79-
value={'Updated ' + formatDistanceToNow(user.githubFetchedAt, { addSuffix: true })}
80-
Icon={Timer}
81-
/>
82-
<ProfileListItem
83-
value={`${user.followersCount?.toLocaleString('en-US')} followers • ${user.followingCount?.toLocaleString(
84-
'en-US',
85-
)} following`}
86-
Icon={UsersRound}
87-
/>
88-
</div>
89-
{showContact && (
90-
<div className="flex flex-col gap-1.5">
91-
<h4 className="text-lg font-semibold">Contacts</h4>
92-
<ProfileListItem value={user.email} url={`mailto:${user.email}`} Icon={Mail} />
93-
<ProfileListItem value={user.websiteUrl} url={user.websiteUrl!} Icon={Link2} />
94-
{user.socialAccounts?.nodes?.map((account) => (
95-
<ProfileListItem
96-
key={account.displayName}
97-
value={account.displayName}
98-
url={account.url}
99-
Icon={getSocialIcon(account.provider, account.url)}
100-
/>
101-
))}
102-
</div>
103-
)}
104-
{!!user.organizations?.length && (
105-
<div className="flex flex-col gap-1.5">
106-
<h4 className="text-lg font-semibold">Organizations</h4>
107-
<div className="flex gap-1 flex-wrap">
108-
{user.organizations?.map((org) => (
109-
<TooltipProvider key={org.login}>
110-
<Tooltip>
111-
<TooltipTrigger asChild>
112-
<Link href={`https://github.com/${org.login}`} target="_blank" rel="noopener noreferrer">
113-
<Image
114-
src={org.avatarUrl!}
115-
alt={org.login}
116-
width={32}
117-
height={32}
118-
className="rounded-sm bg-secondary"
119-
/>
120-
</Link>
121-
</TooltipTrigger>
122-
<TooltipContent>{org.login}</TooltipContent>
123-
</Tooltip>
124-
</TooltipProvider>
125-
))}
126-
</div>
127-
</div>
128-
)}
129-
</div>
40+
<div className="flex-grow flex flex-col gap-6">
41+
<div className="flex flex-col md:flex-row flex-wrap gap-6">
42+
<RanksOverview ranksData={user.rank} />
43+
<RepositoriesOverview
44+
repositories={user.repositories}
45+
contributions={user.contributions}
46+
ownedStars={user.ownedStars}
47+
contributedStars={user.contributedStars}
48+
/>
13049
</div>
131-
<div className="flex-grow flex flex-col gap-6">
132-
<div className="flex flex-col md:flex-row flex-wrap gap-6">
133-
<RanksOverview ranksData={user.rank} />
134-
<RepositoriesOverview
135-
repositories={user.repositories}
136-
contributions={user.contributions}
137-
ownedStars={user.ownedStars}
138-
contributedStars={user.contributedStars}
139-
/>
140-
</div>
141-
<div>
142-
<ProfileTimeline timeline={user.timeline} firstSeenAt={user.firstSeenAt} />
143-
</div>
50+
<div>
51+
<ProfileTimeline timeline={user.timeline} firstSeenAt={user.firstSeenAt} />
14452
</div>
145-
</Page>
53+
</div>
14654
);
14755
}

0 commit comments

Comments
 (0)