Marketing improvements 3 (#3175)

* Improve marketing website

* User guide with icons

* Add TOC

* Linter

* Basic GraphQL playground

* Very basic contributors page

* Failed attempt to integrate REST playground

* Yarn

* Begin contributors DB

* Improve contributors page
This commit is contained in:
Félix Malfait 2023-12-29 11:17:32 +01:00 committed by GitHub
parent fa8a04743c
commit c422045ea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 7589 additions and 687 deletions

View File

@ -1 +1,2 @@
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000
GITHUB_TOKEN=your_github_token

View File

@ -1,3 +1,7 @@
{ {
"extends": "next/core-web-vitals" "extends": [
} "next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
]
}

View File

@ -34,3 +34,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# DB
*.sqlite

View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "auto"
}

View File

@ -1,4 +1,14 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
],
},
};
module.exports = nextConfig;

View File

@ -11,20 +11,31 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@jsdevtools/rehype-toc": "^3.0.2",
"@stoplight/elements": "^7.16.2",
"@tabler/icons-react": "^2.44.0", "@tabler/icons-react": "^2.44.0",
"better-sqlite3": "^9.2.2",
"graphiql": "^3.0.10",
"next": "14.0.4", "next": "14.0.4",
"next-mdx-remote": "^4.4.1", "next-mdx-remote": "^4.4.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0", "remark-behead": "^3.1.0",
"remark-gfm": "^3.0.1" "remark-gfm": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.0.4", "eslint-config-next": "14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"prettier": "^3.1.1",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -0,0 +1,62 @@
'use client'
import styled from '@emotion/styled';
export interface User {
login: string;
avatarUrl: string;
}
const AvatarGridContainer = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-gap: 10px;
`;
const AvatarItem = styled.div`
position: relative;
width: 100%;
img {
width: 100%;
height: auto;
display: block;
}
.username {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background-color: rgba(0, 0, 0, 0.7);
color: white;
text-align: center;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease, visibility 0.3s;
}
&:hover .username {
visibility: visible;
opacity: 1;
}
`;
import React from 'react';
const AvatarGrid = ({ users }: { users: User[] }) => {
return (
<AvatarGridContainer>
{users.map(user => (
<AvatarItem key={user.login}>
<img src={user.avatarUrl} alt={user.login} />
<span className="username">{user.login}</span>
</AvatarItem>
))}
</AvatarGridContainer>
);
};
export default AvatarGrid;

View File

@ -1,18 +1,22 @@
'use client' 'use client';
import styled from '@emotion/styled' import styled from '@emotion/styled';
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 600px; width: 100%;
@media(max-width: 809px) { padding: 0px 96px 0px 96px;
width: 100%; @media (max-width: 809px) {
}`; width: 100%;
padding: 0px 12px 0px 12px;
}
`;
export const ContentContainer = ({
export const ContentContainer = ({children}: {children?: React.ReactNode}) => { children,
return ( }: {
<Container>{children}</Container> children?: React.ReactNode;
) }) => {
} return <Container>{children}</Container>;
};

View File

@ -0,0 +1,16 @@
export const ExternalArrow = () => {
return (
<div style={{ width: '14px', height: '14px', fill: 'rgb(179, 179, 179)' }}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
focusable="false"
color="rgb(179, 179, 179)"
>
<g color="rgb(179, 179, 179)">
<path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"></path>
</g>
</svg>
</div>
);
};

View File

@ -1,111 +1,154 @@
'use client' 'use client';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { DiscordIcon, GithubIcon2, LinkedInIcon, XIcon } from "./Icons"; import { DiscordIcon, GithubIcon2, LinkedInIcon, XIcon } from './Icons';
import { usePathname } from 'next/navigation';
const FooterContainer = styled.div` const FooterContainer = styled.div`
padding: 64px 96px 64px 96px; padding: 64px 96px 64px 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: rgb(129, 129, 129); color: rgb(129, 129, 129);
gap: 32px; gap: 32px;
@media(max-width: 809px) { @media (max-width: 809px) {
display: none; display: none;
} }
`; `;
const LeftSideFooter = styled.div` const LeftSideFooter = styled.div`
width: 36Opx; width: 36Opx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;`; gap: 16px;
`;
const RightSideFooter = styled.div` const RightSideFooter = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 48px; gap: 48px;
height: 146px;`; height: 146px;
`;
const RightSideFooterColumn = styled.div` const RightSideFooterColumn = styled.div`
width: 160px; width: 160px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
`; `;
const RightSideFooterLink = styled.a` const RightSideFooterLink = styled.a`
color: rgb(129, 129, 129); color: rgb(129, 129, 129);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
color: #000; color: #000;
}`; }
`;
const RightSideFooterColumnTitle = styled.div` const RightSideFooterColumnTitle = styled.div`
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: #000; color: #000;
`; `;
export const FooterDesktop = () => { export const FooterDesktop = () => {
return <FooterContainer> const path = usePathname();
<div style={{ width: '100%', display: 'flex', flexDirection: 'row', justifyContent:'space-between'}}> const isTwentyDev = path.includes('developers');
<LeftSideFooter>
<Logo /> if (isTwentyDev) return;
<div>
The #1 Open Source CRM return (
</div> <FooterContainer>
</LeftSideFooter> <div
<RightSideFooter> style={{
<RightSideFooterColumn> width: '100%',
<RightSideFooterColumnTitle>Company</RightSideFooterColumnTitle> display: 'flex',
<RightSideFooterLink href='/pricing'>Pricing</RightSideFooterLink> flexDirection: 'row',
<RightSideFooterLink href='/story'>Story</RightSideFooterLink> justifyContent: 'space-between',
</RightSideFooterColumn> }}
<RightSideFooterColumn> >
<RightSideFooterColumnTitle>Resources</RightSideFooterColumnTitle> <LeftSideFooter>
<RightSideFooterLink href='https://docs.twenty.com'>Documentation</RightSideFooterLink> <Logo />
<RightSideFooterLink href='/releases'>Changelog</RightSideFooterLink> <div>The #1 Open Source CRM</div>
</RightSideFooterColumn> </LeftSideFooter>
<RightSideFooterColumn> <RightSideFooter>
<RightSideFooterColumnTitle>Other</RightSideFooterColumnTitle> <RightSideFooterColumn>
<RightSideFooterLink href='/oss-friends'>OSS Friends</RightSideFooterLink> <RightSideFooterColumnTitle>Company</RightSideFooterColumnTitle>
<RightSideFooterLink href='/legal/terms'>Terms of Service</RightSideFooterLink> <RightSideFooterLink href="/pricing">Pricing</RightSideFooterLink>
<RightSideFooterLink href='/legal/privacy'>Privacy Policy</RightSideFooterLink> <RightSideFooterLink href="/story">Story</RightSideFooterLink>
</RightSideFooterColumn> </RightSideFooterColumn>
</RightSideFooter> <RightSideFooterColumn>
<RightSideFooterColumnTitle>Resources</RightSideFooterColumnTitle>
<RightSideFooterLink href="https://docs.twenty.com">
Documentation
</RightSideFooterLink>
<RightSideFooterLink href="/releases">
Changelog
</RightSideFooterLink>
</RightSideFooterColumn>
<RightSideFooterColumn>
<RightSideFooterColumnTitle>Other</RightSideFooterColumnTitle>
<RightSideFooterLink href="/oss-friends">
OSS Friends
</RightSideFooterLink>
<RightSideFooterLink href="/legal/terms">
Terms of Service
</RightSideFooterLink>
<RightSideFooterLink href="/legal/privacy">
Privacy Policy
</RightSideFooterLink>
</RightSideFooterColumn>
</RightSideFooter>
</div>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
borderTop: '1px solid rgb(179, 179, 179)',
paddingTop: '32px',
}}
>
<div>
<span style={{ fontFamily: 'Inter, sans-serif' }}>©</span>
2023 Twenty PBC
</div> </div>
<div style={{ <div
width: '100%', style={{
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
justifyContent:'space-between', justifyContent: 'space-between',
borderTop: '1px solid rgb(179, 179, 179)', gap: '10px',
paddingTop: '32px' }}
}}> >
<div> <a href="https://x.com/twentycrm" target="_blank" rel="noreferrer">
<span style={{fontFamily: "Inter, sans-serif"}}>©</span> <XIcon size="M" />
2023 Twenty PBC </a>
</div> <a
<div style={{ display: 'flex', flexDirection: 'row', justifyContent:'space-between', gap:'10px'}}> href="https://github.com/twentyhq/twenty"
<a href="https://x.com/twentycrm" target="_blank"> target="_blank"
<XIcon size='M'/> rel="noreferrer"
</a> >
<a href="https://github.com/twentyhq/twenty" target="_blank"> <GithubIcon2 size="M" />
<GithubIcon2 size='M'/> </a>
</a> <a
<a href="https://www.linkedin.com/company/twenty" target="_blank"> href="https://www.linkedin.com/company/twenty"
<LinkedInIcon size='M'/> target="_blank"
</a> rel="noreferrer"
<a href="https://discord.gg/UfGNZJfAG6" target="_blank"> >
<DiscordIcon size='M' /> <LinkedInIcon size="M" />
</a> </a>
</div> <a
href="https://discord.gg/UfGNZJfAG6"
target="_blank"
rel="noreferrer"
>
<DiscordIcon size="M" />
</a>
</div> </div>
</div>
</FooterContainer> </FooterContainer>
; );
} };

View File

