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:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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="/" />
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user