Skip to content

Commit 6421a5f

Browse files
committed
profile page: add own projects and public contributions cards
1 parent deaeee2 commit 6421a5f

16 files changed

Lines changed: 674 additions & 46 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { FC } from 'react';
2+
3+
import { Link } from '@/components/link/link';
4+
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from '@/components/ui/popover';
5+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
6+
import { cn } from '@/lib/utils';
7+
import type { PageProfileOverviewQuery } from '@/types/generated/graphql';
8+
import { formatNumberShort } from '@/utils/format-number-short';
9+
import { getPercentageIcon } from '@/utils/get-percentage-icon';
10+
import { getRepoName, getRepoUrl } from '@/utils/repositories';
11+
12+
export type RepoSource = {
13+
name: string;
14+
stargazerCount: number;
15+
percentage: number;
16+
year?: number | null;
17+
};
18+
19+
export type ContributionSource = RepoSource & {
20+
prsCount: number;
21+
linesAdded: number;
22+
linesRemoved: number;
23+
url: string;
24+
};
25+
26+
export type LanguageSource = RepoSource | ContributionSource;
27+
28+
type LangListWithSourcesProps = {
29+
languages?:
30+
| NonNullable<PageProfileOverviewQuery['user']>['sLangs']
31+
| NonNullable<PageProfileOverviewQuery['user']>['cLangs'];
32+
login: string;
33+
};
34+
35+
const isContributionSources = (sources: LanguageSource[]): sources is ContributionSource[] =>
36+
sources.length > 0 && sources.every((s): s is ContributionSource => 'url' in s);
37+
38+
export const LangListWithSources: FC<LangListWithSourcesProps> = ({ languages, login }) => {
39+
if (!languages?.length) {
40+
return null;
41+
}
42+
43+
const getRepoStats = (sources: LanguageSource[] | undefined | null, languageName: string) => {
44+
if (!sources?.length) {
45+
return null;
46+
}
47+
48+
const showContributionColumns = isContributionSources(sources);
49+
const showYearColumn = sources.some((source) => source.year);
50+
51+
return (
52+
<Table>
53+
<TableHeader>
54+
<TableRow>
55+
<TableHead>Name</TableHead>
56+
<TableHead>Stars</TableHead>
57+
<TableHead>Lang %</TableHead>
58+
{showContributionColumns && (
59+
<>
60+
<TableHead>PRs</TableHead>
61+
<TableHead>Changes</TableHead>
62+
</>
63+
)}
64+
{showYearColumn && <TableHead>Year</TableHead>}
65+
</TableRow>
66+
</TableHeader>
67+
<TableBody className="text-xs">
68+
{sources.map((source) => {
69+
const pct = source.percentage * 100;
70+
const { Icon, fillClass } = getPercentageIcon(pct);
71+
72+
return (
73+
<TableRow key={source.name}>
74+
<TableCell className="font-medium text-sm">
75+
<Link target="_blank" href={getRepoUrl(source, login)}>
76+
{getRepoName(source)}
77+
</Link>
78+
</TableCell>
79+
<TableCell>{source.stargazerCount.toLocaleString()}</TableCell>
80+
<TableCell
81+
className="font-semibold gap-1"
82+
title={`${languageName} accounts for ${formatNumberShort(pct)}% of the code in this repository`}
83+
>
84+
<div className="flex items-center gap-1">
85+
<Icon className={cn('size-4 shrink-0', fillClass)} />
86+
{formatNumberShort(pct)}
87+
</div>
88+
</TableCell>
89+
{showContributionColumns && 'prsCount' in source && (
90+
<>
91+
<TableCell>{formatNumberShort(source.prsCount)}</TableCell>
92+
<TableCell className="font-semibold gap-1">
93+
<span className="text-positive">+{formatNumberShort(source.linesAdded)}</span>&nbsp;
94+
<span className="text-negative">-{formatNumberShort(source.linesRemoved)}</span>
95+
</TableCell>
96+
</>
97+
)}
98+
{showYearColumn && <TableCell>{source.year}</TableCell>}
99+
</TableRow>
100+
);
101+
})}
102+
</TableBody>
103+
</Table>
104+
);
105+
};
106+
107+
const renderLanguageName = (lang: (typeof languages)[number]) => {
108+
if (!lang.sources?.length) {
109+
return <span>{lang.name}</span>;
110+
}
111+
112+
return (
113+
<Popover>
114+
<PopoverTrigger asChild>
115+
<span className="underline decoration-dotted underline-offset-4 cursor-pointer">{lang.name}</span>
116+
</PopoverTrigger>
117+
<PopoverContent className="w-max">
118+
<PopoverHeader>
119+
<PopoverTitle>
120+
Top 5 <b>{lang.name}</b> Repositories by Lines Changed
121+
</PopoverTitle>
122+
</PopoverHeader>
123+
{getRepoStats(lang.sources, lang.name)}
124+
</PopoverContent>
125+
</Popover>
126+
);
127+
};
128+
129+
return (
130+
<>
131+
{languages.map((lang) => (
132+
<div key={lang.name} className="flex items-center gap-1">
133+
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: lang.color ?? '#cccccc' }} />
134+
{renderLanguageName(lang)}
135+
</div>
136+
))}
137+
</>
138+
);
139+
};
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { InfoIcon } from 'lucide-react';
2+
import type { FC } from 'react';
3+
4+
import { LangListWithSources } from '../lang-list-with-sources';
5+
import { ProfileCard, ProfileCardHeader } from '../profile-card';
6+
import { AdaptiveTooltip } from '@/components/adaptive-tooltip/adaptive-tooltip';
7+
import ChartStarsSnapshot from '@/components/chart-stars-snapshot/chart-stars-snapshot';
8+
import type { PageProfileOverviewQuery } from '@/types/generated/graphql';
9+
import { formatNumberShort } from '@/utils/format-number-short';
10+
11+
type OsContributionsCardProps = {
12+
login: string;
13+
totalRankedUsers: number;
14+
userRank?: number | null;
15+
repoCount: number;
16+
repoStars: number;
17+
languages: NonNullable<PageProfileOverviewQuery['user']>['cLangs'];
18+
snapshots: NonNullable<PageProfileOverviewQuery['user']>['snapshots'];
19+
};
20+
21+
export const OsContributionsCard: FC<OsContributionsCardProps> = ({
22+
login,
23+
repoCount,
24+
repoStars,
25+
languages,
26+
snapshots,
27+
totalRankedUsers,
28+
userRank,
29+
}) => {
30+
const getTopRankedMessage = () => {
31+
if (!totalRankedUsers || !userRank) {
32+
return null;
33+
}
34+
35+
const percentage = Math.max((userRank / totalRankedUsers) * 100, 0.1);
36+
37+
return (
38+
<div className="flex items-center gap-1.5">
39+
{percentage > 50 ? 'Bottom' : 'Top'} {formatNumberShort(percentage > 50 ? 100 - percentage : percentage)}% of
40+
ranked profiles
41+
</div>
42+
);
43+
};
44+
45+
const getTooltip = () => (
46+
<AdaptiveTooltip trigger={<InfoIcon size={20} />}>
47+
<div className="max-w-80 text-sm">
48+
<div>
49+
<b>What counts as a contribution?</b>
50+
</div>
51+
<div className="mt-1">
52+
We count <b>merged pull requests</b> to <b>public repositories not owned by this profile</b> as open source
53+
contributions.
54+
</div>
55+
<div className="mt-1">
56+
This helps highlight meaningful collaboration with open source projects outside of your own repositories.
57+
</div>
58+
</div>
59+
</AdaptiveTooltip>
60+
);
61+
62+
const getCardContent = () => {
63+
if (!repoCount) {
64+
return <div className="flex grow items-center p-3 md:p-4">This profile has no public contributions.</div>;
65+
}
66+
67+
return (
68+
<div className="flex flex-col gap-3">
69+
<ProfileCardHeader meta={getTooltip()}>
70+
<span className="font-semibold">Open Source Contributions</span>
71+
</ProfileCardHeader>
72+
<div className="flex flex-col gap-1.5">
73+
<div className="flex-inline items-center">
74+
Contributed to {repoCount} repositories{' '}
75+
{!!repoStars && (
76+
<>
77+
{' '}
78+
with <span className="font-semibold">{formatNumberShort(repoStars)}</span> stars
79+
</>
80+
)}
81+
</div>
82+
{getTopRankedMessage()}
83+
{!!languages?.length && (
84+
<div className="flex gap-2 flex-wrap leading-tight">
85+
Languages: <LangListWithSources languages={languages} login={login} />
86+
</div>
87+
)}
88+
</div>
89+
</div>
90+
);
91+
};
92+
return (
93+
<ProfileCard className="gap-2">
94+
{getCardContent()}
95+
<ChartStarsSnapshot snapshots={snapshots} metric="c" className="mt-auto" />
96+
</ProfileCard>
97+
);
98+
};