@ -1,137 +1,188 @@
'use client' 'use client';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { IBM_Plex_Mono } from 'next/font/google'; import { IBM_Plex_Mono } from 'next/font/google';
import { GithubIcon } from './Icons'; import { DiscordIcon, GithubIcon, GithubIcon2, XIcon } from './Icons';
import { usePathname } from 'next/navigation';
import { ExternalArrow } from '@/app/components/ExternalArrow';
const IBMPlexMono = IBM_Plex_Mono({ const IBMPlexMono = IBM_Plex_Mono({
weight: '500', weight: '500',
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
}); });
const Nav = styled.nav` const Nav = styled.nav`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
overflow: visible; overflow: visible;
padding: 12px 16px 12px 16px; padding: 12px 16px 12px 16px;
position: relative; position: relative;
transform-origin: 50% 50% 0px; transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08); border-bottom: 1px solid rgba(20, 20, 20, 0.08);
@media(max-width: 809px) { @media (max-width: 809px) {
display: none; display: none;
} }
`; `;
const LinkList = styled.div` const LinkList = styled.div`
display:flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 2px; gap: 2px;
`; `;
const ListItem = styled.a` const ListItem = styled.a`
color: rgb(71, 71, 71); color: rgb(71, 71, 71);
text-decoration: none; text-decoration: none;
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
border-radius: 8px; border-radius: 8px;
height: 40px; height: 40px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
&:hover { &:hover {
background-color: #F1F1F1; background-color: #f1f1f1;
} }
`; `;
const LogoContainer = styled.div` const LogoContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width:202px;`; width: 202px;
`;
const LogoAddon = styled.div` const LogoAddon = styled.div`
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
line-height: 150%; line-height: 150%;
`; `;
const StyledButton = styled.div` const StyledButton = styled.div`
display: flex; display: flex;
height: 40px; height: 40px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
align-items: center; align-items: center;
background-color: #000; background-color: #000;
color: #fff; color: #fff;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;
border: none; border: none;
outline: inherit; outline: inherit;
cursor: pointer; cursor: pointer;
`; `;
const CallToActionContainer = styled.div` const CallToActionContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
a { a {
text-decoration: none; text-decoration: none;
} }
`; `;
const LinkNextToCTA = styled.a` const LinkNextToCTA = styled.a`
display: flex; display: flex;
align-items: center; align-items: center;
color: rgb(71, 71, 71); color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px; padding: 0px 16px 0px 16px;
span { span {
text-decoration: underline; text-decoration: underline;
}`; }
`;
const CallToAction = () => { const CallToAction = () => {
return <CallToActionContainer> const path = usePathname();
<LinkNextToCTA href="https://github.com/twentyhq/twenty">Sign in</LinkNextToCTA> const isTwentyDev = path.includes('developers');
<a href="#">
<StyledButton> return (
Get Started <CallToActionContainer>
</StyledButton> {isTwentyDev ? (
</a> <>
</CallToActionContainer>; <div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
gap: '10px',
}}
>
<a href="https://x.com/twentycrm" target="_blank" rel="noreferrer">
<XIcon size="M" />
</a>
<a
href="https://github.com/twentyhq/twenty"
target="_blank"
rel="noreferrer"
>
<GithubIcon2 size="M" />
</a>
<a
href="https://discord.gg/UfGNZJfAG6"
target="_blank"
rel="noreferrer"
>
<DiscordIcon size="M" />
</a>
</div>
</>
) : (
<>
<LinkNextToCTA href="https://github.com/twentyhq/twenty">
Sign in
</LinkNextToCTA>
<a href="https://twenty.com/stripe-redirection">
<StyledButton>Get Started</StyledButton>
</a>
</>
)}
</CallToActionContainer>
);
}; };
const ExternalArrow = () => {
return <div style={{width:'14px', height:'14px', fill: 'rgb(179, 179, 179)'}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false" color="rgb(179, 179, 179)"><g color="rgb(179, 179, 179)" ><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"></path></g></svg>
</div>
}
export const HeaderDesktop = () => { export const HeaderDesktop = () => {
const path = usePathname();
const isTwentyDev = path.includes('developers');
const isTwentyDev = false; return (
<Nav>
return <Nav> <LogoContainer>
<LogoContainer> <Logo />
<Logo /> {isTwentyDev && (
{isTwentyDev && <LogoAddon className={IBMPlexMono.className}>for Developers</LogoAddon>} <LogoAddon className={IBMPlexMono.className}>
</LogoContainer> for Developers
</LogoAddon>
)}
</LogoContainer>
{isTwentyDev ? (
<LinkList> <LinkList>
<ListItem href="/pricing">Pricing</ListItem> <ListItem href="/developers/docs">Docs</ListItem>
<ListItem href="/story">Story</ListItem> <ListItem href="/developers/contributors">Contributors</ListItem>
<ListItem href="https://docs.twenty.com">Docs <ExternalArrow /></ListItem> <ListItem href="/">
<ListItem href="https://github.com/twentyhq/twenty"><GithubIcon color='rgb(71,71,71)' /> 5.7k <ExternalArrow /></ListItem> Cloud <ExternalArrow />
</ListItem>
</LinkList> </LinkList>
<CallToAction /> ) : (
</Nav>; <LinkList>
<ListItem href="/pricing">Pricing</ListItem>
<ListItem href="/story">Story</ListItem>
<ListItem href="https://docs.twenty.com">
Docs <ExternalArrow />
</ListItem>
<ListItem href="https://github.com/twentyhq/twenty">
<GithubIcon color="rgb(71,71,71)" /> 5.7k <ExternalArrow />
</ListItem>
</LinkList>
)}
<CallToAction />
</Nav>
);
}; };

View File

@ -1,176 +1,216 @@
'use client' 'use client';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { IBM_Plex_Mono } from 'next/font/google'; import { IBM_Plex_Mono } from 'next/font/google';
import { GithubIcon } from './Icons'; import { GithubIcon } from './Icons';
import { useState } from 'react';
import { ExternalArrow } from '@/app/components/ExternalArrow';
const IBMPlexMono = IBM_Plex_Mono({ const IBMPlexMono = IBM_Plex_Mono({
weight: '500', weight: '500',
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
}); });
const Nav = styled.nav` const Nav = styled.nav`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
overflow: visible; overflow: visible;
padding: 0 12px; padding: 0 12px;
position: relative; position: relative;
transform-origin: 50% 50% 0px; transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08); border-bottom: 1px solid rgba(20, 20, 20, 0.08);
height: 64px; height: 64px;
width: 100%; width: 100%;
@media(min-width: 810px) { @media (min-width: 810px) {
display: none; display: none;
} }
`; `;
const LinkList = styled.div` const LinkList = styled.div`
display:flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const ListItem = styled.a` const ListItem = styled.a`
color: rgb(71, 71, 71); color: rgb(71, 71, 71);
text-decoration: none; text-decoration: none;
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
border-radius: 8px; border-radius: 8px;
height: 40px; height: 40px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
&:hover { &:hover {
background-color: #F1F1F1; background-color: #f1f1f1;
} }
`; `;
const LogoContainer = styled.div` const LogoContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width:202px;`; width: 202px;
`;
const LogoAddon = styled.div` const LogoAddon = styled.div`
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
line-height: 150%; line-height: 150%;
`; `;
const StyledButton = styled.div` const StyledButton = styled.div`
display: flex; display: flex;
height: 40px; height: 40px;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
align-items: center; align-items: center;
background-color: #000; background-color: #000;
color: #fff; color: #fff;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;
border: none; border: none;
outline: inherit; outline: inherit;
cursor: pointer; cursor: pointer;
`; `;
const CallToActionContainer = styled.div` const CallToActionContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
a { a {
text-decoration: none; text-decoration: none;
} }
`; `;
const LinkNextToCTA = styled.a` const LinkNextToCTA = styled.a`
display: flex; display: flex;
align-items: center; align-items: center;
color: rgb(71, 71, 71); color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px; padding: 0px 16px 0px 16px;
span { span {
text-decoration: underline; text-decoration: underline;
}`; }
`;
const CallToAction = () => { const CallToAction = () => {
return <CallToActionContainer> return (
<LinkNextToCTA href="https://github.com/twentyhq/twenty">Sign in</LinkNextToCTA> <CallToActionContainer>
<a href="#"> <LinkNextToCTA href="https://github.com/twentyhq/twenty">
<StyledButton> Sign in
Get Started </LinkNextToCTA>
</StyledButton> <a href="#">
</a> <StyledButton>Get Started</StyledButton>
</CallToActionContainer>; </a>
</CallToActionContainer>
);
}; };
const ExternalArrow = () => {
return <div style={{width:'14px', height:'14px', fill: 'rgb(179, 179, 179)'}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false" color="rgb(179, 179, 179)"><g color="rgb(179, 179, 179)" ><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"></path></g></svg>
</div>
}
const HamburgerContainer = styled.div` const HamburgerContainer = styled.div`
height: 44px;
width: 44px;
cursor: pointer;
position: relative;
input {
height: 44px; height: 44px;
width: 44px; width: 44px;
cursor: pointer; opacity: 0;
position: relative; }
input {
height: 44px; #line1 {
width: 44px; transition: transform 0.5s;
opacity: 0; transition-timing-function: ease-in-out;
}`; }
#line2 {
transition: transform 0.5s;
transition-timing-function: ease-in-out;
}
#menu-input:checked ~ #line1 {
transform: rotate(45deg) translate(7px);
}
#menu-input:checked ~ #line2 {
transform: rotate(-45deg) translate(7px);
}
`;
const HamburgerLine1 = styled.div` const HamburgerLine1 = styled.div`
height: 2px; height: 2px;
left: calc(50.00000000000002% - 20px / 2); left: calc(50.00000000000002% - 20px / 2);
position: absolute; position: absolute;
top: calc(37.50000000000002% - 2px / 2); top: calc(37.50000000000002% - 2px / 2);
width: 20px; width: 20px;
border-radius: 10px; border-radius: 10px;
background-color: rgb(179, 179, 179);`; background-color: rgb(179, 179, 179);
`;
const HamburgerLine2 = styled.div` const HamburgerLine2 = styled.div`
height: 2px; height: 2px;
left: calc(50.00000000000002% - 20px / 2); left: calc(50.00000000000002% - 20px / 2);
position: absolute; position: absolute;
top: calc(62.50000000000002% - 2px / 2); top: calc(62.50000000000002% - 2px / 2);
width: 20px; width: 20px;
border-radius: 10px; border-radius: 10px;
background-color: rgb(179, 179, 179);`; background-color: rgb(179, 179, 179);
`;
const NavOpen = styled.div` const NavOpen = styled.div`
display:none;`; flex-direction: column;
align-items: center;
`;
const MobileMenu = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export const HeaderMobile = () => { export const HeaderMobile = () => {
const isTwentyDev = false;
const isTwentyDev = false; const [menuOpen, setMenuOpen] = useState(false);
return <Nav> const toggleMenu = () => {
setMenuOpen(!menuOpen);
};
return (
<MobileMenu>
<Nav>
<LogoContainer> <LogoContainer>
<Logo /> <Logo />
{isTwentyDev && <LogoAddon className={IBMPlexMono.className}>for Developers</LogoAddon>} {isTwentyDev && (
<LogoAddon className={IBMPlexMono.className}>
for Developers
</LogoAddon>
)}
</LogoContainer> </LogoContainer>
<HamburgerContainer> <HamburgerContainer>
<input type="checkbox" /> <input type="checkbox" id="menu-input" onChange={toggleMenu} />
<HamburgerLine1 /> <HamburgerLine1 id="line1" />
<HamburgerLine2 /> <HamburgerLine2 id="line2" />
</HamburgerContainer> </HamburgerContainer>
</Nav>
<NavOpen> <NavOpen style={{ display: menuOpen ? 'flex' : 'none' }}>
<LinkList> <LinkList>
<ListItem href="/pricing">Pricing</ListItem> <ListItem href="/pricing">Pricing</ListItem>
<ListItem href="/story">Story</ListItem> <ListItem href="/story">Story</ListItem>
<ListItem href="https://docs.twenty.com">Docs <ExternalArrow /></ListItem> <ListItem href="https://docs.twenty.com">
<ListItem href="https://github.com/twentyhq/twenty"><GithubIcon color='rgb(71,71,71)' /> 5.7k <ExternalArrow /></ListItem> Docs <ExternalArrow />
</ListItem>
<ListItem href="https://github.com/twentyhq/twenty">
<GithubIcon color="rgb(71,71,71)" /> 5.7k <ExternalArrow />
</ListItem>
</LinkList> </LinkList>
<CallToAction /> <CallToAction />
</NavOpen> </NavOpen>
</Nav>; </MobileMenu>
);
}; };

View File

@ -1,51 +1,119 @@
const getSize = (size: string) => { const getSize = (size: string) => {
switch(size) { switch (size) {
case 'S': case 'S':
return '14px'; return '14px';
case 'M': case 'M':
return '24px'; return '24px';
case 'L': case 'L':
return '48px'; return '48px';
default: default:
return '14px'; return '14px';
} }
}; };
export const GithubIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => { export const GithubIcon = ({ size = 'S', color = 'rgb(179, 179, 179)' }) => {
let dimension = getSize(size); const dimension = getSize(size);
return <div style={{width: dimension, height: dimension}}> return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><path d="M 6.979 0 C 3.12 0 0 3.143 0 7.031 C 0 10.139 1.999 12.77 4.772 13.701 C 5.119 13.771 5.246 13.55 5.246 13.364 C 5.246 13.201 5.234 12.642 5.234 12.06 C 3.293 12.479 2.889 11.222 2.889 11.222 C 2.577 10.407 2.114 10.197 2.114 10.197 C 1.479 9.767 2.161 9.767 2.161 9.767 C 2.866 9.813 3.235 10.488 3.235 10.488 C 3.859 11.559 4.865 11.257 5.269 11.07 C 5.327 10.616 5.512 10.302 5.708 10.127 C 4.16 9.964 2.531 9.359 2.531 6.658 C 2.531 5.89 2.808 5.262 3.247 4.773 C 3.178 4.598 2.935 3.876 3.316 2.91 C 3.316 2.91 3.906 2.724 5.234 3.632 C 5.803 3.478 6.39 3.4 6.979 3.399 C 7.568 3.399 8.169 3.481 8.724 3.632 C 10.053 2.724 10.642 2.91 10.642 2.91 C 11.023 3.876 10.781 4.598 10.711 4.773 C 11.162 5.262 11.428 5.89 11.428 6.658 C 11.428 9.359 9.799 9.953 8.239 10.127 C 8.493 10.349 8.712 10.768 8.712 11.431 C 8.712 12.374 8.701 13.131 8.701 13.363 C 8.701 13.55 8.828 13.771 9.175 13.701 C 11.948 12.77 13.947 10.139 13.947 7.031 C 13.958 3.143 10.827 0 6.979 0 Z" fill={color}></path></svg> <div style={{ width: dimension, height: dimension }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
<path
d="M 6.979 0 C 3.12 0 0 3.143 0 7.031 C 0 10.139 1.999 12.77 4.772 13.701 C 5.119 13.771 5.246 13.55 5.246 13.364 C 5.246 13.201 5.234 12.642 5.234 12.06 C 3.293 12.479 2.889 11.222 2.889 11.222 C 2.577 10.407 2.114 10.197 2.114 10.197 C 1.479 9.767 2.161 9.767 2.161 9.767 C 2.866 9.813 3.235 10.488 3.235 10.488 C 3.859 11.559 4.865 11.257 5.269 11.07 C 5.327 10.616 5.512 10.302 5.708 10.127 C 4.16 9.964 2.531 9.359 2.531 6.658 C 2.531 5.89 2.808 5.262 3.247 4.773 C 3.178 4.598 2.935 3.876 3.316 2.91 C 3.316 2.91 3.906 2.724 5.234 3.632 C 5.803 3.478 6.39 3.4 6.979 3.399 C 7.568 3.399 8.169 3.481 8.724 3.632 C 10.053 2.724 10.642 2.91 10.642 2.91 C 11.023 3.876 10.781 4.598 10.711 4.773 C 11.162 5.262 11.428 5.89 11.428 6.658 C 11.428 9.359 9.799 9.953 8.239 10.127 C 8.493 10.349 8.712 10.768 8.712 11.431 C 8.712 12.374 8.701 13.131 8.701 13.363 C 8.701 13.55 8.828 13.771 9.175 13.701 C 11.948 12.77 13.947 10.139 13.947 7.031 C 13.958 3.143 10.827 0 6.979 0 Z"
fill={color}
></path>
</svg>
</div> </div>
} );
};
export const LinkedInIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => { export const LinkedInIcon = ({ size = 'S', color = 'rgb(179, 179, 179)' }) => {
let dimension = getSize(size); const dimension = getSize(size);
return <div style={{width: dimension, height: dimension}}> return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false" color={color} ><g color={color}><path d="M216,24H40A16,16,0,0,0,24,40V216a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V40A16,16,0,0,0,216,24Zm0,192H40V40H216V216ZM96,112v64a8,8,0,0,1-16,0V112a8,8,0,0,1,16,0Zm88,28v36a8,8,0,0,1-16,0V140a20,20,0,0,0-40,0v36a8,8,0,0,1-16,0V112a8,8,0,0,1,15.79-1.78A36,36,0,0,1,184,140ZM100,84A12,12,0,1,1,88,72,12,12,0,0,1,100,84Z" fill={color}></path></g></svg> <div style={{ width: dimension, height: dimension }}>
</div>; <svg
} xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
export const DiscordIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => { focusable="false"
let dimension = getSize(size); color={color}
return <div style={{width: dimension, height: dimension}}> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false" color={color} ><g color={color}><path d="M104,140a12,12,0,1,1-12-12A12,12,0,0,1,104,140Zm60-12a12,12,0,1,0,12,12A12,12,0,0,0,164,128Zm74.45,64.9-67,29.71a16.17,16.17,0,0,1-21.71-9.1l-8.11-22q-6.72.45-13.63.46t-13.63-.46l-8.11,22a16.18,16.18,0,0,1-21.71,9.1l-67-29.71a15.93,15.93,0,0,1-9.06-18.51L38,58A16.07,16.07,0,0,1,51,46.14l36.06-5.93a16.22,16.22,0,0,1,18.26,11.88l3.26,12.84Q118.11,64,128,64t19.4.93l3.26-12.84a16.21,16.21,0,0,1,18.26-11.88L205,46.14A16.07,16.07,0,0,1,218,58l29.53,116.38A15.93,15.93,0,0,1,238.45,192.9ZM232,178.28,202.47,62s0,0-.08,0L166.33,56a.17.17,0,0,0-.17,0l-2.83,11.14c5,.94,10,2.06,14.83,3.42A8,8,0,0,1,176,86.31a8.09,8.09,0,0,1-2.16-.3A172.25,172.25,0,0,0,128,80a172.25,172.25,0,0,0-45.84,6,8,8,0,1,1-4.32-15.4c4.82-1.36,9.78-2.48,14.82-3.42L89.83,56s0,0-.12,0h0L53.61,61.93a.17.17,0,0,0-.09,0L24,178.33,91,208a.23.23,0,0,0,.22,0L98,189.72a173.2,173.2,0,0,1-20.14-4.32A8,8,0,0,1,82.16,170,171.85,171.85,0,0,0,128,176a171.85,171.85,0,0,0,45.84-6,8,8,0,0,1,4.32,15.41A173.2,173.2,0,0,1,158,189.72L164.75,208a.22.22,0,0,0,.21,0Z" fill={color}></path></g></svg> <g color={color}>
<path
d="M216,24H40A16,16,0,0,0,24,40V216a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V40A16,16,0,0,0,216,24Zm0,192H40V40H216V216ZM96,112v64a8,8,0,0,1-16,0V112a8,8,0,0,1,16,0Zm88,28v36a8,8,0,0,1-16,0V140a20,20,0,0,0-40,0v36a8,8,0,0,1-16,0V112a8,8,0,0,1,15.79-1.78A36,36,0,0,1,184,140ZM100,84A12,12,0,1,1,88,72,12,12,0,0,1,100,84Z"
fill={color}
></path>
</g>
</svg>
</div> </div>
} );
};
export const XIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => { export const DiscordIcon = ({ size = 'S', color = 'rgb(179, 179, 179)' }) => {
let dimension = getSize(size); const dimension = getSize(size);
return <div style={{width: dimension, height: dimension}}> return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" id="svg2382164700"> <div style={{ width: dimension, height: dimension }}>
<path d="M 15.418 19.037 L 3.44 3.637 C 3.311 3.471 3.288 3.247 3.381 3.058 C 3.473 2.87 3.665 2.75 3.875 2.75 L 6.148 2.75 C 6.318 2.75 6.478 2.829 6.582 2.963 L 18.56 18.363 C 18.689 18.529 18.712 18.753 18.619 18.942 C 18.527 19.13 18.335 19.25 18.125 19.25 L 15.852 19.25 C 15.682 19.25 15.522 19.171 15.418 19.037 Z" fill="transparent" strokeWidth="1.38" strokeMiterlimit="10" stroke={color}></path> <svg
<path d="M 18.333 2.75 L 3.667 19.25" fill="transparent" strokeWidth="1.38" strokeLinecap="round" strokeMiterlimit="10" stroke={color}></path> xmlns="http://www.w3.org/2000/svg"
</svg> viewBox="0 0 256 256"
focusable="false"
color={color}
>
<g color={color}>
<path
d="M104,140a12,12,0,1,1-12-12A12,12,0,0,1,104,140Zm60-12a12,12,0,1,0,12,12A12,12,0,0,0,164,128Zm74.45,64.9-67,29.71a16.17,16.17,0,0,1-21.71-9.1l-8.11-22q-6.72.45-13.63.46t-13.63-.46l-8.11,22a16.18,16.18,0,0,1-21.71,9.1l-67-29.71a15.93,15.93,0,0,1-9.06-18.51L38,58A16.07,16.07,0,0,1,51,46.14l36.06-5.93a16.22,16.22,0,0,1,18.26,11.88l3.26,12.84Q118.11,64,128,64t19.4.93l3.26-12.84a16.21,16.21,0,0,1,18.26-11.88L205,46.14A16.07,16.07,0,0,1,218,58l29.53,116.38A15.93,15.93,0,0,1,238.45,192.9ZM232,178.28,202.47,62s0,0-.08,0L166.33,56a.17.17,0,0,0-.17,0l-2.83,11.14c5,.94,10,2.06,14.83,3.42A8,8,0,0,1,176,86.31a8.09,8.09,0,0,1-2.16-.3A172.25,172.25,0,0,0,128,80a172.25,172.25,0,0,0-45.84,6,8,8,0,1,1-4.32-15.4c4.82-1.36,9.78-2.48,14.82-3.42L89.83,56s0,0-.12,0h0L53.61,61.93a.17.17,0,0,0-.09,0L24,178.33,91,208a.23.23,0,0,0,.22,0L98,189.72a173.2,173.2,0,0,1-20.14-4.32A8,8,0,0,1,82.16,170,171.85,171.85,0,0,0,128,176a171.85,171.85,0,0,0,45.84-6,8,8,0,0,1,4.32,15.41A173.2,173.2,0,0,1,158,189.72L164.75,208a.22.22,0,0,0,.21,0Z"
fill={color}
></path>
</g>
</svg>
</div> </div>
} );
};
export const GithubIcon2 = ({size = 'S', color = 'rgb(179, 179, 179)'}) => { export const XIcon = ({ size = 'S', color = 'rgb(179, 179, 179)' }) => {
let dimension = getSize(size); const dimension = getSize(size);
return <div style={{width: dimension, height: dimension}}> return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false" color={color}><g color={color}><path d="M208.31,75.68A59.78,59.78,0,0,0,202.93,28,8,8,0,0,0,196,24a59.75,59.75,0,0,0-48,24H124A59.75,59.75,0,0,0,76,24a8,8,0,0,0-6.93,4,59.78,59.78,0,0,0-5.38,47.68A58.14,58.14,0,0,0,56,104v8a56.06,56.06,0,0,0,48.44,55.47A39.8,39.8,0,0,0,96,192v8H72a24,24,0,0,1-24-24A40,40,0,0,0,8,136a8,8,0,0,0,0,16,24,24,0,0,1,24,24,40,40,0,0,0,40,40H96v16a8,8,0,0,0,16,0V192a24,24,0,0,1,48,0v40a8,8,0,0,0,16,0V192a39.8,39.8,0,0,0-8.44-24.53A56.06,56.06,0,0,0,216,112v-8A58.14,58.14,0,0,0,208.31,75.68ZM200,112a40,40,0,0,1-40,40H112a40,40,0,0,1-40-40v-8a41.74,41.74,0,0,1,6.9-22.48A8,8,0,0,0,80,73.83a43.81,43.81,0,0,1,.79-33.58,43.88,43.88,0,0,1,32.32,20.06A8,8,0,0,0,119.82,64h32.35a8,8,0,0,0,6.74-3.69,43.87,43.87,0,0,1,32.32-20.06A43.81,43.81,0,0,1,192,73.83a8.09,8.09,0,0,0,1,7.65A41.72,41.72,0,0,1,200,104Z" fill={color}></path></g></svg> <div style={{ width: dimension, height: dimension }}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 22 22"
id="svg2382164700"
>
<path
d="M 15.418 19.037 L 3.44 3.637 C 3.311 3.471 3.288 3.247 3.381 3.058 C 3.473 2.87 3.665 2.75 3.875 2.75 L 6.148 2.75 C 6.318 2.75 6.478 2.829 6.582 2.963 L 18.56 18.363 C 18.689 18.529 18.712 18.753 18.619 18.942 C 18.527 19.13 18.335 19.25 18.125 19.25 L 15.852 19.25 C 15.682 19.25 15.522 19.171 15.418 19.037 Z"
fill="transparent"
strokeWidth="1.38"
strokeMiterlimit="10"
stroke={color}
></path>
<path
d="M 18.333 2.75 L 3.667 19.25"
fill="transparent"
strokeWidth="1.38"
strokeLinecap="round"
strokeMiterlimit="10"
stroke={color}
></path>
</svg>
</div> </div>
} );
};
export const GithubIcon2 = ({ size = 'S', color = 'rgb(179, 179, 179)' }) => {
const dimension = getSize(size);
return (
<div style={{ width: dimension, height: dimension }}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
focusable="false"
color={color}
>
<g color={color}>
<path
d="M208.31,75.68A59.78,59.78,0,0,0,202.93,28,8,8,0,0,0,196,24a59.75,59.75,0,0,0-48,24H124A59.75,59.75,0,0,0,76,24a8,8,0,0,0-6.93,4,59.78,59.78,0,0,0-5.38,47.68A58.14,58.14,0,0,0,56,104v8a56.06,56.06,0,0,0,48.44,55.47A39.8,39.8,0,0,0,96,192v8H72a24,24,0,0,1-24-24A40,40,0,0,0,8,136a8,8,0,0,0,0,16,24,24,0,0,1,24,24,40,40,0,0,0,40,40H96v16a8,8,0,0,0,16,0V192a24,24,0,0,1,48,0v40a8,8,0,0,0,16,0V192a39.8,39.8,0,0,0-8.44-24.53A56.06,56.06,0,0,0,216,112v-8A58.14,58.14,0,0,0,208.31,75.68ZM200,112a40,40,0,0,1-40,40H112a40,40,0,0,1-40-40v-8a41.74,41.74,0,0,1,6.9-22.48A8,8,0,0,0,80,73.83a43.81,43.81,0,0,1,.79-33.58,43.88,43.88,0,0,1,32.32,20.06A8,8,0,0,0,119.82,64h32.35a8,8,0,0,0,6.74-3.69,43.87,43.87,0,0,1,32.32-20.06A43.81,43.81,0,0,1,192,73.83a8.09,8.09,0,0,0,1,7.65A41.72,41.72,0,0,1,200,104Z"
fill={color}
></path>
</g>
</svg>
</div>
);
};

