GH 3365 Add contributors page on twenty-website (#3745)

* add transition in mobile navbar

* add contributors listing page

* Add breadcrumb component

* Add profilecard component

* Make profile info dynamic

* Style activity log component

* Make title a re-usable component

* Make card container re-usable

* add rank and active days logic

* complete single contributor page

* add styles for mobile

* Add github link

* Reset header desktop

* update calendar height

* remove conditional header

* add GH PR link

* display 10 prs

* Remove employees and fix rank

* Unrelated CSS adjustment

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Deepak Kumar
2024-02-05 18:26:12 +05:30
committed by GitHub
parent 33bb48e681
commit 8dda4b0b8f
20 changed files with 749 additions and 127 deletions

View File

@ -2,10 +2,28 @@
import { ResponsiveTimeRange } from '@nivo/calendar';
import { CardContainer } from '@/app/developers/contributors/[slug]/components/CardContainer';
import { Title } from '@/app/developers/contributors/[slug]/components/Title';
export const ActivityLog = ({
data,
}: {
data: { value: number; day: string }[];
}) => {
return <ResponsiveTimeRange data={data} />;
return (
<CardContainer>
<Title>Activity</Title>
<div style={{ width: '100%', height: '214px' }}>
<ResponsiveTimeRange
data={data}
emptyColor="#F4EFFF"
colors={['#E9DFFF', '#B28FFE', '#915FFD']}
dayBorderWidth={2}
dayBorderColor="#ffffff"
dayRadius={4}
daySpacing={2}
/>
</div>
</CardContainer>
);
};

View File

@ -0,0 +1,16 @@
'use client';
import { Breadcrumbs } from '@/app/components/Breadcrumbs';
const BREADCRUMB_ITEMS = [
{
uri: '/developers/contributors',
label: 'Contributors',
},
];
export const Breadcrumb = ({ active }: { active: string }) => {
return (
<Breadcrumbs items={BREADCRUMB_ITEMS} activePage={active} separator="/" />
);
};

View File

@ -0,0 +1,17 @@
import styled from '@emotion/styled';
export const CardContainer = styled.div`
border: 3px solid #141414;
width: 100%;
border-radius: 12px;
padding: 40px;
display: flex;
gap: 32px;
flex-direction: column;
background-color: #fafafa;
@media (max-width: 810px) {
gap: 16px;
padding: 24px;
}
`;

View File

@ -0,0 +1,26 @@
'use client';
import styled from '@emotion/styled';
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
max-width: 898px;
padding: 40px;
gap: 40px;
@media (max-width: 809px) {
width: 100%;
padding: 40px 24px 40px 24px;
}
`;
export const ContentContainer = ({
children,
}: {
children?: React.ReactNode;
}) => {
return <Container>{children}</Container>;
};

View File

@ -0,0 +1,100 @@
'use client';
import styled from '@emotion/styled';
import { format } from 'date-fns';
import { GithubIcon } from '@/app/components/Icons';
const ProfileContainer = styled.div`
display: flex;
gap: 32px;
width: 100%;
align-items: center;
`;
const Avatar = styled.div`
border: 3px solid #141414;
width: 96px;
height: 96px;
border-radius: 16px;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
display: block;
height: 100%;
object-fit: cover;
}
`;
const Details = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
.username {
font-size: 40px;
font-weight: 700;
line-height: 48px;
margin: 0;
display: flex;
gap: 12px;
@media (max-width: 810px) {
font-size: 24px;
line-height: 28.8px;
}
}
.duration {
font-size: 24px;
font-weight: 400;
color: #818181;
margin: 0;
@media (max-width: 810px) {
font-size: 20px;
line-height: 28px;
}
}
`;
const StyledGithubIcon = styled(GithubIcon)`
@media (max-width: 810px) {
width: 20px;
height: 20px;
}
`;
interface ProfileCardProps {
username: string;
avatarUrl: string;
firstContributionAt: string;
}
export const ProfileCard = ({
username,
avatarUrl,
firstContributionAt,
}: ProfileCardProps) => {
return (
<ProfileContainer>
<Avatar>
<img src={avatarUrl} alt={username} />
</Avatar>
<Details>
<h3 className="username">
@{username}
<a href={`https://github.com/${username}`} target="_blank">
<StyledGithubIcon size="M" color="rgba(0,0,0,1)" />
</a>
</h3>
<p className="duration">
Contributing since{' '}
{format(new Date(firstContributionAt), 'MMMM yyyy')}
</p>
</Details>
</ProfileContainer>
);
};

