Added OG Image (#5251)

- Added dynamic OG Image to share and download in contributors page

<img width="1176" alt="Screenshot 2024-05-02 at 16 24 00"
src="https://github.com/twentyhq/twenty/assets/102751374/0579454b-ccc7-46ba-9875-52458f06ee82">

- Added dynamic metadata 

- Added design to contributor page

- Added a NEXT_PUBLIC_HOST_URL in the .env file

Co-authored-by: Ady Beraud <a.beraud96@gmail.com>
This commit is contained in:
Ady Beraud
2024-05-03 17:38:41 +03:00
committed by GitHub
parent a5a9e0e238
commit 20670695d6
6 changed files with 507 additions and 106 deletions

View File

@ -1,3 +1,4 @@
GITHUB_TOKEN=your_github_token
DATABASE_PG_URL=postgres://website:website@localhost:5432/website # only if using postgres
NEXT_PUBLIC_HOST_URL=http://localhost:3000

View File

@ -0,0 +1,94 @@
'use client';
import styled from '@emotion/styled';
import { IconDownload } from '@tabler/icons-react';
import { CardContainer } from '@/app/_components/contributors/CardContainer';
import { GithubIcon, XIcon } from '@/app/_components/ui/icons/SvgIcons';
import { Theme } from '@/app/_components/ui/theme/theme';
const Container = styled(CardContainer)`
flex-direction: row;
justify-content: center;
align-items: baseline;
gap: 32px;
padding: 40px 0px;
@media (min-width: 800px) and (max-width: 855px) {
padding: 40px 24px;
gap: 24px;
}
@media (max-width: 800px) {
flex-direction: column;
align-items: center;
}
`;
const StyledButton = styled.a`
display: flex;
flex-direction: row;
font-size: ${Theme.font.size.lg};
font-weight: ${Theme.font.weight.medium};
padding: 14px 24px;
color: ${Theme.text.color.primary};
font-family: var(--font-gabarito);
background-color: #fafafa;
border: 2px solid ${Theme.color.gray60};
border-radius: 12px;
gap: 12px;
cursor: pointer;
text-decoration: none;
box-shadow:
-6px 6px 0px 1px #fafafa,
-6px 6px 0px 3px ${Theme.color.gray60};
&:hover {
box-shadow: -6px 6px 0px 1px ${Theme.color.gray60};
}
`;
interface ProfileProps {
userUrl: string;
username: string;
}
export const ProfileSharing = ({ userUrl, username }: ProfileProps) => {
const contributorUrl = `${process.env.NEXT_PUBLIC_HOST_URL}/contributors/${username}`;
const handleDownload = async () => {
const imageSrc = `${process.env.NEXT_PUBLIC_HOST_URL}/api/contributors/og-image/${username}`;
try {
const response = await fetch(imageSrc);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `twenty-${username}.png`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading the image:', error);
}
};
return (
<Container>
<StyledButton href={userUrl} target="blank">
<GithubIcon color="black" size="24px" />
Visit Profile
</StyledButton>
<StyledButton onClick={handleDownload}>
<IconDownload /> Download Image
</StyledButton>
<StyledButton
href={`http://www.twitter.com/share?url=${contributorUrl}`}
target="blank"
>
<XIcon color="black" size="24px" /> Share on X
</StyledButton>
</Container>
);
};

View File

@ -0,0 +1,131 @@
import { format } from 'date-fns';
import { ImageResponse } from 'next/og';
import {
bottomBackgroundImage,
container,
contributorInfo,
contributorInfoBox,
contributorInfoContainer,
contributorInfoStats,
contributorInfoTitle,
infoSeparator,
profileContainer,
profileContributionHeader,
profileInfoContainer,
profileUsernameHeader,
styledContributorAvatar,
topBackgroundImage,
} from '@/app/api/contributors/og-image/[slug]/style';
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
const GABARITO_FONT_CDN_URL =
'https://fonts.cdnfonts.com/s/105143/Gabarito-Medium-BF651cdf1f3f18e.woff';
const getGabarito = async () => {
const fontGabarito = await fetch(GABARITO_FONT_CDN_URL).then((res) =>
res.arrayBuffer(),
);
return fontGabarito;
};
export async function GET(request: Request) {
try {
const url = request.url;
const username = url.split('/')?.pop() || '';
const contributorActivity = await getContributorActivity(username);
if (contributorActivity) {
const {
firstContributionAt,
mergedPRsCount,
rank,
activeDays,
contributorAvatar,
} = contributorActivity;
return await new ImageResponse(
(
<div style={container}>
<div style={topBackgroundImage}></div>
<div style={bottomBackgroundImage}></div>
<div style={profileContainer}>
<img src={contributorAvatar} style={styledContributorAvatar} />
<div style={profileInfoContainer}>
<h1 style={profileUsernameHeader}>@{username} x Twenty</h1>
<h2 style={profileContributionHeader}>
Since {format(new Date(firstContributionAt), 'MMMM yyyy')}
</h2>
</div>
<svg
width="96"
height="96"
viewBox="0 0 136 136"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_2343_96406)">
<path
d="M136 2.28882e-05H0L0.000144482 136H136V2.28882e-05ZM27.27 50.6401C27.27 43.2101 33.3 37.1801 40.73 37.1801H66.64C67.02 37.1801 67.37 37.4101 67.53 37.7601C67.69 38.1101 67.62 38.5201 67.36 38.8101L61.68 44.9801C60.69 46.0501 59.3 46.6701 57.84 46.6701H40.8C38.57 46.6701 36.76 48.4801 36.76 50.7101V60.8901C36.76 62.2001 35.7 63.2601 34.39 63.2601H29.65C28.34 63.2601 27.28 62.2001 27.28 60.8901V50.6401H27.27ZM107.88 85.3601C107.88 92.7901 101.85 98.82 94.42 98.82H83.41C75.98 98.82 69.95 92.7901 69.95 85.3601V66.0901C69.95 64.7801 70.44 63.5201 71.33 62.5501L77.75 55.5801C78.02 55.2901 78.44 55.1901 78.82 55.3301C79.19 55.4801 79.44 55.83 79.44 56.23V85.3001C79.44 87.5301 81.25 89.3401 83.48 89.3401H94.36C96.59 89.3401 98.4 87.5301 98.4 85.3001V50.7101C98.4 48.4801 96.59 46.6701 94.36 46.6701H81.71C80.26 46.6701 78.88 47.2801 77.89 48.3401L40.16 89.3401H62.83C64.14 89.3401 65.2 90.4001 65.2 91.7101V96.4501C65.2 97.7601 64.14 98.82 62.83 98.82H32.28C29.51 98.82 27.26 96.5701 27.26 93.8001V91.29C27.26 90.03 27.73 88.8201 28.59 87.8901L70.89 41.9401C73.69 38.9001 77.62 37.1801 81.75 37.1801H94.41C101.84 37.1801 107.87 43.2101 107.87 50.6401V85.3601H107.88Z"
fill="black"
/>
<path
d="M27.27 50.6401C27.27 43.2101 33.3 37.1801 40.73 37.1801H66.64C67.02 37.1801 67.37 37.4101 67.53 37.7601C67.69 38.1101 67.62 38.5201 67.36 38.8101L61.68 44.9801C60.69 46.0501 59.3 46.6701 57.84 46.6701H40.8C38.57 46.6701 36.76 48.4801 36.76 50.7101V60.8901C36.76 62.2001 35.7 63.2601 34.39 63.2601H29.65C28.34 63.2601 27.28 62.2001 27.28 60.8901V50.6401H27.27Z"
fill="white"
/>
<path
d="M107.88 85.3601C107.88 92.7901 101.85 98.82 94.42 98.82H83.41C75.98 98.82 69.95 92.7901 69.95 85.3601V66.0901C69.95 64.7801 70.44 63.5201 71.33 62.5501L77.75 55.5801C78.02 55.2901 78.44 55.1901 78.82 55.3301C79.19 55.4801 79.44 55.83 79.44 56.23V85.3001C79.44 87.5301 81.25 89.3401 83.48 89.3401H94.36C96.59 89.3401 98.4 87.5301 98.4 85.3001V50.7101C98.4 48.4801 96.59 46.6701 94.36 46.6701H81.71C80.26 46.6701 78.88 47.2801 77.89 48.3401L40.16 89.3401H62.83C64.14 89.3401 65.2 90.4001 65.2 91.7101V96.4501C65.2 97.7601 64.14 98.82 62.83 98.82H32.28C29.51 98.82 27.26 96.5701 27.26 93.8001V91.29C27.26 90.03 27.73 88.8201 28.59 87.8901L70.89 41.9401C73.69 38.9001 77.62 37.1801 81.75 37.1801H94.41C101.84 37.1801 107.87 43.2101 107.87 50.6401V85.3601H107.88Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_2343_96406">
<rect width="136" height="136" rx="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
<div style={contributorInfoContainer}>
<div style={contributorInfoBox}>
<div style={contributorInfo}>
<h3 style={contributorInfoTitle}>Merged PR</h3>
<p style={contributorInfoStats}>{mergedPRsCount}</p>
</div>
<div style={infoSeparator} />
</div>
<div style={contributorInfoBox}>
<div style={contributorInfo}>
<h3 style={contributorInfoTitle}>Ranking</h3>
<p style={contributorInfoStats}>{rank}%</p>
</div>
<div style={infoSeparator} />
</div>
<div style={contributorInfoBox}>
<div style={contributorInfo}>
<h3 style={contributorInfoTitle}>Active Days</h3>
<h1 style={contributorInfoStats}>{activeDays}</h1>
</div>
</div>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Gabarito',
data: await getGabarito(),
style: 'normal',
},
],
},
);
}
} catch (error) {
return new Response(`error: ${error}`, {
status: 500,
});
}
}