app/profile/[login]/components/overview-cards/overview-language-card.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,34 @@ export const ProfileLanguageCard: FC<ProfileLanguageCardProps> = ({ login, langu
1818
if (!languages?.length) {
1919
return (
2020
<div className="flex grow items-center p-3 md:p-4">
21-
No languages to show yet! Looks like your profile doesn&apos;t have any public repos with code. Once you share
22-
some, we&apos;ll chart your top languages here.
21+
No languages to show yet. This profile has no public repos with code. Once there are some, we&apos;ll show
22+
their top languages here.
2323
</div>
2424
);
2525
}
2626

27-
return <BarChartLanguages languages={languages} className="px-3 md:px-4" />;
27+
return <BarChartLanguages languages={languages.slice(0, 3)} className="px-3 md:px-4" />;
2828
};
2929

3030
const getTooltip = () => (
3131
<AdaptiveTooltip trigger={<InfoIcon size={20} />}>
3232
<div className="max-w-80 text-sm">
3333
<div>
34-
Every repo has stars and a language breakdown. We calculate language stars by multiplying the repo&apos;s
35-
stars by the share of a language in that repo.
34+
Each repo has stars and a language breakdown. We get &quot;language stars&quot; by multiplying the repo&apos;s
35+
stars by that language&apos;s share in the repo.
3636
</div>
3737
<div className="mt-1">
3838
<b>Example:</b> A repo with 100 stars that&apos;s 60% JavaScript gives JavaScript 60 stars.
3939
</div>
40-
<div className="mt-1">
41-
We do this for all public repos, add them up, and rank all languages by their total stars.
42-
</div>
40+
<div className="mt-1">We do this for all public repos, sum the totals, and rank languages by total stars.</div>
4341
</div>
4442
</AdaptiveTooltip>
4543
);
4644

4745
return (
4846
<ProfileCard className="gap-2 p-0 md:p-0">
4947
<ProfileCardHeader meta={getTooltip()} className="p-3 md:p-4 pb-0 md:pb-0">
50-
Top 3 Languages By Stars
48+
Top 3 Languages by Stars
5149
</ProfileCardHeader>
5250
{getCardContent()}
5351
<Link
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { FC } from 'react';
2+
3+
import { LangListWithSources } from '../lang-list-with-sources';
4+
import { ProfileCard, ProfileCardHeader } from '../profile-card';
5+
import ChartStarsSnapshot from '@/components/chart-stars-snapshot/chart-stars-snapshot';
6+
import type { PageProfileOverviewQuery } from '@/types/generated/graphql';
7+
import { formatNumberShort } from '@/utils/format-number-short';
8+
9+
type OwnProjectsCardProps = {
10+
login: string;
11+
totalRankedUsers: number;
12+
userRank?: number | null;
13+
repoCount: number;
14+
repoStars: number;
15+
languages: NonNullable<PageProfileOverviewQuery['user']>['cLangs'];
16+
snapshots: NonNullable<PageProfileOverviewQuery['user']>['snapshots'];
17+
};
18+
19+
export const OwnProjectsCard: FC<OwnProjectsCardProps> = ({
20+
login,
21+
repoCount,
22+
repoStars,
23+
languages,
24+
snapshots,
25+
totalRankedUsers,
26+
userRank,
27+
}) => {
28+
const getTopRankedMessage = () => {
29+
if (!totalRankedUsers || !userRank) {
30+
return null;
31+
}
32+
33+
const percentage = Math.max((userRank / totalRankedUsers) * 100, 0.1);
34+
35+
return (
36+
<div className="flex items-center gap-1.5">
37+
{percentage > 50 ? 'Bottom' : 'Top'} {formatNumberShort(percentage > 50 ? 100 - percentage : percentage)}% of
38+
ranked profiles
39+
</div>
40+
);
41+
};
42+
43+
const getCardContent = () => {
44+
if (!repoCount) {
45+
return <div className="flex grow items-center p-3 md:p-4">No public repositories.</div>;
46+
}
47+
48+
return (
49+
<div className="flex flex-col gap-3">
50+
<ProfileCardHeader>
51+
<span className="font-semibold">Own Projects</span>
52+
</ProfileCardHeader>
53+
<div className="flex flex-col gap-1.5">
54+
<div className="flex-inline items-center">
55+
{repoCount} public repositories{' '}
56+
{!!repoStars && (
57+
<>
58+
{' '}
59+
with <span className="font-semibold">{formatNumberShort(repoStars)}</span> stars
60+
</>
61+
)}
62+
</div>
63+
{getTopRankedMessage()}
64+
{!!languages?.length && (
65+
<div className="flex gap-2 flex-wrap leading-tight">
66+
Languages:
67+
<LangListWithSources languages={languages} login={login} />
68+
</div>
69+
)}
70+
</div>
71+
</div>
72+
);
73+
};
74+
75+
return (
76+
<ProfileCard className="gap-2">
77+
{getCardContent()}
78+
<ChartStarsSnapshot snapshots={snapshots} metric="s" className="mt-auto" />
79+
</ProfileCard>
80+
);
81+
};

0 commit comments

Comments
 (0)