View File

@ -0,0 +1,86 @@
'use client';
import styled from '@emotion/styled';
import { CardContainer } from '@/app/developers/contributors/[slug]/components/CardContainer';
const Container = styled(CardContainer)`
flex-direction: row;
@media (max-width: 810px) {
flex-direction: column;
}
.item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-grow: 1;
flex-basis: 0;
.title {
font-size: 24px;
color: #b3b3b3;
margin: 0;
@media (max-width: 810px) {
font-size: 20px;
}
}
.value {
font-size: 56px;
font-weight: 700;
color: #474747;
@media (max-width: 810px) {
font-size: 32px;
}
}
}
.separator {
width: 2px;
background-color: #141414;
border-radius: 40px;
@media (max-width: 810px) {
width: 100%;
height: 2px;
}
}
`;
interface ProfileInfoProps {
mergedPRsCount: number;
rank: string;
activeDays: number;
}
export const ProfileInfo = ({
mergedPRsCount,
rank,
activeDays,
}: ProfileInfoProps) => {
return (
<>
<Container>
<div className="item">
<p className="title">Merged PR</p>
<span className="value">{mergedPRsCount}</span>
</div>
<div className="separator"></div>
<div className="item">
<p className="title">Rank</p>
<span className="value">{rank}%</span>
</div>
<div className="separator"></div>
<div className="item">
<p className="title">Active Days</p>
<span className="value">{activeDays}</span>
</div>
</Container>
</>
);
};

View File

@ -0,0 +1,94 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { format } from 'date-fns';
import { PullRequestIcon } from '@/app/components/Icons';
import { formatIntoRelativeDate } from '@/lib/utils';
const StyledTooltip = styled(Tooltip)``;
const Item = styled.div`
display: flex;
gap: 17px;
`;
const StyledTitle = styled.a`
font-size: 24px;
font-weight: 400;
color: #474747;
margin: 0;
text-decoration: none;
@media (max-width: 810px) {
font-size: 20px;
}
`;
const StyledDescription = styled.div`
font-size: 20px;
line-height: 28px;
font-weight: 500;
color: #b3b3b3;
@media (max-width: 810px) {
font-size: 18px;
}
`;
const StyledPullRequestIcon = styled(PullRequestIcon)`
@media screen {
width: 20px;
height: 20px;
}
`;
interface PullRequestItemProps {
id: string;
title: string;
url: string;
createdAt: string;
mergedAt: string | null;
authorId: string;
}
export const PullRequestItem = ({
id,
title,
url,
createdAt,
mergedAt,
authorId,
}: PullRequestItemProps) => {
const prNumber = url.split('/').slice(-1)[0];
return (
<Item key={id}>
<div>
<StyledPullRequestIcon
color={mergedAt ? '#915FFD' : '#1A7F37'}
size="M"
/>
</div>
<div>
<StyledTitle href={url} target="_blank">
{title}
</StyledTitle>
<StyledDescription>
#{prNumber} by {authorId.slice(1)} was{' '}
{mergedAt ? `merged` : `opened`}{' '}
<span id={`date-${prNumber}`}>
{formatIntoRelativeDate(mergedAt ? mergedAt : createdAt)}
</span>
<StyledTooltip
anchorSelect={`#date-${prNumber}`}
content={format(
new Date(mergedAt ? mergedAt : createdAt),
'dd MMMM yyyy',
)}
clickable
noArrow
/>
</StyledDescription>
</div>
</Item>
);
};

