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
*.tsbuildinfo
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} */
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": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@jsdevtools/rehype-toc": "^3.0.2",
"@stoplight/elements": "^7.16.2",
"@tabler/icons-react": "^2.44.0",
"better-sqlite3": "^9.2.2",
"graphiql": "^3.0.10",
"next": "14.0.4",
"next-mdx-remote": "^4.4.1",
"react": "^18",
"react-dom": "^18",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0",
"remark-gfm": "^3.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"eslint": "^8",
"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"
}
}

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`
display: flex;
flex-direction: column;
width: 600px;
@media(max-width: 809px) {
width: 100%;
}`;
display: flex;
flex-direction: column;
width: 100%;
padding: 0px 96px 0px 96px;
@media (max-width: 809px) {
width: 100%;
padding: 0px 12px 0px 12px;
}
`;
export const ContentContainer = ({children}: {children?: React.ReactNode}) => {
return (
<Container>{children}</Container>
)
}
export const ContentContainer = ({
children,
}: {
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 { DiscordIcon, GithubIcon2, LinkedInIcon, XIcon } from "./Icons";
import { DiscordIcon, GithubIcon2, LinkedInIcon, XIcon } from './Icons';
import { usePathname } from 'next/navigation';
const FooterContainer = styled.div`
padding: 64px 96px 64px 96px;
display: flex;
flex-direction: column;
color: rgb(129, 129, 129);
gap: 32px;
@media(max-width: 809px) {
display: none;
}
padding: 64px 96px 64px 96px;
display: flex;
flex-direction: column;
color: rgb(129, 129, 129);
gap: 32px;
@media (max-width: 809px) {
display: none;
}
`;
const LeftSideFooter = styled.div`
width: 36Opx;
display: flex;
flex-direction: column;
gap: 16px;`;
width: 36Opx;
display: flex;
flex-direction: column;
gap: 16px;
`;
const RightSideFooter = styled.div`
display: flex;
justify-content: space-between;
gap: 48px;
height: 146px;`;
display: flex;
justify-content: space-between;
gap: 48px;
height: 146px;
`;
const RightSideFooterColumn = styled.div`
width: 160px;
display: flex;
flex-direction: column;
gap: 8px;
width: 160px;
display: flex;
flex-direction: column;
gap: 8px;
`;
const RightSideFooterLink = styled.a`
color: rgb(129, 129, 129);
text-decoration: none;
&:hover {
text-decoration: underline;
color: #000;
}`;
color: rgb(129, 129, 129);
text-decoration: none;
&:hover {
text-decoration: underline;
color: #000;
}
`;
const RightSideFooterColumnTitle = styled.div`
font-size: 20px;
font-weight: 500;
color: #000;
`;
font-size: 20px;
font-weight: 500;
color: #000;
`;
export const FooterDesktop = () => {
return <FooterContainer>
<div style={{ width: '100%', display: 'flex', flexDirection: 'row', justifyContent:'space-between'}}>
<LeftSideFooter>
<Logo />
<div>
The #1 Open Source CRM
</div>
</LeftSideFooter>
<RightSideFooter>
<RightSideFooterColumn>
<RightSideFooterColumnTitle>Company</RightSideFooterColumnTitle>
<RightSideFooterLink href='/pricing'>Pricing</RightSideFooterLink>
<RightSideFooterLink href='/story'>Story</RightSideFooterLink>
</RightSideFooterColumn>
<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>
const path = usePathname();
const isTwentyDev = path.includes('developers');
if (isTwentyDev) return;
return (
<FooterContainer>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<LeftSideFooter>
<Logo />
<div>The #1 Open Source CRM</div>
</LeftSideFooter>
<RightSideFooter>
<RightSideFooterColumn>
<RightSideFooterColumnTitle>Company</RightSideFooterColumnTitle>
<RightSideFooterLink href="/pricing">Pricing</RightSideFooterLink>
<RightSideFooterLink href="/story">Story</RightSideFooterLink>
</RightSideFooterColumn>
<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 style={{
width: '100%',
<div
style={{
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 style={{ display: 'flex', flexDirection: 'row', justifyContent:'space-between', gap:'10px'}}>
<a href="https://x.com/twentycrm" target="_blank">
<XIcon size='M'/>
</a>
<a href="https://github.com/twentyhq/twenty" target="_blank">
<GithubIcon2 size='M'/>
</a>
<a href="https://www.linkedin.com/company/twenty" target="_blank">
<LinkedInIcon size='M'/>
</a>
<a href="https://discord.gg/UfGNZJfAG6" target="_blank">
<DiscordIcon size='M' />
</a>
</div>
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://www.linkedin.com/company/twenty"
target="_blank"
rel="noreferrer"
>
<LinkedInIcon size="M" />
</a>
<a
href="https://discord.gg/UfGNZJfAG6"
target="_blank"
rel="noreferrer"
>
<DiscordIcon size="M" />
</a>
</div>
</div>
</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 { 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({
weight: '500',
subsets: ['latin'],
display: 'swap',
});
weight: '500',
subsets: ['latin'],
display: 'swap',
});
const Nav = styled.nav`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
overflow: visible;
padding: 12px 16px 12px 16px;
position: relative;
transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08);
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
overflow: visible;
padding: 12px 16px 12px 16px;
position: relative;
transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08);
@media(max-width: 809px) {
display: none;
}
@media (max-width: 809px) {
display: none;
}
`;
const LinkList = styled.div`
display:flex;
flex-direction: row;
gap: 2px;
`;
display: flex;
flex-direction: row;
gap: 2px;
`;
const ListItem = styled.a`
color: rgb(71, 71, 71);
text-decoration: none;
display: flex;
gap: 4px;
align-items: center;
border-radius: 8px;
height: 40px;
padding-left: 16px;
padding-right: 16px;
&:hover {
background-color: #F1F1F1;
}
color: rgb(71, 71, 71);
text-decoration: none;
display: flex;
gap: 4px;
align-items: center;
border-radius: 8px;
height: 40px;
padding-left: 16px;
padding-right: 16px;
&:hover {
background-color: #f1f1f1;
}
`;
const LogoContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
width:202px;`;
display: flex;
align-items: center;
gap: 8px;
width: 202px;
`;
const LogoAddon = styled.div`
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 150%;
`;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 150%;
`;
const StyledButton = styled.div`
display: flex;
height: 40px;
padding-left: 16px;
padding-right: 16px;
align-items: center;
background-color: #000;
color: #fff;
border-radius: 8px;
font-weight: 500;
border: none;
outline: inherit;
cursor: pointer;
display: flex;
height: 40px;
padding-left: 16px;
padding-right: 16px;
align-items: center;
background-color: #000;
color: #fff;
border-radius: 8px;
font-weight: 500;
border: none;
outline: inherit;
cursor: pointer;
`;
const CallToActionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
a {
text-decoration: none;
}
`;
display: flex;
align-items: center;
gap: 8px;
a {
text-decoration: none;
}
`;
const LinkNextToCTA = styled.a`
display: flex;
align-items: center;
color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px;
span {
text-decoration: underline;
}`;
display: flex;
align-items: center;
color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px;
span {
text-decoration: underline;
}
`;
const CallToAction = () => {
return <CallToActionContainer>
<LinkNextToCTA href="https://github.com/twentyhq/twenty">Sign in</LinkNextToCTA>
<a href="#">
<StyledButton>
Get Started
</StyledButton>
</a>
</CallToActionContainer>;
const path = usePathname();
const isTwentyDev = path.includes('developers');
return (
<CallToActionContainer>
{isTwentyDev ? (
<>
<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 = () => {
const path = usePathname();
const isTwentyDev = path.includes('developers');
const isTwentyDev = false;
return <Nav>
<LogoContainer>
<Logo />
{isTwentyDev && <LogoAddon className={IBMPlexMono.className}>for Developers</LogoAddon>}
</LogoContainer>
return (
<Nav>
<LogoContainer>
<Logo />
{isTwentyDev && (
<LogoAddon className={IBMPlexMono.className}>
for Developers
</LogoAddon>
)}
</LogoContainer>
{isTwentyDev ? (
<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>
<ListItem href="/developers/docs">Docs</ListItem>
<ListItem href="/developers/contributors">Contributors</ListItem>
<ListItem href="/">
Cloud <ExternalArrow />
</ListItem>
</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 { IBM_Plex_Mono } from 'next/font/google';
import { GithubIcon } from './Icons';
import { useState } from 'react';
import { ExternalArrow } from '@/app/components/ExternalArrow';
const IBMPlexMono = IBM_Plex_Mono({
weight: '500',
subsets: ['latin'],
display: 'swap',
weight: '500',
subsets: ['latin'],
display: 'swap',
});
const Nav = styled.nav`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
overflow: visible;
padding: 0 12px;
position: relative;
transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08);
height: 64px;
width: 100%;
@media(min-width: 810px) {
display: none;
}
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
overflow: visible;
padding: 0 12px;
position: relative;
transform-origin: 50% 50% 0px;
border-bottom: 1px solid rgba(20, 20, 20, 0.08);
height: 64px;
width: 100%;
@media (min-width: 810px) {
display: none;
}
`;
const LinkList = styled.div`
display:flex;
flex-direction: column;
`;
display: flex;
flex-direction: column;
`;
const ListItem = styled.a`
color: rgb(71, 71, 71);
text-decoration: none;
display: flex;
gap: 4px;
align-items: center;
border-radius: 8px;
height: 40px;
padding-left: 16px;
padding-right: 16px;
&:hover {
background-color: #F1F1F1;
}
color: rgb(71, 71, 71);
text-decoration: none;
display: flex;
gap: 4px;
align-items: center;
border-radius: 8px;
height: 40px;
padding-left: 16px;
padding-right: 16px;
&:hover {
background-color: #f1f1f1;
}
`;
const LogoContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
width:202px;`;
display: flex;
align-items: center;
gap: 8px;
width: 202px;
`;
const LogoAddon = styled.div`
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 150%;
`;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 150%;
`;
const StyledButton = styled.div`
display: flex;
height: 40px;
padding-left: 16px;
padding-right: 16px;
align-items: center;
background-color: #000;
color: #fff;
border-radius: 8px;
font-weight: 500;
border: none;
outline: inherit;
cursor: pointer;
display: flex;
height: 40px;
padding-left: 16px;
padding-right: 16px;
align-items: center;
background-color: #000;
color: #fff;
border-radius: 8px;
font-weight: 500;
border: none;
outline: inherit;
cursor: pointer;
`;
const CallToActionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
a {
text-decoration: none;
}
`;
display: flex;
align-items: center;
gap: 8px;
a {
text-decoration: none;
}
`;
const LinkNextToCTA = styled.a`
display: flex;
align-items: center;
color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px;
span {
text-decoration: underline;
}`;
display: flex;
align-items: center;
color: rgb(71, 71, 71);
padding: 0px 16px 0px 16px;
span {
text-decoration: underline;
}
`;
const CallToAction = () => {
return <CallToActionContainer>
<LinkNextToCTA href="https://github.com/twentyhq/twenty">Sign in</LinkNextToCTA>
<a href="#">
<StyledButton>
Get Started
</StyledButton>
</a>
</CallToActionContainer>;
return (
<CallToActionContainer>
<LinkNextToCTA href="https://github.com/twentyhq/twenty">
Sign in
</LinkNextToCTA>
<a href="#">
<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>
}
const HamburgerContainer = styled.div`
height: 44px;
width: 44px;
cursor: pointer;
position: relative;
input {
height: 44px;
width: 44px;
cursor: pointer;
position: relative;
input {
height: 44px;
width: 44px;
opacity: 0;
}`;
opacity: 0;
}
#line1 {
transition: transform 0.5s;
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`
height: 2px;
left: calc(50.00000000000002% - 20px / 2);
position: absolute;
top: calc(37.50000000000002% - 2px / 2);
width: 20px;
border-radius: 10px;
background-color: rgb(179, 179, 179);`;
height: 2px;
left: calc(50.00000000000002% - 20px / 2);
position: absolute;
top: calc(37.50000000000002% - 2px / 2);
width: 20px;
border-radius: 10px;
background-color: rgb(179, 179, 179);
`;
const HamburgerLine2 = styled.div`
height: 2px;
left: calc(50.00000000000002% - 20px / 2);
position: absolute;
top: calc(62.50000000000002% - 2px / 2);
width: 20px;
border-radius: 10px;
background-color: rgb(179, 179, 179);`;
height: 2px;
left: calc(50.00000000000002% - 20px / 2);
position: absolute;
top: calc(62.50000000000002% - 2px / 2);
width: 20px;
border-radius: 10px;
background-color: rgb(179, 179, 179);
`;
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 = () => {
const isTwentyDev = false;
const isTwentyDev = false;
const [menuOpen, setMenuOpen] = useState(false);
return <Nav>
const toggleMenu = () => {
setMenuOpen(!menuOpen);
};
return (
<MobileMenu>
<Nav>
<LogoContainer>
<Logo />
{isTwentyDev && <LogoAddon className={IBMPlexMono.className}>for Developers</LogoAddon>}
<Logo />
{isTwentyDev && (
<LogoAddon className={IBMPlexMono.className}>
for Developers
</LogoAddon>
)}
</LogoContainer>
<HamburgerContainer>
<input type="checkbox" />
<HamburgerLine1 />
<HamburgerLine2 />
<input type="checkbox" id="menu-input" onChange={toggleMenu} />
<HamburgerLine1 id="line1" />
<HamburgerLine2 id="line2" />
</HamburgerContainer>
<NavOpen>
</Nav>
<NavOpen style={{ display: menuOpen ? 'flex' : 'none' }}>
<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>
<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 />
</NavOpen>
</Nav>;
</NavOpen>
</MobileMenu>
);
};

View File

@ -1,51 +1,119 @@
const getSize = (size: string) => {
switch(size) {
case 'S':
return '14px';
case 'M':
return '24px';
case 'L':
return '48px';
default:
return '14px';
}
switch (size) {
case 'S':
return '14px';
case 'M':
return '24px';
case 'L':
return '48px';
default:
return '14px';
}
};
export const GithubIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => {
let dimension = getSize(size);
return <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>
export const GithubIcon = ({ 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 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>
}
);
};
export const LinkedInIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => {
let dimension = getSize(size);
export const LinkedInIcon = ({ 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="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>;
}
export const DiscordIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => {
let 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="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>
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="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>
}
);
};
export const XIcon = ({size = 'S', color = 'rgb(179, 179, 179)'}) => {
let dimension = getSize(size);
return <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>
export const DiscordIcon = ({ 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="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>
}
);
};
export const GithubIcon2 = ({size = 'S', color = 'rgb(179, 179, 179)'}) => {
let 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>
export const XIcon = ({ 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 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>
}
);
};
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`
display:block;
image-rendering: pixelated;
flex-shrink: 0;
background-size: 100% 100%;
border-radius: 8px;
height: 40px;
width: 40px;
background-image: url("images/core/logo.svg");
opacity: 1;`;
display: block;
image-rendering: pixelated;
flex-shrink: 0;
background-size: 100% 100%;
border-radius: 8px;
height: 40px;
width: 40px;
background-image: url('/images/core/logo.svg');
opacity: 1;
`;
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';
import Image from 'next/image'
export const PostImage = ({ sources, style }: { sources: { light: string, dark: string }, style?: React.CSSProperties }) => {
return <Image src={sources.light} style={style} alt={sources.light} />
}
export const PostImage = ({
sources,
style,
}: {
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 createCache from '@emotion/cache'
import { useServerInsertedHTML } from 'next/navigation'
import { useState } from 'react'
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';
export default function RootStyleRegistry({ children }) {
const [{ cache, flush }] = useState(() => {
const cache = createCache({ key: 'emotion-cache' })
cache.compat = true
const prevInsert = cache.insert
let inserted = []
const cache = createCache({ key: 'emotion-cache' });
cache.compat = true;
const prevInsert = cache.insert;
let inserted = [];
cache.insert = (...args) => {
const serialized = args[1]
const serialized = args[1];
if (cache.inserted[serialized.name] === undefined) {
inserted.push(serialized.name)
inserted.push(serialized.name);
}
return prevInsert(...args)
}
return prevInsert(...args);
};
const flush = () => {
const prevInserted = inserted
inserted = []
return prevInserted
}
return { cache, flush }
})
const prevInserted = inserted;
inserted = [];
return prevInserted;
};
return { cache, flush };
});
useServerInsertedHTML(() => {
const names = flush()
if (names.length === 0) return null
let styles = ''
const names = flush();
if (names.length === 0) return null;
let styles = '';
for (const name of names) {
styles += cache.inserted[name]
styles += cache.inserted[name];
}
return (
<style
@ -40,8 +40,8 @@ export default function RootStyleRegistry({ children }) {
__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 { compileMDX } from 'next-mdx-remote/rsc';
import { ReactElement } from 'react';
import gfm from 'remark-gfm';
import rehypeToc from '@jsdevtools/rehype-toc';
import rehypeSlug from 'rehype-slug';
interface ItemInfo {
title: string;
position?: number;
path: string;
type: 'file' | 'directory';
icon?: string;
}
export interface FileContent {
@ -20,15 +24,15 @@ export interface Directory {
itemInfo: ItemInfo;
}
const basePath = '/src/content/user-guide';
async function getFiles(filePath: string, position: number = 0): Promise<Directory> {
async function getFiles(
filePath: string,
basePath: string,
position: number = 0,
): Promise<Directory> {
const entries = fs.readdirSync(filePath, { withFileTypes: true });
const urlpath = path.toString().split(basePath);
const pathName = urlpath.length > 1 ? urlpath[1] : path.basename(filePath);
console.log(pathName);
const directory: Directory = {
itemInfo: {
@ -41,57 +45,97 @@ async function getFiles(filePath: string, position: number = 0): Promise<Directo
for (const entry of entries) {
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') {
const fileContent = fs.readFileSync(path.join(filePath, entry.name), 'utf8');
const { content, frontmatter } = await compileMDX<{ title: string, position?: number }>({ source: fileContent, options: { parseFrontmatter: true } });
directory[entry.name] = { content, itemInfo: {...frontmatter, type: 'file', path: pathName + "/" + entry.name.replace(/\.mdx$/, '')} };
const { content, frontmatter } = await compileMDXFile(
path.join(filePath, entry.name),
);
directory[entry.name] = {
content,
itemInfo: {
...frontmatter,
type: 'file',
path: pathName + '/' + entry.name.replace(/\.mdx$/, ''),
},
};
}
}
return directory;
}
async function parseFrontMatterAndCategory(directory: Directory, dirPath: string): Promise<Directory> {
async function parseFrontMatterAndCategory(
directory: Directory,
dirPath: string,
): Promise<Directory> {
const parsedDirectory: Directory = {
itemInfo: directory.itemInfo,
};
for (const entry in directory) {
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');
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;
}
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 directory = await getFiles(postsDirectory);
const directory = await getFiles(postsDirectory, basePath);
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 modifiedSlug = slug.join('/');
const filePath = path.join(postsDirectory, `${modifiedSlug}.mdx`);
console.log(filePath);
if (!fs.existsSync(filePath)) {
return null;
}
const fileContent = fs.readFileSync(filePath, 'utf8');
const { content, frontmatter } = await compileMDX<{ title: string, position?: number }>({ source: fileContent, options: { parseFrontmatter: true } });
return { content, itemInfo: {...frontmatter, type: 'file', path: modifiedSlug }};
const { content, frontmatter } = await compileMDXFile(filePath);
return {
content,
itemInfo: { ...frontmatter, type: 'file', path: modifiedSlug },
};
}

View File

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

View File

@ -1,43 +1,41 @@
import type { Metadata } from 'next'
import { Gabarito } from 'next/font/google'
import EmotionRootStyleRegistry from './emotion-root-style-registry'
import styled from '@emotion/styled'
import { HeaderDesktop } from './components/HeaderDesktop'
import { FooterDesktop } from './components/FooterDesktop'
import { HeaderMobile } from '@/app/components/HeaderMobile'
import './layout.css'
import type { Metadata } from 'next';
import { Gabarito } from 'next/font/google';
import EmotionRootStyleRegistry from './emotion-root-style-registry';
import { HeaderDesktop } from './components/HeaderDesktop';
import { FooterDesktop } from './components/FooterDesktop';
import { HeaderMobile } from '@/app/components/HeaderMobile';
import './layout.css';
export const metadata: Metadata = {
title: 'Twenty.dev',
description: 'Twenty for Developer',
icons: '/images/core/logo.svg',
}
};
const gabarito = Gabarito({
weight: ['400', '500'],
subsets: ['latin'],
display: 'swap',
adjustFontFallback: false
})
adjustFontFallback: false,
});
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<html lang="en" className={gabarito.className}>
<body>
<EmotionRootStyleRegistry>
<HeaderDesktop />
<div className="container">
<HeaderMobile />
{children}
</div>
<FooterDesktop />
<EmotionRootStyleRegistry>
<HeaderDesktop />
<div className="container">
<HeaderMobile />
{children}
</div>
<FooterDesktop />
</EmotionRootStyleRegistry>
</body>
</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() {
return (
<ContentContainer>
<div style={{ minHeight: '60vh', marginTop: '50px' }}>
Part of the website is built directly with Framer, including the homepage. <br />
We use Clouflare to split the traffic between the two sites.
Part of the website is built directly with Framer, including the
homepage. <br />
We use Clouflare to split the traffic between the two sites.
</div>
</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 { ContentContainer } from '../components/ContentContainer';
import remarkBehead from 'remark-behead';
import type { Metadata } from 'next'
import type { Metadata } from 'next';
interface Release {
id: number;
name: string;
body: string;
id: number;
name: string;
body: string;
}
export const metadata: Metadata= {
export const metadata: Metadata = {
title: 'Twenty - Releases',
description: 'Latest releases of Twenty',
}
};
const Home = async () => {
const response = await fetch('https://api.github.com/repos/twentyhq/twenty/releases');
const data: Release[] = await response.json();
const releases = await Promise.all(
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}`;;
}
const Home = async () => {
const response = await fetch(
'https://api.github.com/repos/twentyhq/twenty/releases',
);
const data: Release[] = await response.json();
return {
id: release.id,
name: release.name,
body: mdxSource,
};
})
);
return (
<ContentContainer>
<h1>Releases</h1>
const releases = await Promise.all(
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}`;
}
{releases.map((release, index) => (
<div key={release.id}
return {
id: release.id,
name: release.name,
body: mdxSource,
};
}),
);
return (
<ContentContainer>
<h1>Releases</h1>
{releases.map((release, index) => (
<div
key={release.id}
style={{
padding: '24px 0px 24px 0px',
borderBottom: index === releases.length - 1 ? 'none' : '1px solid #ccc',
}}>
<h2>{release.name}</h2>
<div>{release.body}</div>
</div>
))}
</ContentContainer>
)
}
export default Home;
borderBottom:
index === releases.length - 1 ? 'none' : '1px solid #ccc',
}}
>
<h2>{release.name}</h2>
<div>{release.body}</div>
</div>
))}
</ContentContainer>
);
};
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
sidebar_position: 1
sidebar_custom_props:
icon: TbAugmentedReality
position: 1
icon: IconAugmentedReality
---

View File

@ -1,11 +1,9 @@
---
title: Notes
sidebar_position: 1
sidebar_custom_props:
icon: TbNote
position: 1
icon: IconNote
---
import PostImage from '@theme/PostImage';
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.
<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

View File

@ -1,12 +1,9 @@
---
title: Opportunities
sidebar_position: 1
sidebar_custom_props:
icon: TbTargetArrow
position: 1
icon: IconTargetArrow
---
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.
<img src="/images/user-guide/all-opportunities-light.png" style={{width:'100%', maxWidth:'800px'}}/>

View File

@ -1,11 +1,9 @@
---
title: Tasks
sidebar_position: 1
sidebar_custom_props:
icon: TbChecklist
position: 1
icon: IconChecklist
---
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.

View File

@ -1,16 +1,9 @@
---
title: Get started
displayed_sidebar: userSidebar
sidebar_class_name: hidden
sidebar_position: 0
sidebar_custom_props:
icon: TbUsers
isSidebarRoot: true
position: 0
icon: IconUsers
---
# 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.
## Quick Search

View File

@ -1,15 +1,13 @@
---
title: Connect Twenty and Zapier
sidebar_position: 3
sidebar_custom_props:
icon: TbBrandZapier
title: Connect Zapier
position: 3
icon: IconBrandZapier
---
:::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.
</div>
:::
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.'
<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>
You can now continue creating your automation!

View File

@ -1,8 +1,7 @@
---
title: Generating an API Key
sidebar_position: 2
sidebar_custom_props:
icon: TbApi
title: API Keys
position: 2
icon: IconApi
---
To generate an API key:
@ -14,11 +13,9 @@ To generate an 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.
:::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.
:::
</div>
## Regenerating API key

View File

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

View File

@ -1,8 +1,7 @@
---
title: Tips
sidebar_position: 1
sidebar_custom_props:
icon: TbInfoCircle
icon: IconInfoCircle
---
## 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