Skip to content

Commit ed7ea29

Browse files
committed
profile overview tab: commit work
1 parent 18fced2 commit ed7ea29

File tree

13 files changed

+351
-32
lines changed

13 files changed

+351
-32
lines changed

app/by/[rankingType]/page.tsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import Link from 'next/link';
2+
13
import { Page } from '@/components/page/page';
4+
import { RankDelta } from '@/components/rank-delta/rank-delta';
25
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
36
import {
47
Pagination,
@@ -9,7 +12,6 @@ import {
912
} from '@/components/ui/pagination';
1013
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
1114
import { graphqlRequest } from '@/lib/graphql-request';
12-
import { cn } from '@/lib/utils';
1315
import { RankingsDocument, RankOrder } from '@/types/generated/graphql';
1416
import { getInitials } from '@/utils/get-initials';
1517

@@ -50,26 +52,6 @@ function getConfigByRankingType(rankingType: string) {
5052
return [queryOrder, propName, title, subtitle, rankingBaseEntity] as const;
5153
}
5254

53-
const getDelta = (ownedStars: number, ownedStarsM: number | null | undefined) => {
54-
if (!ownedStarsM) {
55-
return '';
56-
}
57-
58-
const difference = ownedStarsM - ownedStars;
59-
60-
if (difference === 0) {
61-
return '';
62-
}
63-
64-
const isPositive = difference > 0;
65-
66-
return (
67-
<div className={cn('text-xs', { 'text-positive': isPositive, 'text-negative': !isPositive })}>{`${
68-
isPositive ? '+' : ''
69-
}${difference?.toLocaleString('en-US')}`}</div>
70-
);
71-
};
72-
7355
export default async function GlobalRanking({
7456
searchParams,
7557
params,
@@ -107,7 +89,7 @@ export default async function GlobalRanking({
10789
<TableCell className="font-medium">
10890
<div className="flex items-end gap-1">
10991
{item[rankPropName]}
110-
{getDelta(item[rankPropName], item[`${rankPropName}M`])}
92+
<RankDelta current={item[rankPropName]} previous={item[`${rankPropName}M`]} />
11193
</div>
11294
</TableCell>
11395
<TableCell className="flex items-center gap-2">
@@ -117,7 +99,7 @@ export default async function GlobalRanking({
11799
<AvatarFallback>{getInitials(user?.login)}</AvatarFallback>
118100
</Avatar>
119101
)}
120-
{user?.login}
102+
<Link href={`/profile/${user?.login}`}>{user?.login}</Link>
121103
</TableCell>
122104
<TableCell className="hidden sm:table-cell break-all whitespace-normal">{user?.location}</TableCell>
123105
<TableCell className="text-right">{user?.[rankPropName]?.toLocaleString('en-US')}</TableCell>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Card, CardContent } from '@/components/ui/card';
2+
3+
export const ProfileCard = ({ title, children }) => {
4+
return (
5+
<Card className="border-border p-4 min-w-xs flex-grow basis-0">
6+
<CardContent className="p-0 flex flex-col gap-4">
7+
<h4 className="text-lg font-semibold">{title}</h4>
8+
<div className="flex flex-col gap-2">{children}</div>
9+
</CardContent>
10+
</Card>
11+
);
12+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ComponentType, FC } from 'react';
2+
3+
type ProfileListItemProps = {
4+
value?: string | number | null;
5+
Icon: ComponentType<{ size: number; className?: string }>;
6+
};
7+
8+
export const ProfileListItem: FC<ProfileListItemProps> = ({ value, Icon }) => {
9+
if (!value) {
10+
return null;
11+
}
12+
13+
return (
14+
<div className="flex gap-2 items-center break-all">
15+
<Icon size={20} className="shrink-0" />
16+
{value}
17+
</div>
18+
);
19+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { FC } from 'react';
2+
3+
import { RankDelta } from '@/components/rank-delta/rank-delta';
4+
import { UserQuery } from '@/types/generated/graphql';
5+
6+
import { ProfileCard } from './profile-card';
7+
8+
type RanksOverviewProps = {
9+
ranksData: UserQuery['user']['rank'];
10+
};
11+
12+
export const RanksOverview: FC<RanksOverviewProps> = ({ ranksData }) => {
13+
return (
14+
<ProfileCard title="Ranks">
15+
<p>
16+
⭐&nbsp;&nbsp;Stars rank: {ranksData?.ownedStars?.toLocaleString('en-US')}{' '}
17+
<RankDelta current={ranksData?.ownedStars} previous={ranksData?.ownedStarsM} />
18+
</p>
19+
<p>
20+
🔀&nbsp;&nbsp;Contributor rank: {ranksData?.contributedStars?.toLocaleString('en-US')}
21+
<RankDelta current={ranksData?.contributedStars} previous={ranksData?.contributedStarsM} />
22+
</p>
23+
<p>
24+
👥&nbsp;&nbsp;Followers rank: {ranksData?.followersCount?.toLocaleString('en-US')}
25+
<RankDelta current={ranksData?.followersCount} previous={ranksData?.followersCountM} />
26+
</p>
27+
</ProfileCard>
28+
);
29+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { FC } from 'react';
2+
3+
import { UserQuery } from '@/types/generated/graphql';
4+
5+
import { ProfileCard } from './profile-card';
6+
7+
type RepositoriesOverviewProps = {
8+
repositories: UserQuery['user']['repositories'];
9+
contributions: UserQuery['user']['contributions'];
10+
ownedStars: UserQuery['user']['ownedStars'];
11+
contributedStars: UserQuery['user']['contributedStars'];
12+
};
13+
14+
export const RepositoriesOverview: FC<RepositoriesOverviewProps> = ({
15+
repositories,
16+
contributions,
17+
ownedStars,
18+
contributedStars,
19+
}) => {
20+
const topRepoStars = repositories?.reduce((topStars, repo) => {
21+
if (repo.stargazerCount > topStars) {
22+
return repo.stargazerCount;
23+
}
24+
return topStars;
25+
}, 0);
26+
27+
const contributedRepoCount = [...new Set(contributions?.map((contribution) => contribution?.repository?.githubId))]
28+
.length;
29+
30+
return (
31+
<ProfileCard title="Repositories">
32+
<p>
33+
📦&nbsp;&nbsp;Owns {repositories?.length} repos
34+
{!!ownedStars && ` • ⭐ ${ownedStars?.toLocaleString('en-US')} total`}
35+
</p>
36+
<p>
37+
🏆&nbsp;&nbsp;Top repository
38+
{topRepoStars ? ` • ⭐ ${topRepoStars?.toLocaleString('en-US')}` : ' not found ( •_•) 🔍'}
39+
</p>
40+
<p>
41+
🤝&nbsp;&nbsp;Contributed to {contributedRepoCount} repos
42+
{!!contributedRepoCount && ` • ⭐ ${contributedStars?.toLocaleString('en-US')} total`}
43+
</p>
44+
</ProfileCard>
45+
);
46+
};

app/profile/[login]/page.tsx

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1+
import { formatDistanceToNow } from 'date-fns';
2+
import {
3+
BriefcaseBusiness,
4+
ExternalLink,
5+
Hourglass,
6+
Link2,
7+
Mail,
8+
MapPin,
9+
RefreshCw,
10+
Timer,
11+
UsersRound,
12+
} from 'lucide-react';
113
import type { Metadata } from 'next';
14+
import Image from 'next/image';
15+
import Link from 'next/link';
216

317
import { Page } from '@/components/page/page';
18+
import { AspectRatio } from '@/components/ui/aspect-ratio';
19+
import { Avatar, AvatarImage } from '@/components/ui/avatar';
20+
import { Button } from '@/components/ui/button';
21+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
422
import { graphqlRequest } from '@/lib/graphql-request';
523
import { ProfileForMetadataDocument, UserDocument } from '@/types/generated/graphql';
624

25+
import { ProfileListItem } from './components/profile-list-item';
26+
import { RanksOverview } from './components/ranks-overview';
27+
import { RepositoriesOverview } from './components/repositories-overiview';
28+
import { getSocialIcon } from './utils/get-social-icon';
29+
730
type Props = {
831
params: Promise<{ login: string }>;
932
};
@@ -28,12 +51,102 @@ export default async function Profile({ params }: { params: Promise<{ login: str
2851
const { login } = await params;
2952
const { user } = (await graphqlRequest(UserDocument, { login })) ?? {};
3053

31-
console.log('user', user);
54+
const showContact = !!user?.email || !!user?.websiteUrl || !!user?.socialAccounts?.nodes?.length;
3255

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

app/profile/[login]/profile-summary.gql

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
fragment RepositoryFields on Repository {
2+
githubId
23
createdAt
34
pushedAt
45
url
@@ -51,7 +52,6 @@ query User($login: String!) {
5152
name
5253
}
5354
contributions {
54-
repoId
5555
year
5656
prsCount
5757
repository {
@@ -65,5 +65,13 @@ query User($login: String!) {
6565
changes
6666
createdAt
6767
}
68+
rank {
69+
ownedStars
70+
ownedStarsM
71+
contributedStars
72+
contributedStarsM
73+
followersCount
74+
followersCountM
75+
}
6876
}
6977
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { FaMastodon } from 'react-icons/fa';
2+
import { FaBluesky, FaXTwitter, FaInstagram, FaLinkedinIn, FaStackOverflow } from 'react-icons/fa6';
3+
import { IoShareSocialOutline } from 'react-icons/io5';
4+
5+
export const getSocialIcon = (provider: string) => {
6+
switch (provider) {
7+
case 'TWITTER':
8+
return FaXTwitter;
9+
case 'MASTODON':
10+
return FaMastodon;
11+
case 'BLUESKY':
12+
return FaBluesky;
13+
case 'INSTAGRAM':
14+
return FaInstagram;
15+
case 'LINKEDIN':
16+
return FaLinkedinIn;
17+
case 'STACKOVERFLOW':
18+
return FaStackOverflow;
19+
case 'GENERIC':
20+
default:
21+
return IoShareSocialOutline;
22+
}
23+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { FC } from 'react';
2+
3+
import { cn } from '@/lib/utils';
4+
5+
type RankDeltaProps = {
6+
current?: number | null;
7+
previous?: number | null;
8+
};
9+
10+
export const RankDelta: FC<RankDeltaProps> = ({ current, previous }) => {
11+
if (!previous || !current) {
12+
return null;
13+
}
14+
15+
const difference = previous - current;
16+
17+
if (difference === 0) {
18+
return null;
19+
}
20+
21+
const isPositive = difference > 0;
22+
23+
return (
24+
<span className={cn('text-xs', { 'text-positive': isPositive, 'text-negative': !isPositive })}>{`${
25+
isPositive ? '+' : ''
26+
}${difference?.toLocaleString('en-US')}`}</span>
27+
);
28+
};

0 commit comments

Comments
 (0)