mirror of
https://github.com/twentyhq/twenty.git
synced 2024-10-27 03:33:21 +03:00
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:
parent
fa8a04743c
commit
c422045ea6
@ -1 +1,2 @@
|
||||
BASE_URL=http://localhost:3000
|
||||
BASE_URL=http://localhost:3000
|
||||
GITHUB_TOKEN=your_github_token
|
||||
|
@ -1,3 +1,7 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
]
|
||||
}
|
3
packages/twenty-website/.gitignore
vendored
3
packages/twenty-website/.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# DB
|
||||
*.sqlite
|
5
packages/twenty-website/.prettierrc
Normal file
5
packages/twenty-website/.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
62
packages/twenty-website/src/app/components/AvatarGrid.tsx
Normal file
62
packages/twenty-website/src/app/components/AvatarGrid.tsx
Normal 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;
|
@ -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>;
|
||||
};
|
||||
|
16
packages/twenty-website/src/app/components/ExternalArrow.tsx
Normal file
16
packages/twenty-website/src/app/components/ExternalArrow.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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="/" />;
|
||||
};
|
||||
|
30
packages/twenty-website/src/app/components/Playground.tsx
Normal file
30
packages/twenty-website/src/app/components/Playground.tsx
Normal 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;
|
@ -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;
|
@ -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} />;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
@ -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 });
|
||||
};
|
@ -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;
|
@ -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;
|
63
packages/twenty-website/src/app/developers/docs/layout.tsx
Normal file
63
packages/twenty-website/src/app/developers/docs/layout.tsx
Normal 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;
|
9
packages/twenty-website/src/app/developers/docs/page.tsx
Normal file
9
packages/twenty-website/src/app/developers/docs/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
const DeveloperDocs = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Developer Docs</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeveloperDocs;
|
@ -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;
|
9
packages/twenty-website/src/app/developers/page.tsx
Normal file
9
packages/twenty-website/src/app/developers/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
const Developers = () => {
|
||||
return (
|
||||
<div>
|
||||
<p>This page should probably be built on Framer</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Developers;
|
@ -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>;
|
||||
}
|
||||
|
@ -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 },
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
---
|
||||
title: Custom Objects
|
||||
sidebar_position: 1
|
||||
sidebar_custom_props:
|
||||
icon: TbAugmentedReality
|
||||
position: 1
|
||||
icon: IconAugmentedReality
|
||||
---
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'}}/>
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
@ -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!
|
||||
|
@ -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
|
||||
|
@ -1,8 +1,7 @@
|
||||
---
|
||||
title: Glossary
|
||||
sidebar_position: 3
|
||||
sidebar_custom_props:
|
||||
icon: TbVocabulary
|
||||
position: 3
|
||||
icon: IconVocabulary
|
||||
---
|
||||
|
||||
### Company & People
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user