View File

@ -1,16 +1,17 @@
import styled from "@emotion/styled"; import styled from '@emotion/styled';
const Link = styled.a` const Link = styled.a`
display:block; display: block;
image-rendering: pixelated; image-rendering: pixelated;
flex-shrink: 0; flex-shrink: 0;
background-size: 100% 100%; background-size: 100% 100%;
border-radius: 8px; border-radius: 8px;
height: 40px; height: 40px;
width: 40px; width: 40px;
background-image: url("images/core/logo.svg"); background-image: url('/images/core/logo.svg');
opacity: 1;`; opacity: 1;
`;
export const Logo = () => { export const Logo = () => {
return <Link href="/" />; return <Link href="/" />;
}; };

View File

@ -0,0 +1,30 @@
import TokenForm, {
TokenFormProps,
} from '@/app/components/PlaygroundTokenForm';
import React, { ReactNode, useState } from 'react';
type PlaygroundProps = TokenFormProps & {
children?: ReactNode;
};
const Playground = ({
children,
setOpenApiJson,
setToken,
}: PlaygroundProps) => {
const [isTokenValid, setIsTokenValid] = useState(false);
return (
<>
<TokenForm
setOpenApiJson={setOpenApiJson}
setToken={setToken}
isTokenValid={isTokenValid}
setIsTokenValid={setIsTokenValid}
/>
{isTokenValid && children}
</>
);
};
export default Playground;

