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}

+
+
+
+
+
+

Ranking

+

{rank}%

+
+
+
+
+
+

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, + }; +};