View File

@ -0,0 +1,44 @@
'use client';
import styled from '@emotion/styled';
import { CardContainer } from '@/app/developers/contributors/[slug]/components/CardContainer';
import { PullRequestItem } from '@/app/developers/contributors/[slug]/components/PullRequestItem';
import { Title } from '@/app/developers/contributors/[slug]/components/Title';
const List = styled.div`
display: flex;
gap: 24px;
flex-direction: column;
`;
interface PullRequestsProps {
list: {
id: string;
title: string;
url: string;
createdAt: string;
mergedAt: string | null;
authorId: string;
}[];
}
export const PullRequests = ({ list }: PullRequestsProps) => {
return (
<CardContainer>
<Title>Pull Requests</Title>
<List>
{list.map((pr) => (
<PullRequestItem
key={pr.id}
id={pr.id}
title={pr.title}
url={pr.url}
createdAt={pr.createdAt}
mergedAt={pr.mergedAt}
authorId={pr.authorId}
/>
))}
</List>
</CardContainer>
);
};

View File

@ -0,0 +1,38 @@
'use client';
import styled from '@emotion/styled';
import { HeartIcon } from '@/app/components/Icons';
import { CardContainer } from '@/app/developers/contributors/[slug]/components/CardContainer';
const StyledTitle = styled.div`
display: flex;
font-size: 24px;
font-weight: 500;
gap: 8px;
@media (max-width: 810px) {
font-size: 20px;
}
`;
const StyledHeartIcon = styled(HeartIcon)`
@media (max-width: 810px) {
width: 16px !important;
height: 16px !important;
}
`;
interface ThankYouProps {
authorId: string;
}
export const ThankYou = ({ authorId }: ThankYouProps) => {
return (
<CardContainer>
<StyledTitle>
Thank you @{authorId} <StyledHeartIcon color="#333333" size="18px" />
</StyledTitle>
</CardContainer>
);
};

View File

@ -0,0 +1,13 @@
import styled from '@emotion/styled';
export const Title = styled.h3`
margin: 0;
font-size: 32px;
line-height: 41.6px;
font-weight: 500;
@media (max-width: 810px) {
font-size: 24px;
line-height: 31.2px;
}
`;

View File