View File

@ -0,0 +1,190 @@
import React, { useEffect, useState } from 'react';
import { parseJson } from 'nx/src/utils/json';
import { TbLoader2 } from 'react-icons/tb';
import styled from '@emotion/styled';
export type TokenFormProps = {
setOpenApiJson?: (json: object) => void;
setToken?: (token: string) => void;
isTokenValid: boolean;
setIsTokenValid: (boolean: boolean) => void;
};
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
`;
const Form = styled.form`
text-align: center;
padding: 50px;
`;
const StyledLink = styled.a`
color: #16233f;
text-decoration: none;
position: relative;
font-weight: bold;
transition: color 0.3s ease;
[data-theme='dark'] & {
color: #a3c0f8;
}
`;
const Input = styled.input`
padding: 6px;
margin: 20px 0 5px 0;
max-width: 460px;
width: 100%;
box-sizing: border-box;
background-color: #f3f3f3;
border: 1px solid #ddd;
border-radius: 4px;
[data-theme='dark'] & {
background-color: #16233f;
}
&.invalid {
border: 1px solid #f83e3e;
}
`;
const TokenInvalid = styled.span`
color: #f83e3e;
font-size: 12px;
`;
const Loader = styled(TbLoader2)`
color: #16233f;
font-size: 2rem;
animation: animate 2s infinite;
[data-theme='dark'] & {
color: #a3c0f8;
}
&.not-visible {
visibility: hidden;
}
@keyframes animate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(720deg);
}
}
`;
const LoaderContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 50px;
`;
const TokenForm = ({
setOpenApiJson,
setToken,
isTokenValid,
setIsTokenValid,
}: TokenFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const token =
parseJson(localStorage.getItem('TryIt_securitySchemeValues') || '')
?.bearerAuth ?? '';
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
localStorage.setItem(
'TryIt_securitySchemeValues',
JSON.stringify({ bearerAuth: event.target.value }),
);
await submitToken(event.target.value);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validateToken = (openApiJson: any) =>
setIsTokenValid(!!openApiJson.tags);
const getJson = async (token: string) => {
setIsLoading(true);
return await fetch('https://api.twenty.com/open-api', {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((result) => {
validateToken(result);
setIsLoading(false);
return result;
})
.catch(() => setIsLoading(false));
};
const submitToken = async (token: string) => {
if (isLoading) return;
const json = await getJson(token);
setToken && setToken(token);
setOpenApiJson && setOpenApiJson(json);
};
useEffect(() => {
(async () => {
await submitToken(token);
})();
});
// We load playground style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = TokenForm.toString();
document.head.append(styleElement);
return () => styleElement.remove();
}, []);
return (
!isTokenValid && (
<Container>
<Form>
<label>
To load your playground schema,{' '}
<StyledLink href="https://app.twenty.com/settings/developers/api-keys">
generate an API key
</StyledLink>{' '}
and paste it here:
</label>
<p>
<Input
className={token && !isLoading ? 'input invalid' : 'input'}
type="text"
readOnly={isLoading}
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMD..."
defaultValue={token}
onChange={updateToken}
/>
<TokenInvalid
className={`${(!token || isLoading) && 'not-visible'}`}
>
Token invalid
</TokenInvalid>
<LoaderContainer>
<Loader className={`${!isLoading && 'not-visible'}`} />
</LoaderContainer>
</p>
</Form>
</Container>
)
);
};
export default TokenForm;