View File

@ -0,0 +1,130 @@
import { CSSProperties } from 'react';
const BACKGROUND_IMAGE_URL =
'https://framerusercontent.com/images/nqEmdwe7yDXNsOZovuxG5zvj2E.png';
export const container: CSSProperties = {
display: 'flex',
flexDirection: 'column',
width: '1200px',
height: '630px',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'white',
fontFamily: 'Gabarito',
};
export const topBackgroundImage: CSSProperties = {
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
position: 'absolute',
zIndex: '-1',
width: '1300px',
height: '250px',
transform: 'rotate(-11deg)',
opacity: '0.2',
top: '-100',
left: '-25',
};
export const bottomBackgroundImage: CSSProperties = {
backgroundImage: `url(${BACKGROUND_IMAGE_URL})`,
position: 'absolute',
zIndex: '-1',
width: '1300px',
height: '250px',
transform: 'rotate(-11deg)',
opacity: '0.2',
bottom: '-120',
right: '-40',
};
export const profileContainer: CSSProperties = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
width: '780px',
margin: '0px 0px 40px',
};
export const styledContributorAvatar = {
display: 'flex',
width: '96px',
height: '96px',
margin: '0px',
border: '3px solid #141414',
borderRadius: '16px',
};
export const profileInfoContainer: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'center',
justifyContent: 'center',
};
export const profileUsernameHeader: CSSProperties = {
margin: '0px',
fontSize: '28px',
fontWeight: '700',
color: '#141414',
fontFamily: 'Gabarito',
};
export const profileContributionHeader: CSSProperties = {
margin: '0px',
color: '#818181',
fontSize: '20px',
fontWeight: '400',
};
export const contributorInfoContainer: CSSProperties = {
border: '3px solid #141414',
borderRadius: '12px',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-around',
width: '780px',
height: '149px',
backgroundColor: '#F1F1F1',
};
export const contributorInfoBox: CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
};
export const contributorInfo: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: '32px',
gap: '16px',
};
export const contributorInfoTitle = {
color: '#B3B3B3',
margin: '0px',
fontWeight: '500',
fontSize: '24px',
};
export const contributorInfoStats = {
color: '#474747',
margin: '0px',
fontWeight: '700',
fontSize: '40px',
};
export const infoSeparator: CSSProperties = {
position: 'absolute',
right: 0,
display: 'flex',
width: '2px',
height: '85px',
backgroundColor: '#141414',
};