@ -1,8 +1,14 @@
import Database from 'better-sqlite3';
import { Metadata } from 'next';
import Image from 'next/image';
import { ActivityLog } from './components/ActivityLog';
import { Background } from '@/app/components/oss-friends/Background';
import { ActivityLog } from '@/app/developers/contributors/[slug]/components/ActivityLog';
import { Breadcrumb } from '@/app/developers/contributors/[slug]/components/Breadcrumb';
import { ContentContainer } from '@/app/developers/contributors/[slug]/components/ContentContainer';
import { ProfileCard } from '@/app/developers/contributors/[slug]/components/ProfileCard';
import { ProfileInfo } from '@/app/developers/contributors/[slug]/components/ProfileInfo';
import { PullRequests } from '@/app/developers/contributors/[slug]/components/PullRequests';
import { ThankYou } from '@/app/developers/contributors/[slug]/components/ThankYou';
interface Contributor {
login: string;
@ -57,6 +63,7 @@ export default async function ({ params }: { params: { slug: string } }) {
)
.all({ user_id: params.slug }) as { value: number; day: string }[];
// Latest PRs.
const pullRequestList = db
.prepare(
`
@ -68,53 +75,88 @@ export default async function ({ params }: { params: { slug: string } }) {
createdAt,
updatedAt,
closedAt,
mergedAt
mergedAt,
authorId
FROM
pullRequests
WHERE
authorId = (SELECT id FROM users WHERE login = :user_id)
ORDER BY
DATE(createdAt) DESC
LIMIT
10
`,
)
.all({ user_id: params.slug }) as {
title: string;
createdAt: string;
url: string;
id: string;
mergedAt: string | null;
authorId: string;
}[];
const mergedPullRequests = db
.prepare(
`
SELECT * FROM (
SELECT
merged_pr_counts.*,
(RANK() OVER(ORDER BY merged_count) - 1) / CAST( total_authors as float) * 100 as rank_percentage
FROM
(
SELECT
authorId,
COUNT(*) FILTER (WHERE mergedAt IS NOT NULL) as merged_count
FROM
pullRequests pr
JOIN
users u ON pr.authorId = u.id
WHERE
u.isEmployee = FALSE
GROUP BY
authorId
) AS merged_pr_counts
CROSS JOIN
(
SELECT COUNT(DISTINCT authorId) as total_authors
FROM pullRequests pr
JOIN
users u ON pr.authorId = u.id
WHERE
u.isEmployee = FALSE
) AS author_counts
) WHERE authorId = (SELECT id FROM users WHERE login = :user_id)
`,
)
.all({ user_id: params.slug }) as {
merged_count: number;
rank_percentage: number;
}[];
db.close();
return (
<div
style={{
maxWidth: '900px',
display: 'flex',
padding: '40px',
gap: '24px',
}}
>
<div style={{ flexDirection: 'column', width: '240px' }}>
<Image
src={contributor.avatarUrl}
alt={contributor.login}
width={240}
height={240}
<>
<Background />
<ContentContainer>
<Breadcrumb active={contributor.login} />
<ProfileCard
username={contributor.login}
avatarUrl={contributor.avatarUrl}
firstContributionAt={pullRequestActivity[0].day}
/>
<h1>{contributor.login}</h1>
</div>
<div style={{ flexDirection: 'column' }}>
<div style={{ width: '450px', height: '200px' }}>
<ActivityLog data={pullRequestActivity} />
</div>
<div style={{ width: '450px' }}>
{pullRequestList.map((pr) => (
<div>
<a href={pr.url}>{pr.title}</a>
</div>
))}
</div>
</div>
</div>
<ProfileInfo
mergedPRsCount={mergedPullRequests[0].merged_count}
rank={(100 - Number(mergedPullRequests[0].rank_percentage)).toFixed(
0,
)}
activeDays={pullRequestActivity.length}
/>
<ActivityLog data={pullRequestActivity} />
<PullRequests list={pullRequestList} />
<ThankYou authorId={contributor.login} />
</ContentContainer>
</>
);
}

View File

@ -1,6 +1,9 @@
import Database from 'better-sqlite3';
import AvatarGrid from '@/app/components/AvatarGrid';
import { Header } from '@/app/components/developers/contributors/Header';
import { Background } from '@/app/components/oss-friends/Background';
import { ContentContainer } from '@/app/components/oss-friends/ContentContainer';
interface Contributor {
login: string;
@ -14,16 +17,19 @@ const Contributors = async () => {
const contributors = db
.prepare(
`SELECT
u.login,
u.avatarUrl,
COUNT(pr.id) AS pullRequestCount
FROM
u.login,
u.avatarUrl,
COUNT(pr.id) AS pullRequestCount
FROM
users u
JOIN
JOIN
pullRequests pr ON u.id = pr.authorId
GROUP BY
WHERE
u.isEmployee = FALSE
AND u.login NOT IN ('dependabot', 'cyborch', 'emilienchvt', 'Samox')
GROUP BY
u.id
ORDER BY
ORDER BY
pullRequestCount DESC;
`,
)
@ -32,9 +38,15 @@ const Contributors = async () => {
db.close();
return (
<div>
<AvatarGrid users={contributors} />
</div>
<>
<Background />
<ContentContainer>
<Header />
<div>
<AvatarGrid users={contributors} />
</div>
</ContentContainer>
</>
);
};