View File

@ -1,7 +1,11 @@
import Image from 'next/image';
export const PostImage = ({
import Image from 'next/image' sources,
style,
export const PostImage = ({ sources, style }: { sources: { light: string, dark: string }, style?: React.CSSProperties }) => { }: {
return <Image src={sources.light} style={style} alt={sources.light} /> sources: { light: string; dark: string };
} style?: React.CSSProperties;
}) => {
return <Image src={sources.light} style={style} alt={sources.light} />;
};

View File

@ -0,0 +1,19 @@
import Database from 'better-sqlite3';
export async function GET(
request: Request,
{ params }: { params: { slug: string } }) {
const db = new Database('db.sqlite', { readonly: true });
if(params.slug !== 'users' && params.slug !== 'labels' && params.slug !== 'pullRequests') {
return Response.json({ error: 'Invalid table name' }, { status: 400 });
}
const rows = db.prepare('SELECT * FROM ' + params.slug).all();
db.close();
return Response.json(rows);
}

View File

@ -0,0 +1,288 @@
import Database from 'better-sqlite3';
import { graphql } from '@octokit/graphql';
const db = new Database('db.sqlite', { verbose: console.log });
interface LabelNode {
id: string;
name: string;
color: string;
description: string;
}
export interface AuthorNode {
resourcePath: string;
login: string;
avatarUrl: string;
url: string;
}
interface PullRequestNode {
id: string;
title: string;
body: string;
createdAt: string;
updatedAt: string;
closedAt: string;
mergedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
interface IssueNode {
id: string;
title: string;
body: string;
createdAt: string;
updatedAt: string;
closedAt: string;
author: AuthorNode;
labels: {
nodes: LabelNode[];
};
}
interface PageInfo {
hasNextPage: boolean;
endCursor: string | null;
}
interface PullRequests {
nodes: PullRequestNode[];
pageInfo: PageInfo;
}
interface Issues {
nodes: IssueNode[];
pageInfo: PageInfo;
}
interface AssignableUserNode {
login: string;
}
interface AssignableUsers {
nodes: AssignableUserNode[];
}
interface RepoData {
repository: {
pullRequests: PullRequests;
issues: Issues;
assignableUsers: AssignableUsers;
};
}
const query = graphql.defaults({
headers: {
Authorization: 'bearer ' + process.env.GITHUB_TOKEN,
},
});
async function fetchData(cursor: string | null = null, isIssues: boolean = false, accumulatedData: Array<PullRequestNode | IssueNode> = []): Promise<Array<PullRequestNode | IssueNode>> {
const { repository } = await query<RepoData>(`
query ($cursor: String) {
repository(owner: "twentyhq", name: "twenty") {
pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${isIssues}) {
nodes {
id
title
body
createdAt
updatedAt
closedAt
mergedAt
author {
resourcePath
login
avatarUrl(size: 460)
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) @skip(if: ${!isIssues}) {
nodes {
id
title
body
createdAt
updatedAt
closedAt
author {
resourcePath
login
avatarUrl
url
}
labels(first: 100) {
nodes {
id
name
color
description
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`, { cursor });
const newAccumulatedData: Array<PullRequestNode | IssueNode> = [...accumulatedData, ...(isIssues ? repository.issues.nodes : repository.pullRequests.nodes)];
const pageInfo = isIssues ? repository.issues.pageInfo : repository.pullRequests.pageInfo;
if (pageInfo.hasNextPage) {
return fetchData(pageInfo.endCursor, isIssues, newAccumulatedData);
} else {
return newAccumulatedData;
}
}
async function fetchAssignableUsers(): Promise<Set<string>> {
const { repository } = await query<RepoData>(`
query {
repository(owner: "twentyhq", name: "twenty") {
assignableUsers(first: 100) {
nodes {
login
}
}
}
}
`);
return new Set(repository.assignableUsers.nodes.map(user => user.login));
}
const initDb = () => {
db.prepare(`
CREATE TABLE IF NOT EXISTS pullRequests (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT,
createdAt TEXT,
updatedAt TEXT,
closedAt TEXT,
mergedAt TEXT,
authorId TEXT,
FOREIGN KEY (authorId) REFERENCES users(id)
);
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT,
createdAt TEXT,
updatedAt TEXT,
closedAt TEXT,
authorId TEXT,
FOREIGN KEY (authorId) REFERENCES users(id)
);
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
login TEXT,
avatarUrl TEXT,
url TEXT,
isEmployee BOOLEAN
);
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS labels (
id TEXT PRIMARY KEY,
name TEXT,
color TEXT,
description TEXT
);
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS pullRequestLabels (
pullRequestId TEXT,
labelId TEXT,
FOREIGN KEY (pullRequestId) REFERENCES pullRequests(id),
FOREIGN KEY (labelId) REFERENCES labels(id)
);
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS issueLabels (
issueId TEXT,
labelId TEXT,
FOREIGN KEY (issueId) REFERENCES issues(id),
FOREIGN KEY (labelId) REFERENCES labels(id)
);
`).run();
};
export async function GET() {
initDb();
// TODO if we ever hit API Rate Limiting
const lastPRCursor = null;
const lastIssueCursor = null;
const assignableUsers = await fetchAssignableUsers();
const prs = await fetchData(lastPRCursor) as Array<PullRequestNode>;
const issues = await fetchData(lastIssueCursor) as Array<IssueNode>;
const insertPR = db.prepare('INSERT INTO pullRequests (id, title, body, createdAt, updatedAt, closedAt, mergedAt, authorId) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING');
const insertIssue = db.prepare('INSERT INTO issues (id, title, body, createdAt, updatedAt, closedAt, authorId) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING');
const insertUser = db.prepare('INSERT INTO users (id, login, avatarUrl, url, isEmployee) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING');
const insertLabel = db.prepare('INSERT INTO labels (id, name, color, description) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO NOTHING');
const insertPullRequestLabel = db.prepare('INSERT INTO pullRequestLabels (pullRequestId, labelId) VALUES (?, ?)');
const insertIssueLabel = db.prepare('INSERT INTO issueLabels (issueId, labelId) VALUES (?, ?)');
for (const pr of prs) {
console.log(pr);
if(pr.author == null) { continue; }
insertUser.run(pr.author.resourcePath, pr.author.login, pr.author.avatarUrl, pr.author.url, assignableUsers.has(pr.author.login) ? 1 : 0);
insertPR.run(pr.id, pr.title, pr.body, pr.createdAt, pr.updatedAt, pr.closedAt, pr.mergedAt, pr.author.resourcePath);
for (const label of pr.labels.nodes) {
insertLabel.run(label.id, label.name, label.color, label.description);
insertPullRequestLabel.run(pr.id, label.id);
}
}
for (const issue of issues) {
if(issue.author == null) { continue; }
insertUser.run(issue.author.resourcePath, issue.author.login, issue.author.avatarUrl, issue.author.url, assignableUsers.has(issue.author.login) ? 1 : 0);
insertIssue.run(issue.id, issue.title, issue.body, issue.createdAt, issue.updatedAt, issue.closedAt, issue.author.resourcePath);
for (const label of issue.labels.nodes) {
insertLabel.run(label.id, label.name, label.color, label.description);
insertIssueLabel.run(issue.id, label.id);
}
}
db.close();
return new Response("Data synced", { status: 200 });
};

View File

@ -0,0 +1,40 @@
import Image from 'next/image';
import Database from 'better-sqlite3';
import AvatarGrid from '@/app/components/AvatarGrid';
interface Contributor {
login: string;
avatarUrl: string;
pullRequestCount: number;
}
const Contributors = async () => {
const db = new Database('db.sqlite', { readonly: true });
const contributors = db.prepare(`SELECT
u.login,
u.avatarUrl,
COUNT(pr.id) AS pullRequestCount
FROM
users u
JOIN
pullRequests pr ON u.id = pr.authorId
GROUP BY
u.id
ORDER BY
pullRequestCount DESC;
`).all() as Contributor[];
db.close();
return (
<div>
<h1>Top Contributors</h1>
<AvatarGrid users={contributors} />
</div>
);
};
export default Contributors;

View File

@ -0,0 +1,26 @@
'use client';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { GraphiQL } from 'graphiql';
import dynamic from 'next/dynamic';
import 'graphiql/graphiql.css';
// Create a named function for your component
function GraphiQLComponent() {
const fetcher = createGraphiQLFetcher({
url: 'https://api.twenty.com/graphql',
});
return <GraphiQL fetcher={fetcher} />;
}
// Dynamically import the GraphiQL component with SSR disabled
const GraphiQLWithNoSSR = dynamic(() => Promise.resolve(GraphiQLComponent), {
ssr: false,
});
const GraphQLDocs = () => {
return <GraphiQLWithNoSSR />;
};
export default GraphQLDocs;

View File

@ -0,0 +1,63 @@
import { ContentContainer } from '@/app/components/ContentContainer';
const DeveloperDocsLayout = ({ children }: { children: React.ReactNode }) => {
return (
<ContentContainer>
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div
style={{
borderRight: '1px solid rgba(20, 20, 20, 0.08)',
paddingRight: '24px',
minWidth: '200px',
paddingTop: '48px',
}}
>
<h4 style={{ textTransform: 'uppercase', color: '#B3B3B3' }}>
Install & Maintain
</h4>
<a style={{ textDecoration: 'none', color: '#333' }} href="/">
Local setup
</a>{' '}
<br />
<a style={{ textDecoration: 'none', color: '#333' }} href="/">
Self-hosting
</a>{' '}
<br />
<a style={{ textDecoration: 'none', color: '#333' }} href="/">
Upgrade guide
</a>{' '}
<br /> <br />
<h4 style={{ textTransform: 'uppercase', color: '#B3B3B3' }}>
Resources
</h4>
<a style={{ textDecoration: 'none', color: '#333' }} href="/">
Contributors Guide
</a>{' '}
<br />
<a
style={{ textDecoration: 'none', color: '#333' }}
href="/developers/docs/graphql"
>
GraphQL API
</a>{' '}
<br />
<a
style={{ textDecoration: 'none', color: '#333', display: 'flex' }}
href="/developers/rest"
>
Rest API
</a>
<a style={{ textDecoration: 'none', color: '#333' }} href="/">
Twenty UI
</a>{' '}
<br />
</div>
<div style={{ padding: '24px', minHeight: '80vh', width: '100%' }}>
{children}
</div>
</div>
</ContentContainer>
);
};
export default DeveloperDocsLayout;

View File

@ -0,0 +1,9 @@
const DeveloperDocs = () => {
return (
<div>
<h1>Developer Docs</h1>
</div>
);
};
export default DeveloperDocs;

View File

@ -0,0 +1,33 @@
'use client';
/*import { API } from '@stoplight/elements';/
import '@stoplight/elements/styles.min.css';
/*
const RestApiComponent = ({ openApiJson }: { openApiJson: any }) => {
// We load spotlightTheme style using useEffect as it breaks remaining docs style
useEffect(() => {
const styleElement = document.createElement('style');
// styleElement.innerHTML = spotlightTheme.toString();
document.head.append(styleElement);
return () => styleElement.remove();
}, []);
return (
<API apiDescriptionDocument={JSON.stringify(openApiJson)} router="hash" />
);
};*/
const RestApi = () => {
/* const [openApiJson, setOpenApiJson] = useState({});
const children = <RestApiComponent openApiJson={openApiJson} />;*/
return <>API</>;
// return <Playground setOpenApiJson={setOpenApiJson}>{children}</Playground>;
};
export default RestApi;

View File

@ -0,0 +1,9 @@
const Developers = () => {
return (
<div>
<p>This page should probably be built on Framer</p>
</div>
);
};
export default Developers;

View File

@ -1,37 +1,37 @@
'use client' 'use client';
import { CacheProvider } from '@emotion/react' import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache' import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation' import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react' import { useState } from 'react';
export default function RootStyleRegistry({ children }) { export default function RootStyleRegistry({ children }) {
const [{ cache, flush }] = useState(() => { const [{ cache, flush }] = useState(() => {
const cache = createCache({ key: 'emotion-cache' }) const cache = createCache({ key: 'emotion-cache' });
cache.compat = true cache.compat = true;
const prevInsert = cache.insert const prevInsert = cache.insert;
let inserted = [] let inserted = [];
cache.insert = (...args) => { cache.insert = (...args) => {
const serialized = args[1] const serialized = args[1];
if (cache.inserted[serialized.name] === undefined) { if (cache.inserted[serialized.name] === undefined) {
inserted.push(serialized.name) inserted.push(serialized.name);
} }
return prevInsert(...args) return prevInsert(...args);
} };
const flush = () => { const flush = () => {
const prevInserted = inserted const prevInserted = inserted;
inserted = [] inserted = [];
return prevInserted return prevInserted;
} };
return { cache, flush } return { cache, flush };
}) });
useServerInsertedHTML(() => { useServerInsertedHTML(() => {
const names = flush() const names = flush();
if (names.length === 0) return null if (names.length === 0) return null;
let styles = '' let styles = '';
for (const name of names) { for (const name of names) {
styles += cache.inserted[name] styles += cache.inserted[name];
} }
return ( return (
<style <style
@ -40,8 +40,8 @@ export default function RootStyleRegistry({ children }) {
__html: styles, __html: styles,
}} }}
/> />
) );
}) });
return <CacheProvider value={cache}>{children}</CacheProvider> return <CacheProvider value={cache}>{children}</CacheProvider>;
} }