View File

@ -7,11 +7,11 @@ import { Breadcrumb } from '@/app/_components/contributors/Breadcrumb';
import { ContentContainer } from '@/app/_components/contributors/ContentContainer';
import { ProfileCard } from '@/app/_components/contributors/ProfileCard';
import { ProfileInfo } from '@/app/_components/contributors/ProfileInfo';
import { ProfileSharing } from '@/app/_components/contributors/ProfileSharing';
import { PullRequests } from '@/app/_components/contributors/PullRequests';
import { ThankYou } from '@/app/_components/contributors/ThankYou';
import { Background } from '@/app/_components/oss-friends/Background';
import { findAll } from '@/database/database';
import { pullRequestModel, userModel } from '@/database/model';
import { getContributorActivity } from '@/app/contributors/utils/get-contributor-activity';
export function generateMetadata({
params,
@ -24,114 +24,63 @@ export function generateMetadata({
'Explore the impactful contributions of ' +
params.slug +
' on the Twenty Github Repo. Discover their merged pull requests, ongoing work, and top ranking. Join and contribute to the #1 Open-Source CRM thriving community!',
openGraph: {
images: [`/api/contributors/og-image/${params.slug}`],
},
};
}
export default async function ({ params }: { params: { slug: string } }) {
const contributors = await findAll(userModel);
try {
const contributorActivity = await getContributorActivity(params.slug);
if (contributorActivity) {
const {
firstContributionAt,
mergedPRsCount,
rank,
activeDays,
pullRequestActivityArray,
contributorPullRequests,
contributor,
} = contributorActivity;
const contributor = contributors.find(
(contributor) => contributor.id === params.slug,
);
if (!contributor) {
return;
return (
<Background>
<ContentContainer>
<Breadcrumb active={contributor.id} />
<ProfileCard
username={contributor.id}
avatarUrl={contributor.avatarUrl}
firstContributionAt={firstContributionAt}
/>
<ProfileInfo
mergedPRsCount={mergedPRsCount}
rank={rank}
activeDays={activeDays}
/>
<ProfileSharing
userUrl={contributor.url}
username={contributor.id}
/>
<ActivityLog data={pullRequestActivityArray} />
<PullRequests
list={
contributorPullRequests.slice(0, 9) as {
id: string;
title: string;
url: string;
createdAt: string;
mergedAt: string | null;
authorId: string;
}[]
}
/>
<ThankYou username={contributor.id} />
</ContentContainer>
</Background>
);
}
} catch (error) {
console.error('error: ', error);
}
const pullRequests = await findAll(pullRequestModel);
const mergedPullRequests = pullRequests
.filter((pr) => pr.mergedAt !== null)
.filter(
(pr) =>
![
'dependabot',
'cyborch',
'emilienchvt',
'Samox',
'charlesBochet',
'gitstart-app',
'thaisguigon',
'lucasbordeau',
'magrinj',
'Weiko',
'gitstart-twenty',
'bosiraphael',
'martmull',
'FelixMalfait',
'thomtrp',
'Bonapara',
'nimraahmed',
].includes(pr.authorId),
);
const contributorPullRequests = pullRequests.filter(
(pr) => pr.authorId === contributor.id,
);
const mergedContributorPullRequests = contributorPullRequests.filter(
(pr) => pr.mergedAt !== null,
);
const mergedContributorPullRequestsByContributor = mergedPullRequests.reduce(
(acc, pr) => {
acc[pr.authorId] = (acc[pr.authorId] || 0) + 1;
return acc;
},
{},
);
const mergedContributorPullRequestsByContributorArray = Object.entries(
mergedContributorPullRequestsByContributor,
)
.map(([authorId, value]) => ({ authorId, value }))
.sort((a, b) => b.value - a.value);
const contributorRank =
((mergedContributorPullRequestsByContributorArray.findIndex(
(contributor) => contributor.authorId === params.slug,
) +
1) /
contributors.length) *
100;
const pullRequestActivity = contributorPullRequests.reduce((acc, pr) => {
const date = new Date(pr.createdAt).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, []);
const pullRequestActivityArray = Object.entries(pullRequestActivity)
.map(([day, value]) => ({ day, value }))
.sort((a, b) => new Date(a.day).getTime() - new Date(b.day).getTime());
return (
<Background>
<ContentContainer>
<Breadcrumb active={contributor.id} />
<ProfileCard
username={contributor.id}
avatarUrl={contributor.avatarUrl}
firstContributionAt={pullRequestActivityArray[0]?.day}
/>
<ProfileInfo
mergedPRsCount={mergedContributorPullRequests.length}
rank={Math.ceil(Number(contributorRank)).toFixed(0)}
activeDays={pullRequestActivityArray.length}
/>
<ActivityLog data={pullRequestActivityArray} />
<PullRequests
list={
contributorPullRequests.slice(0, 9) as {
id: string;
title: string;
url: string;
createdAt: string;
mergedAt: string | null;
authorId: string;
}[]
}
/>
<ThankYou username={contributor.id} />
</ContentContainer>
</Background>
);
}

View File

@ -0,0 +1,96 @@
import { findAll } from '@/database/database';
import { pullRequestModel, userModel } from '@/database/model';
export const getContributorActivity = async (username: string) => {
const contributors = await findAll(userModel);
const contributor = contributors.find((contributor) => {
return contributor.id === username;
});
if (!contributor) {
return;
}
const pullRequests = await findAll(pullRequestModel);
const mergedPullRequests = pullRequests
.filter((pr) => pr.mergedAt !== null)
.filter(
(pr) =>
![
'dependabot',
'cyborch',
'emilienchvt',
'Samox',
'charlesBochet',
'gitstart-app',
'thaisguigon',
'lucasbordeau',
'magrinj',
'Weiko',
'gitstart-twenty',
'bosiraphael',
'martmull',
'FelixMalfait',
'thomtrp',
'Bonapara',
'nimraahmed',
].includes(pr.authorId),
);
const contributorPullRequests = pullRequests.filter(
(pr) => pr.authorId === contributor.id,
);
const mergedContributorPullRequests = contributorPullRequests.filter(
(pr) => pr.mergedAt !== null,
);
const mergedContributorPullRequestsByContributor = mergedPullRequests.reduce(
(acc, pr) => {
acc[pr.authorId] = (acc[pr.authorId] || 0) + 1;
return acc;
},
{},
);
const mergedContributorPullRequestsByContributorArray = Object.entries(
mergedContributorPullRequestsByContributor,
)
.map(([authorId, value]) => ({ authorId, value }))
.sort((a, b) => b.value - a.value);
const contributorRank =
((mergedContributorPullRequestsByContributorArray.findIndex(
(contributor) => contributor.authorId === username,
) +
1) /
contributors.length) *
100;
const pullRequestActivity = contributorPullRequests.reduce((acc, pr) => {
const date = new Date(pr.createdAt).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, []);
const pullRequestActivityArray = Object.entries(pullRequestActivity)
.map(([day, value]) => ({ day, value }))
.sort((a, b) => new Date(a.day).getTime() - new Date(b.day).getTime());
const firstContributionAt = pullRequestActivityArray[0]?.day;
const mergedPRsCount = mergedContributorPullRequests.length;
const rank = Math.ceil(Number(contributorRank)).toFixed(0);
const activeDays = pullRequestActivityArray.length;
const contributorAvatar = contributor.avatarUrl;
return {
firstContributionAt,
mergedPRsCount,
rank,
activeDays,
pullRequestActivityArray,
contributorPullRequests,
contributorAvatar,
contributor,
};
};