Skip to content

Commit f57f657

Browse files
committed
country rankings 🔥
1 parent 325e446 commit f57f657

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1332
-297
lines changed

app/by/[rankingType]/[page]/page.tsx

Lines changed: 19 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -2,132 +2,39 @@
22

33
import { unstable_cacheLife as cacheLife } from 'next/cache';
44

5-
import { Page } from '@/components/page/page';
6-
import { RankDelta } from '@/components/rank-delta/rank-delta';
7-
import {
8-
Pagination,
9-
PaginationContent,
10-
PaginationItem,
11-
PaginationNext,
12-
PaginationPrevious,
13-
} from '@/components/ui/pagination';
14-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
15-
import { fetchCountryList } from '@/graphql/helpers/fetch-countries';
5+
import { Pagination } from '@/components/pagination/pagination';
6+
import { RankingTable } from '@/components/ranking-table/ranking-table';
7+
import { fetchCountries } from '@/graphql/helpers/fetch-countries';
168
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
17-
import { GlobalRankingsDocument, RankOrder } from '@/types/generated/graphql';
18-
import { getInitials } from '@/utils/get-initials';
19-
20-
import { ClickableRow } from './components/clickale-row';
21-
import { LinkWithStopPropagation } from './components/link-with-stop-propagation';
22-
import { ProfileAvatar } from './components/profile-avatar';
23-
import { getCountryFlag } from './utils/get-country-flag';
9+
import { GlobalRankingsDocument } from '@/types/generated/graphql';
10+
import { RankingTypeClient } from '@/types/ranking.types';
11+
import { getRankingOrder } from '@/utils/get-ranking-config-by-type';
2412

2513
const ITEMS_PER_PAGE = 100;
2614

27-
function getConfigByRankingType(rankingType: string) {
28-
let propName: 'c' | 'f' | 's';
29-
let queryOrder: RankOrder;
30-
let title: string;
31-
let subtitle: string;
32-
let rankingBaseEntity: string;
33-
34-
switch (rankingType) {
35-
case 'contributions':
36-
queryOrder = RankOrder.Contributions;
37-
propName = 'c';
38-
title = 'Contribution ranking';
39-
subtitle = "Rank is based on the stars from repositories where you've merged pull requests — excluding your own.";
40-
rankingBaseEntity = 'Stars';
41-
break;
42-
case 'followers':
43-
queryOrder = RankOrder.Followers;
44-
propName = 'f';
45-
title = 'Followers ranking';
46-
subtitle = 'Rank is based on the number of followers the user has on GitHub.';
47-
rankingBaseEntity = 'Followers';
48-
break;
49-
case 'stars':
50-
default:
51-
queryOrder = RankOrder.Stars;
52-
propName = 's';
53-
title = 'Star ranking';
54-
subtitle = 'Rank is based on the total number of stars across repositories owned by a user.';
55-
rankingBaseEntity = 'Stars';
56-
break;
57-
}
58-
59-
return [queryOrder, propName, title, subtitle, rankingBaseEntity] as const;
60-
}
15+
type GlobalRankingProps = {
16+
params: Promise<{ rankingType: RankingTypeClient; page: string }>;
17+
};
6118