View File

@ -2,12 +2,16 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { compileMDX } from 'next-mdx-remote/rsc'; import { compileMDX } from 'next-mdx-remote/rsc';
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import gfm from 'remark-gfm';
import rehypeToc from '@jsdevtools/rehype-toc';
import rehypeSlug from 'rehype-slug';
interface ItemInfo { interface ItemInfo {
title: string; title: string;
position?: number; position?: number;
path: string; path: string;
type: 'file' | 'directory'; type: 'file' | 'directory';
icon?: string;
} }
export interface FileContent { export interface FileContent {
@ -20,15 +24,15 @@ export interface Directory {
itemInfo: ItemInfo; itemInfo: ItemInfo;
} }
const basePath = '/src/content/user-guide'; async function getFiles(
filePath: string,
basePath: string,
async function getFiles(filePath: string, position: number = 0): Promise<Directory> { position: number = 0,
): Promise<Directory> {
const entries = fs.readdirSync(filePath, { withFileTypes: true }); const entries = fs.readdirSync(filePath, { withFileTypes: true });
const urlpath = path.toString().split(basePath); const urlpath = path.toString().split(basePath);
const pathName = urlpath.length > 1 ? urlpath[1] : path.basename(filePath); const pathName = urlpath.length > 1 ? urlpath[1] : path.basename(filePath);
console.log(pathName);
const directory: Directory = { const directory: Directory = {
itemInfo: { itemInfo: {
@ -41,57 +45,97 @@ async function getFiles(filePath: string, position: number = 0): Promise<Directo
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
directory[entry.name] = await getFiles(path.join(filePath, entry.name), position++); directory[entry.name] = await getFiles(
path.join(filePath, entry.name),
basePath,
position++,
);
} else if (entry.isFile() && path.extname(entry.name) === '.mdx') { } else if (entry.isFile() && path.extname(entry.name) === '.mdx') {
const fileContent = fs.readFileSync(path.join(filePath, entry.name), 'utf8'); const { content, frontmatter } = await compileMDXFile(
const { content, frontmatter } = await compileMDX<{ title: string, position?: number }>({ source: fileContent, options: { parseFrontmatter: true } }); path.join(filePath, entry.name),
directory[entry.name] = { content, itemInfo: {...frontmatter, type: 'file', path: pathName + "/" + entry.name.replace(/\.mdx$/, '')} }; );
directory[entry.name] = {
content,
itemInfo: {
...frontmatter,
type: 'file',
path: pathName + '/' + entry.name.replace(/\.mdx$/, ''),
},
};
} }
} }
return directory; return directory;
} }
async function parseFrontMatterAndCategory(directory: Directory, dirPath: string): Promise<Directory> { async function parseFrontMatterAndCategory(
directory: Directory,
dirPath: string,
): Promise<Directory> {
const parsedDirectory: Directory = { const parsedDirectory: Directory = {
itemInfo: directory.itemInfo, itemInfo: directory.itemInfo,
}; };
for (const entry in directory) { for (const entry in directory) {
if (entry !== 'itemInfo' && directory[entry] instanceof Object) { if (entry !== 'itemInfo' && directory[entry] instanceof Object) {
parsedDirectory[entry] = await parseFrontMatterAndCategory(directory[entry] as Directory, path.join(dirPath, entry)); parsedDirectory[entry] = await parseFrontMatterAndCategory(
directory[entry] as Directory,
path.join(dirPath, entry),
);
} }
} }
const categoryPath = path.join(dirPath, '_category_.json'); const categoryPath = path.join(dirPath, '_category_.json');
if (fs.existsSync(categoryPath)) { if (fs.existsSync(categoryPath)) {
const categoryJson: ItemInfo = JSON.parse(fs.readFileSync(categoryPath, 'utf8')); const categoryJson: ItemInfo = JSON.parse(
fs.readFileSync(categoryPath, 'utf8'),
);
parsedDirectory.itemInfo = categoryJson; parsedDirectory.itemInfo = categoryJson;
} }
return parsedDirectory; return parsedDirectory;
} }
export async function getPosts(): Promise<Directory> { export async function compileMDXFile(filePath: string, addToc: boolean = true) {
const fileContent = fs.readFileSync(filePath, 'utf8');
const compiled = await compileMDX<{ title: string; position?: number }>({
source: fileContent,
options: {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [gfm],
rehypePlugins: [rehypeSlug, ...(addToc ? [rehypeToc] : [])],
},
},
});
return compiled;
}
export async function getPosts(basePath: string): Promise<Directory> {
const postsDirectory = path.join(process.cwd(), basePath); const postsDirectory = path.join(process.cwd(), basePath);
const directory = await getFiles(postsDirectory); const directory = await getFiles(postsDirectory, basePath);
return parseFrontMatterAndCategory(directory, postsDirectory); return parseFrontMatterAndCategory(directory, postsDirectory);
} }
export async function getPost(slug: string[]): Promise<FileContent | null> { export async function getPost(
slug: string[],
basePath: string,
): Promise<FileContent | null> {
const postsDirectory = path.join(process.cwd(), basePath); const postsDirectory = path.join(process.cwd(), basePath);
const modifiedSlug = slug.join('/'); const modifiedSlug = slug.join('/');
const filePath = path.join(postsDirectory, `${modifiedSlug}.mdx`); const filePath = path.join(postsDirectory, `${modifiedSlug}.mdx`);
console.log(filePath);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return null; return null;
} }
const fileContent = fs.readFileSync(filePath, 'utf8'); const { content, frontmatter } = await compileMDXFile(filePath);
const { content, frontmatter } = await compileMDX<{ title: string, position?: number }>({ source: fileContent, options: { parseFrontmatter: true } });
return {
return { content, itemInfo: {...frontmatter, type: 'file', path: modifiedSlug }}; content,
itemInfo: { ...frontmatter, type: 'file', path: modifiedSlug },
};
} }

