diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example
index e6b2146f4..6419d766c 100644
--- a/packages/twenty-website/.env.example
+++ b/packages/twenty-website/.env.example
@@ -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
diff --git a/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx b/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx
new file mode 100644
index 000000000..ddfc76c3a
--- /dev/null
+++ b/packages/twenty-website/src/app/_components/contributors/ProfileSharing.tsx
@@ -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 (
+
+
+
+ Visit Profile
+
+
+ Download Image
+
+
+ Share on X
+
+
+ );
+};
diff --git a/packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx b/packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx
new file mode 100644
index 000000000..17e45b011
--- /dev/null
+++ b/packages/twenty-website/src/app/api/contributors/og-image/[slug]/route.tsx
@@ -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(
+ (
+
+
+
+
+

+
+
@{username} x Twenty
+
+ Since {format(new Date(firstContributionAt), 'MMMM yyyy')}
+
+
+
+
+
+
+
+
Merged PR
+
{mergedPRsCount}
+
+
+
+
+
+
+
Active Days
+ {activeDays}
+
+
+
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ fonts: [
+ {
+ name: 'Gabarito',
+ data: await getGabarito(),
+ style: 'normal',
+ },
+ ],
+ },
+ );
+ }
+ } catch (error) {
+ return new Response(`error: ${error}`, {
+ status: 500,
+ });
+ }
+}
diff --git a/packages/twenty-website/src/app/api/contributors/og-image/[slug]/style.ts b/packages/twenty-website/src/app/api/contributors/og-image/[slug]/style.ts
new file mode 100644
index 000000000..783751a2a
--- /dev/null
+++ b/packages/twenty-website/src/app/api/contributors/og-image/[slug]/style.ts
@@ -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',
+};
diff --git a/packages/twenty-website/src/app/contributors/[slug]/page.tsx b/packages/twenty-website/src/app/contributors/[slug]/page.tsx
index c944001cc..3d64342dd 100644
--- a/packages/twenty-website/src/app/contributors/[slug]/page.tsx
+++ b/packages/twenty-website/src/app/contributors/[slug]/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ } 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 (
-
-
-
-
-
-
-
-
-
-
- );
}
diff --git a/packages/twenty-website/src/app/contributors/utils/get-contributor-activity.ts b/packages/twenty-website/src/app/contributors/utils/get-contributor-activity.ts
new file mode 100644
index 000000000..a81fac230
--- /dev/null
+++ b/packages/twenty-website/src/app/contributors/utils/get-contributor-activity.ts
@@ -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,
+ };
+};