62-
export default async function GlobalRanking({ params }: { params: Promise<{ rankingType: string; page: string }> }) {
19+
export default async function GlobalRanking({ params }: GlobalRankingProps) {
6320
cacheLife('hours');
6421

6522
const { rankingType, page: pageParam } = await params;
6623
const page = parseInt(pageParam, 10);
67-
const [queryOrder, rankPropName, title, subtitle, rankingBaseEntity] = getConfigByRankingType(rankingType);
24+
const queryOrder = getRankingOrder(rankingType);
6825
const offset = (page - 1) * ITEMS_PER_PAGE;
6926
const [{ globalRankings }, countries] = await Promise.all([
7027
graphqlDirect(GlobalRankingsDocument, { order: queryOrder, offset }),
71-
fetchCountryList(),
28+
fetchCountries(),
7229
]);
7330

7431
return (
75-
<Page className="max-w-5xl gap-6">
76-
<div>
77-
<h1 className="text-2xl font-semibold">{title}</h1>
78-
<div>{`${subtitle} Login or Search to see your rank.`}</div>
79-
</div>
80-
81-
<Table>
82-
<TableHeader className="[&_tr]:border-b-0">
83-
<TableRow>
84-
<TableHead className="w-[100px]">#</TableHead>
85-
<TableHead>Login</TableHead>
86-
<TableHead className="hidden sm:table-cell">Location</TableHead>
87-
<TableHead className="text-right">{rankingBaseEntity}</TableHead>
88-
</TableRow>
89-
</TableHeader>
90-
<TableBody>
91-
{globalRankings.map((item) => {
92-
const { githubId, user } = item;
93-
return (
94-
<ClickableRow key={githubId} className="border-b-0" href={`/profile/${user?.login}`}>
95-
<TableCell className="font-medium">
96-
<div className="flex items-end gap-1">
97-
{item[rankPropName]}
98-
{rankingType !== 'contributions' && ( // TODO remove this condition when contributions is fixed
99-
<RankDelta current={item[rankPropName]} previous={item[`${rankPropName}M`]} />
100-
)}
101-
</div>
102-
</TableCell>
103-
<TableCell>
104-
<LinkWithStopPropagation href={`/profile/${user?.login}`}>
105-
<ProfileAvatar url={user?.avatarUrl} initials={getInitials(user?.login)} />
106-
{user?.login}
107-
</LinkWithStopPropagation>
108-
</TableCell>
109-
<TableCell className="hidden sm:table-cell break-all whitespace-normal">
110-
{getCountryFlag(countries, user?.country)} {user?.location}
111-
</TableCell>
112-
<TableCell className="text-right">{user?.[rankPropName]?.toLocaleString('en-US')}</TableCell>
113-
</ClickableRow>
114-
);
115-
})}
116-
</TableBody>
117-
</Table>
118-
119-
<Pagination>
120-
<PaginationContent>
121-
{page > 1 && (
122-
<PaginationItem>
123-
<PaginationPrevious href={`/by/${rankingType}/${page - 1}`} />
124-
</PaginationItem>
125-
)}
126-
<PaginationItem>
127-
<PaginationNext href={`/by/${rankingType}/${page + 1}`} />
128-
</PaginationItem>
129-
</PaginationContent>
130-
</Pagination>
131-
</Page>
32+
<>
33+
<RankingTable rankingType={rankingType} data={globalRankings} countries={countries} />
34+
<Pagination
35+
prev={page > 1 ? `/by/${rankingType}/${page - 1}` : undefined}
36+
next={globalRankings?.length === ITEMS_PER_PAGE ? `/by/${rankingType}/${page + 1}` : undefined}
37+
/>
38+
</>
13239
);
13340
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
import { FC } from 'react';
3+
4+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5+
import { CountryQuery } from '@/types/generated/graphql';
6+
7+
type CountrySelectProps = {
8+
options?: CountryQuery['country'];
9+
};
10+
11+
export const CountrySelect: FC<CountrySelectProps> = ({ options }) => {
12+
return (
13+
<Select onValueChange={() => {}} defaultValue={''}>
14+
<SelectTrigger className="min-w-[200px]">
15+
<SelectValue placeholder="Select a country" />
16+
</SelectTrigger>
17+
<SelectContent>
18+
{options?.map((country) => (
19+
<SelectItem key={country.name} value={country.name}>
20+
{country.flag} {country.name}
21+
</SelectItem>
22+
))}
23+
</SelectContent>
24+
</Select>
25+
);
26+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FC } from 'react';
2+
3+
import { CountryQuery } from '@/types/generated/graphql';
4+
5+
import { CountrySelect } from './country-select';
6+
7+
type CountrySwitcherProps = {
8+
options?: CountryQuery['country'];
9+
};
10+
11+
export const CountrySwitcher: FC<CountrySwitcherProps> = ({ options }) => {
12+
return (
13+
<div className="text-sm flex flex-col gap-1">
14+
<div>Country:</div>
15+
<CountrySelect options={options} />
16+
</div>
17+
);
18+
};

app/by/[rankingType]/layout.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1+
'use cache';
2+
13
import type { Metadata } from 'next';
4+
import { unstable_cacheLife as cacheLife } from 'next/cache';
25

36
import { Header } from '@/components/header/header';
7+
import { Page } from '@/components/page/page';
8+
import { RankingHeaderSection } from '@/components/ranking-header-section/ranking-header-section';
9+
import { RankingTypeClient } from '@/types/ranking.types';
10+
11+
type GlobalRankingProps = {
12+
children: React.ReactNode;
13+
params: Promise<{ rankingType: RankingTypeClient; page: string }>;
14+
};
415

516
export const metadata: Metadata = {
617
title: 'GitRanks · GitHub Profile Analytics & Rankings',
@@ -17,11 +28,17 @@ export async function generateStaticParams() {
1728
];
1829
}
1930

20-
export default function RankingListLayout({ children }: Readonly<{ children: React.ReactNode }>) {
31+
export default async function RankingListLayout({ children, params }: GlobalRankingProps) {
32+
cacheLife('hours');
33+
const { rankingType } = await params;
34+
2135
return (
2236
<>
2337
<Header />
24-
{children}
38+
<Page className="max-w-5xl gap-6">
39+
<RankingHeaderSection rankingType={rankingType} />
40+
{children}
41+
</Page>
2542
</>
2643
);
2744
}

app/by/[rankingType]/loading.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { Page } from '@/components/page/page';
21
import { Skeleton } from '@/components/ui/skeleton';
32

43
export default function Loading() {
54
return (
6-
<Page className="max-w-5xl gap-6">
7-
<div className="flex flex-col gap-4">
8-
<Skeleton className="h-8 w-[200]" />
9-
<Skeleton className="h-5 w-full" />
10-
</div>
11-
</Page>
5+
<div className="flex flex-col gap-4">
6+
<Skeleton className="h-5 w-full" />
7+
<Skeleton className="h-5 w-full" />
8+
</div>
129
);
1310
}

app/by/[rankingType]/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { redirect } from 'next/navigation';
22

3-
export default async function RankingsPage({ params }: { params: Promise<{ rankingType: string }> }) {
3+
import { RankingTypeClient } from '@/types/ranking.types';
4+
5+
type RankingsPageProps = {
6+
params: Promise<{ rankingType: RankingTypeClient; page: string }>;
7+
};
8+
9+
export default async function RankingsPage({ params }: RankingsPageProps) {
410
const { rankingType } = await params;
511
redirect(`/by/${rankingType}/1`);
612
}

app/components/badge-section.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Link } from '@/components/link/link';
2+
3+
export const BadgeSection = () => {
4+
return (
5+
<div className="flex flex-col gap-4 grow py-8">
6+
<h2 className="text-2xl md:text-3xl font-semibold">Put Your GitHub Rank on Display</h2>
7+
<div>
8+
Show off your coding achievements with a dynamic GitHub badge. Let the world see exactly where you stand among
9+
millions of developers.
10+
</div>
11+
<div>
12+
<Link href="/badge">Create a badge</Link>
13+
</div>
14+
</div>
15+
);
16+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import LinkNext from 'next/link';
2+
3+
import { Link } from '@/components/link/link';
4+
import { Card, CardContent } from '@/components/ui/card';
5+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
6+
import { CountrySummaryQuery } from '@/types/generated/graphql';
7+
8+
export const CountryRankingLink = ({
9+
countrySummaries,
10+
}: {
11+
countrySummaries: CountrySummaryQuery['countrySummary'];
12+
}) => {
13+
return (
14+
<Card className="flex-grow gap-4">
15+
<CardContent className="flex flex-col gap-4">
16+
<div className="flex flex-grow items-center">
17+
<Link href="/countries/stars/1">Browse All Countries</Link>
18+
</div>
19+
<div className="grid grid-cols-[repeat(auto-fit,minmax(2.25rem,1fr))] auto-rows-[2.25rem] gap-1 overflow-hidden h-[calc(2.25rem*3+0.25rem*2)]">
20+
{countrySummaries.slice(2, 44).map((countrySummary) => {
21+
const {
22+
country: countryName,
23+
countryData: { flag },
24+
} = countrySummary;
25+
26+
return (
27+
<Tooltip key={countryName}>
28+
<TooltipTrigger asChild>
29+
<LinkNext href={`/country/${countryName}/stars/1`} className="flex items-center justify-center">
30+
{flag}
31+
</LinkNext>
32+
</TooltipTrigger>
33+
<TooltipContent>{countryName}</TooltipContent>
34+
</Tooltip>
35+
);
36+
})}
37+
</div>
38+
</CardContent>
39+
</Card>
40+
);
41+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { FlameIcon } from 'lucide-react';
2+
3+
import { CountryCard } from '@/components/country-card/country-card';
4+
import { Badge } from '@/components/ui/badge';
5+
import { fetchCountrySummaries } from '@/graphql/helpers/fetch-country-summaries';
6+
7+
import { CountryRankingLink } from './country-ranking-link';
8+
9+
export const CountryRankingSection = async () => {
10+
const countrySummaries = await fetchCountrySummaries();
11+
12+
return (
13+
<div className="flex flex-col gap-4 grow py-8">
14+
<h2 className="text-2xl md:text-3xl font-semibold flex items-center gap-2">
15+
Country Rankings
16+
<Badge variant="secondary" className="bg-blue-500 text-white dark:bg-blue-600">
17+
<FlameIcon />
18+
NEW
19+
</Badge>
20+
</h2>
21+
<div>
22+
Curious how you rank at home? Explore country-specific leaderboards to see the top developers in your nation,
23+
track your own standing, and celebrate local talent as it rises on the global stage.
24+
</div>
25+
<div className="grid grid-cols-[repeat(auto-fit,minmax(405px,1fr))] gap-4">
26+
{countrySummaries.slice(0, 2).map((countrySummary) => (
27+
<CountryCard key={countrySummary.country} countrySummary={countrySummary} />
28+
))}
29+
<CountryRankingLink countrySummaries={countrySummaries} />
30+
</div>
31+
</div>
32+
);
33+
};

0 commit comments

Comments
 (0)