View File

@ -18,9 +18,20 @@ body {
align-items: center; align-items: center;
} }
.warning {
color: rgb(94, 30, 4);
}
a { a {
color: rgb(129, 129, 129); color: rgb(129, 129, 129);
&:hover { &:hover {
color: #000; color: #000;
} }
}
nav.toc {
width: 200px;
position: fixed;
top: 100px;
right: 0;
} }

View File

@ -1,43 +1,41 @@
import type { Metadata } from 'next' import type { Metadata } from 'next';
import { Gabarito } from 'next/font/google' import { Gabarito } from 'next/font/google';
import EmotionRootStyleRegistry from './emotion-root-style-registry' import EmotionRootStyleRegistry from './emotion-root-style-registry';
import styled from '@emotion/styled' import { HeaderDesktop } from './components/HeaderDesktop';
import { HeaderDesktop } from './components/HeaderDesktop' import { FooterDesktop } from './components/FooterDesktop';
import { FooterDesktop } from './components/FooterDesktop' import { HeaderMobile } from '@/app/components/HeaderMobile';
import { HeaderMobile } from '@/app/components/HeaderMobile' import './layout.css';
import './layout.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Twenty.dev', title: 'Twenty.dev',
description: 'Twenty for Developer', description: 'Twenty for Developer',
icons: '/images/core/logo.svg', icons: '/images/core/logo.svg',
} };
const gabarito = Gabarito({ const gabarito = Gabarito({
weight: ['400', '500'], weight: ['400', '500'],
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
adjustFontFallback: false adjustFontFallback: false,
}) });
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" className={gabarito.className}> <html lang="en" className={gabarito.className}>
<body> <body>
<EmotionRootStyleRegistry> <EmotionRootStyleRegistry>
<HeaderDesktop /> <HeaderDesktop />
<div className="container"> <div className="container">
<HeaderMobile /> <HeaderMobile />
{children} {children}
</div> </div>
<FooterDesktop /> <FooterDesktop />
</EmotionRootStyleRegistry> </EmotionRootStyleRegistry>
</body> </body>
</html> </html>
) );
} }

View File

@ -1,14 +1,13 @@
import Image from 'next/image' import { ContentContainer } from './components/ContentContainer';
import { ContentContainer } from './components/ContentContainer'
export default function Home() { export default function Home() {
return ( return (
<ContentContainer> <ContentContainer>
<div style={{ minHeight: '60vh', marginTop: '50px' }}> <div style={{ minHeight: '60vh', marginTop: '50px' }}>
Part of the website is built directly with Framer, including the homepage. <br /> Part of the website is built directly with Framer, including the
We use Clouflare to split the traffic between the two sites. homepage. <br />
We use Clouflare to split the traffic between the two sites.
</div> </div>
</ContentContainer> </ContentContainer>
) );
} }

View File

@ -1,71 +1,71 @@
import { compileMDX } from 'next-mdx-remote/rsc' import { compileMDX } from 'next-mdx-remote/rsc';
import gfm from 'remark-gfm'; import gfm from 'remark-gfm';
import { ContentContainer } from '../components/ContentContainer'; import { ContentContainer } from '../components/ContentContainer';
import remarkBehead from 'remark-behead'; import remarkBehead from 'remark-behead';
import type { Metadata } from 'next' import type { Metadata } from 'next';
interface Release { interface Release {
id: number; id: number;
name: string; name: string;
body: string; body: string;
} }
export const metadata: Metadata= { export const metadata: Metadata = {
title: 'Twenty - Releases', title: 'Twenty - Releases',
description: 'Latest releases of Twenty', description: 'Latest releases of Twenty',
} };
const Home = async () => { const Home = async () => {
const response = await fetch('https://api.github.com/repos/twentyhq/twenty/releases'); const response = await fetch(
const data: Release[] = await response.json(); 'https://api.github.com/repos/twentyhq/twenty/releases',
);
const releases = await Promise.all( const data: Release[] = await response.json();
data.map(async (release) => {
let mdxSource;
try {
mdxSource = await compileMDX({
source: release.body,
options: {
mdxOptions: {
remarkPlugins: [
gfm,
[remarkBehead, { depth: 2 }],
],
}
},
});
mdxSource = mdxSource.content;
} catch(error) {
console.error('An error occurred during MDX rendering:', error);
mdxSource = `<p>Oops! Something went wrong.</p> ${error}`;;
}
return { const releases = await Promise.all(
id: release.id, data.map(async (release) => {
name: release.name, let mdxSource;
body: mdxSource, try {
}; mdxSource = await compileMDX({
}) source: release.body,
); options: {
mdxOptions: {
return ( remarkPlugins: [gfm, [remarkBehead, { depth: 2 }]],
<ContentContainer> },
<h1>Releases</h1> },
});
mdxSource = mdxSource.content;
} catch (error) {
console.error('An error occurred during MDX rendering:', error);
mdxSource = `<p>Oops! Something went wrong.</p> ${error}`;
}
{releases.map((release, index) => ( return {
<div key={release.id} id: release.id,
name: release.name,
body: mdxSource,
};
}),
);
return (
<ContentContainer>
<h1>Releases</h1>
{releases.map((release, index) => (
<div
key={release.id}
style={{ style={{
padding: '24px 0px 24px 0px', padding: '24px 0px 24px 0px',
borderBottom: index === releases.length - 1 ? 'none' : '1px solid #ccc', borderBottom:
}}> index === releases.length - 1 ? 'none' : '1px solid #ccc',
<h2>{release.name}</h2> }}
<div>{release.body}</div> >
</div> <h2>{release.name}</h2>
))} <div>{release.body}</div>
</ContentContainer> </div>
) ))}
} </ContentContainer>
);
export default Home; };
export default Home;

View File

@ -1,11 +0,0 @@
import { getPost } from "@/app/user-guide/get-posts";
export default async function BlogPost({ params }: { params: { slug: string[] } }) {
const post = await getPost(params.slug as string[]);
console.log(post);
return <div>
<h1>{post?.itemInfo.title}</h1>
<div>{post?.content}</div>
</div>;
}

View File

@ -0,0 +1,120 @@
import { ContentContainer } from '@/app/components/ContentContainer';
import { getPosts, Directory, FileContent } from '@/app/get-posts';
import Link from 'next/link';
import * as TablerIcons from '@tabler/icons-react';
import { FunctionComponent } from 'react';
function loadIcon(iconName?: string) {
const name = iconName ? iconName : 'IconCategory';
try {
const icon = TablerIcons[
name as keyof typeof TablerIcons
] as FunctionComponent;
return icon as TablerIcons.Icon;
} catch (error) {
console.error('Icon not found:', iconName);
return null;
}
}
const DirectoryItem = ({
name,
item,
}: {
name: string;
item: Directory | FileContent;
}) => {
if ('content' in item) {
// If the item is a file, we render a link.
const Icon = loadIcon(item.itemInfo.icon);
return (
<div key={name}>
<Link
style={{
textDecoration: 'none',
color: '#333',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
href={
item.itemInfo.path != 'user-guide/home'
? `/user-guide/${item.itemInfo.path}`
: '/user-guide'
}
>
{Icon ? <Icon size={12} /> : ''}
{item.itemInfo.title}
</Link>
</div>
);
} else {
// If the item is a directory, we render the title and the items in the directory.
return (
<div key={name}>
<h4 style={{ textTransform: 'uppercase', color: '#B3B3B3' }}>
{item.itemInfo.title}
</h4>
{Object.entries(item).map(([childName, childItem]) => {
if (childName !== 'itemInfo') {
return (
<DirectoryItem
key={childName}
name={childName}
item={childItem as Directory | FileContent}
/>
);
}
})}
</div>
);
}
};
export default async function UserGuideHome({
children,
}: {
children: React.ReactNode;
}) {
const basePath = '/src/content/user-guide';
const posts = await getPosts(basePath);
return (
<ContentContainer>
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div
style={{
borderRight: '1px solid rgba(20, 20, 20, 0.08)',
paddingRight: '24px',
minWidth: '200px',
paddingTop: '48px',
}}
>
{posts['home.mdx'] && (
<DirectoryItem
name="home"
item={posts['home.mdx'] as FileContent}
/>
)}
{Object.entries(posts).map(([name, item]) => {
if (name !== 'itemInfo' && name != 'home.mdx') {
return (
<DirectoryItem
key={name}
name={name}
item={item as Directory | FileContent}
/>
);
}
})}
</div>
<div style={{ paddingLeft: '24px', paddingRight: '200px' }}>
{children}
</div>
</div>
</ContentContainer>
);
}

View File

@ -0,0 +1,21 @@
import { getPost } from '@/app/get-posts';
export default async function UserGuideHome({
params,
}: {
params: { slug: string[] };
}) {
const basePath = '/src/content/user-guide';
const mainPost = await getPost(
params.slug && params.slug.length ? params.slug : ['home'],
basePath,
);
return (
<div>
<h2>{mainPost?.itemInfo.title}</h2>
<div>{mainPost?.content}</div>
</div>
);
}

View File

@ -1,47 +0,0 @@
import { ContentContainer } from '@/app/components/ContentContainer';
import { getPosts, Directory, FileContent } from '@/app/user-guide/get-posts';
import Link from 'next/link';
const DirectoryItem = ({ name, item }: { name: string, item: Directory | FileContent }) => {
if ('content' in item) {
// If the item is a file, we render a link.
return (
<div key={name}>
<Link href={`/user-guide/${item.itemInfo.path}`}>
{item.itemInfo.title}
</Link>
</div>
);
} else {
// If the item is a directory, we render the title and the items in the directory.
return (
<div key={name}>
<h2>{item.itemInfo.title}</h2>
{Object.entries(item).map(([childName, childItem]) => {
if (childName !== 'itemInfo') {
return <DirectoryItem key={childName} name={childName} item={childItem as Directory | FileContent} />;
}
})}
</div>
);
}
};
export default async function BlogHome() {
const posts = await getPosts();
return <ContentContainer>
<h1>User Guide</h1>
<div>
{Object.entries(posts).map(([name, item]) => {
if (name !== 'itemInfo') {
return <DirectoryItem key={name} name={name} item={item as Directory | FileContent} />;
}
})}
</div>
</ContentContainer>;
}

View File

@ -1,8 +1,7 @@
--- ---
title: Custom Objects title: Custom Objects
sidebar_position: 1 position: 1
sidebar_custom_props: icon: IconAugmentedReality
icon: TbAugmentedReality
--- ---

View File

@ -1,11 +1,9 @@
--- ---
title: Notes title: Notes
sidebar_position: 1 position: 1
sidebar_custom_props: icon: IconNote
icon: TbNote
--- ---
import PostImage from '@theme/PostImage';
Easily create a note to keep track of important information. Easily create a note to keep track of important information.
@ -13,7 +11,7 @@ Easily create a note to keep track of important information.
To attach a note to the record, go to the <b>Notes</b> tab of a record page and click on `+ New Note`. You can also format, comment, and upload images to your notes. To attach a note to the record, go to the <b>Notes</b> tab of a record page and click on `+ New Note`. You can also format, comment, and upload images to your notes.
<img src="images/user-guide/create-new-note-light.png" style={{width:'100%', maxWidth:'800px'}}/> <img src="/images/user-guide/create-new-note-light.png" style={{width:'100%', maxWidth:'800px'}}/>
## Format Notes ## Format Notes

View File

@ -1,12 +1,9 @@
--- ---
title: Opportunities title: Opportunities
sidebar_position: 1 position: 1
sidebar_custom_props: icon: IconTargetArrow
icon: TbTargetArrow
--- ---
import PostImage from '@theme/PostImage';
All opportunities are presented in a Kanban board, where each column represents the stage of your workflow and each card represents a record. For each card, you can list the amount, close date, probability, and the point of contact. You can also move each card between stages as it goes through your workflow. All opportunities are presented in a Kanban board, where each column represents the stage of your workflow and each card represents a record. For each card, you can list the amount, close date, probability, and the point of contact. You can also move each card between stages as it goes through your workflow.
<img src="/images/user-guide/all-opportunities-light.png" style={{width:'100%', maxWidth:'800px'}}/> <img src="/images/user-guide/all-opportunities-light.png" style={{width:'100%', maxWidth:'800px'}}/>

View File

@ -1,11 +1,9 @@
--- ---
title: Tasks title: Tasks
sidebar_position: 1 position: 1
sidebar_custom_props: icon: IconChecklist
icon: TbChecklist
--- ---
import PostImage from '../../../components/PostImage'
You can find all the tasks from across your workspace in the <b>Tasks</b> window in your sidebar. You can also find a dedicated tab for Tasks on each record so you can add and edit tasks directly from each record. Alternatively, you can click on the `+` button on the top right of each record page and then click on <b>Task</b> to create a new task. You can find all the tasks from across your workspace in the <b>Tasks</b> window in your sidebar. You can also find a dedicated tab for Tasks on each record so you can add and edit tasks directly from each record. Alternatively, you can click on the `+` button on the top right of each record page and then click on <b>Task</b> to create a new task.

View File

@ -1,16 +1,9 @@
--- ---
title: Get started title: Get started
displayed_sidebar: userSidebar position: 0
sidebar_class_name: hidden icon: IconUsers
sidebar_position: 0
sidebar_custom_props:
icon: TbUsers
isSidebarRoot: true
--- ---
# Welcome to Twenty's User Guide
The purpose of this user guide is to help you learn how you can use Twenty to build the CRM you want. The purpose of this user guide is to help you learn how you can use Twenty to build the CRM you want.
## Quick Search ## Quick Search

View File

@ -1,15 +1,13 @@
--- ---
title: Connect Twenty and Zapier title: Connect Zapier
sidebar_position: 3 position: 3
sidebar_custom_props: icon: IconBrandZapier
icon: TbBrandZapier
--- ---
:::caution <div class="warning">
Twenty integration is currently being registered in the public Zapier repository, you may not find it until the publishing process is complete. Twenty integration is currently being registered in the public Zapier repository, you may not find it until the publishing process is complete.
</div>
:::
Sync Twenty with 3000+ apps using [Zapier](https://zapier.com/), and automate your work. Here's how you can connect Twenty to Zapier: Sync Twenty with 3000+ apps using [Zapier](https://zapier.com/), and automate your work. Here's how you can connect Twenty to Zapier:
@ -21,7 +19,7 @@ Sync Twenty with 3000+ apps using [Zapier](https://zapier.com/), and automate yo
6. Enter your API key and click on 'Yes, Continue to Twenty.' 6. Enter your API key and click on 'Yes, Continue to Twenty.'
<div style={{textAlign: 'center'}}> <div style={{textAlign: 'center'}}>
<img src="/images/user-guide/connect-zapier.png" alt="Connect Twenty to Zapier" /> <img src="/images/user-guide/connect-zapier.png" alt="Connect Twenty to Zapier" style={{width:'100%', maxWidth:'800px'}}/>
</div> </div>
You can now continue creating your automation! You can now continue creating your automation!

View File

@ -1,8 +1,7 @@
--- ---
title: Generating an API Key title: API Keys
sidebar_position: 2 position: 2
sidebar_custom_props: icon: IconApi
icon: TbApi
--- ---
To generate an API key: To generate an API key:
@ -14,11 +13,9 @@ To generate an API key:
5. Hit save to see your API key. 5. Hit save to see your API key.
6. Since the key is only visible once, make sure you store it somewhere safe. 6. Since the key is only visible once, make sure you store it somewhere safe.
:::caution Note <div class="warning">
Since your API key contains sensitive information, you shouldn't share it with services you don't fully trust. If leaked, someone can use it maliciously. If you think your API key is no longer secure, make sure you disable it and generate a new one. Since your API key contains sensitive information, you shouldn't share it with services you don't fully trust. If leaked, someone can use it maliciously. If you think your API key is no longer secure, make sure you disable it and generate a new one.
</div>
:::
## Regenerating API key ## Regenerating API key

View File

@ -1,8 +1,7 @@
--- ---
title: Glossary title: Glossary
sidebar_position: 3 position: 3
sidebar_custom_props: icon: IconVocabulary
icon: TbVocabulary
--- ---
### Company & People ### Company & People

View File

@ -1,8 +1,7 @@
--- ---
title: Tips title: Tips
sidebar_position: 1 sidebar_position: 1
sidebar_custom_props: icon: IconInfoCircle
icon: TbInfoCircle
--- ---
## Update workspace name & logo ## Update workspace name & logo

File diff suppressed because it is too large Load Diff

774
yarn.lock

File diff suppressed because it is too large Load Diff