Migrated Developer Docs (#5683)
- Migrated developer docs to Twenty website - Modified User Guide and Docs layout to include sections and subsections **Section Example:** <img width="549" alt="Screenshot 2024-05-30 at 15 44 42" src="https://github.com/twentyhq/twenty/assets/102751374/41bd4037-4b76-48e6-bc79-48d3d6be9ab8"> **Subsection Example:** <img width="557" alt="Screenshot 2024-05-30 at 15 44 55" src="https://github.com/twentyhq/twenty/assets/102751374/f14c65a9-ab0c-4530-b624-5b20fc00511a"> - Created different components (Tabs, Tables, Editors etc.) for the mdx files **Tabs & Editor** <img width="665" alt="Screenshot 2024-05-30 at 15 47 39" src="https://github.com/twentyhq/twenty/assets/102751374/5166b5c7-b6cf-417d-9f29-b1f674c1c531"> **Tables** <img width="698" alt="Screenshot 2024-05-30 at 15 57 39" src="https://github.com/twentyhq/twenty/assets/102751374/2bbfe937-ec19-4004-ab00-f7a56e96db4a"> <img width="661" alt="Screenshot 2024-05-30 at 16 03 32" src="https://github.com/twentyhq/twenty/assets/102751374/ae95b47c-dd92-44f9-b535-ccdc953f71ff"> - Created a crawler for Twenty Developers (now that it will be on the twenty website). Once this PR is merged and the website is re-deployed, we need to start crawling and make sure the index name is ‘twenty-developer’ - Added a dropdown menu in the header to access User Guide and Developers + added Developers to footer https://github.com/twentyhq/twenty/assets/102751374/1bd1fbbd-1e65-4461-b18b-84d4ddbb8ea1 - Made new layout responsive Please fill in the information for each mdx file so that it can appear on its card, as well as in the ‘In this article’ section. Example with ‘Getting Started’ in the User Guide: <img width="786" alt="Screenshot 2024-05-30 at 16 29 39" src="https://github.com/twentyhq/twenty/assets/102751374/2714b01d-a664-4ddc-9291-528632ee12ea"> Example with info and sectionInfo filled in for 'Getting Started': <img width="620" alt="Screenshot 2024-05-30 at 16 33 57" src="https://github.com/twentyhq/twenty/assets/102751374/bc69e880-da6a-4b7e-bace-1effea866c11"> Please keep in mind that the images that are being used for Developers are the same as those found in User Guide and may not match the article. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import MotionContainer from '@/app/_components/ui/layout/LoaderAnimation';
|
||||
@ -36,12 +37,6 @@ const AvatarItem = styled.div`
|
||||
box-shadow: -6px 6px 0px 1px rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.username {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@ -74,7 +69,12 @@ const AvatarGrid = ({ users }: { users: User[] }) => {
|
||||
{users.map((user) => (
|
||||
<Link href={`/contributors/${user.id}`} key={`l_${user.id}`}>
|
||||
<AvatarItem key={user.id}>
|
||||
<img src={user.avatarUrl} alt={user.id} />
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt={user.id}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<span className="username">{user.id}</span>
|
||||
</AvatarItem>
|
||||
</Link>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { format } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { GithubIcon } from '@/app/_components/ui/icons/SvgIcons';
|
||||
|
||||
@ -89,7 +90,7 @@ export const ProfileCard = ({
|
||||
return (
|
||||
<ProfileContainer>
|
||||
<Avatar>
|
||||
<img src={avatarUrl} alt={username} />
|
||||
<Image src={avatarUrl} alt={username} width={100} height={100} />
|
||||
</Avatar>
|
||||
<Details>
|
||||
<h3 className="username">
|
||||
|
||||
@ -7,7 +7,14 @@ interface AlgoliaHit extends StoredDocSearchHit {
|
||||
};
|
||||
}
|
||||
|
||||
export const AlgoliaDocSearch = () => {
|
||||
interface AlgoliaDocSearchProps {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export const AlgoliaDocSearch = ({ pathname }: AlgoliaDocSearchProps) => {
|
||||
const indexName = pathname.includes('user-guide')
|
||||
? 'user-guide'
|
||||
: 'developer';
|
||||
return (
|
||||
<DocSearch
|
||||
hitComponent={({ hit }: { hit: AlgoliaHit }) => (
|
||||
@ -42,7 +49,7 @@ export const AlgoliaDocSearch = () => {
|
||||
)}
|
||||
appId={process.env.NEXT_PUBLIC_ALGOLIA_APP_ID as string}
|
||||
apiKey={process.env.NEXT_PUBLIC_ALGOLIA_API_KEY as string}
|
||||
indexName="twenty-user-guide"
|
||||
indexName={`twenty-${indexName}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { getCardPath } from '@/shared-utils/getCardPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
color: ${Theme.border.color.plain};
|
||||
@ -46,23 +47,28 @@ const StyledSubHeading = styled.div`
|
||||
const StyledImage = styled.img`
|
||||
border-bottom: 1.5px solid #14141429;
|
||||
height: 160px;
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 6px;
|
||||
`;
|
||||
|
||||
export default function UserGuideCard({
|
||||
export default function DocsCard({
|
||||
card,
|
||||
isSection = false,
|
||||
}: {
|
||||
card: UserGuideArticlesProps;
|
||||
card: DocsArticlesProps;
|
||||
isSection?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<StyledContainer
|
||||
onClick={() => router.push(`/user-guide/${card.fileName}`)}
|
||||
>
|
||||
<StyledImage src={card.image} alt={card.title} />
|
||||
<StyledHeading>{card.title}</StyledHeading>
|
||||
<StyledSubHeading>{card.info}</StyledSubHeading>
|
||||
</StyledContainer>
|
||||
);
|
||||
const pathname = usePathname();
|
||||
const path = getCardPath(card, pathname, isSection);
|
||||
|
||||
if (card.title) {
|
||||
return (
|
||||
<StyledContainer onClick={() => router.push(path)}>
|
||||
<StyledImage src={card.image} alt={card.title} />
|
||||
<StyledHeading>{card.title}</StyledHeading>
|
||||
<StyledSubHeading>{card.info}</StyledSubHeading>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ArticleContent } from '@/app/_components/ui/layout/articles/ArticleContent';
|
||||
import { Breadcrumbs } from '@/app/_components/ui/layout/Breadcrumbs';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { FileContent } from '@/app/_server-utils/get-posts';
|
||||
import { getUriAndLabel } from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled('div')`
|
||||
${mq({
|
||||
@ -27,6 +29,7 @@ const StyledContainer = styled('div')`
|
||||
const StyledWrapper = styled.div`
|
||||
@media (max-width: 450px) {
|
||||
padding: ${Theme.spacing(10)} 30px ${Theme.spacing(20)};
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
@media (min-width: 451px) and (max-width: 800px) {
|
||||
@ -112,11 +115,14 @@ const StyledImageContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function UserGuideContent({ item }: { item: FileContent }) {
|
||||
export default function DocsContent({ item }: { item: FileContent }) {
|
||||
const pathname = usePathname();
|
||||
const { uri, label } = getUriAndLabel(pathname);
|
||||
|
||||
const BREADCRUMB_ITEMS = [
|
||||
{
|
||||
uri: '/user-guide',
|
||||
label: 'User Guide',
|
||||
uri: uri,
|
||||
label: label,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import DocsCard from '@/app/_components/docs/DocsCard';
|
||||
import { Breadcrumbs } from '@/app/_components/ui/layout/Breadcrumbs';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import UserGuideCard from '@/app/_components/user-guide/UserGuideCard';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { constructSections } from '@/shared-utils/constructSections';
|
||||
import { filterDocsIndex } from '@/shared-utils/filterDocsIndex';
|
||||
import { getUriAndLabel } from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
@ -26,12 +31,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
@media (max-width: 450px) {
|
||||
padding: ${Theme.spacing(10)} 30px ${Theme.spacing(20)};
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
padding: ${Theme.spacing(10)} 50px ${Theme.spacing(20)};
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
@ -45,12 +52,25 @@ const StyledTitle = styled.div`
|
||||
font-size: ${Theme.font.size.sm};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
width: 340px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSection = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@media (min-width: 801px) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -63,6 +83,10 @@ const StyledHeader = styled.div`
|
||||
width: 340px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@media (min-width: 450px) and (max-width: 800px) {
|
||||
margin-bottom: 24px;
|
||||
width: 340px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.h1`
|
||||
@ -71,6 +95,7 @@ const StyledHeading = styled.h1`
|
||||
font-size: 40px;
|
||||
color: ${Theme.text.color.primary};
|
||||
margin: 0px;
|
||||
margin-top: 32px;
|
||||
@media (max-width: 800px) {
|
||||
font-size: 28px;
|
||||
}
|
||||
@ -102,28 +127,60 @@ const StyledContent = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
interface UserGuideProps {
|
||||
userGuideArticleCards: UserGuideArticlesProps[];
|
||||
interface DocsProps {
|
||||
docsArticleCards: DocsArticlesProps[];
|
||||
isSection?: boolean;
|
||||
}
|
||||
|
||||
export default function UserGuideMain({
|
||||
userGuideArticleCards,
|
||||
}: UserGuideProps) {
|
||||
export default function DocsMain({
|
||||
docsArticleCards,
|
||||
isSection = false,
|
||||
}: DocsProps) {
|
||||
const sections = constructSections(docsArticleCards, isSection);
|
||||
const pathname = usePathname();
|
||||
const { uri, label } = getUriAndLabel(pathname);
|
||||
|
||||
const BREADCRUMB_ITEMS = [
|
||||
{
|
||||
uri: uri,
|
||||
label: label,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledWrapper>
|
||||
<StyledTitle>User Guide</StyledTitle>
|
||||
<StyledHeader>
|
||||
<StyledHeading>User Guide</StyledHeading>
|
||||
<StyledSubHeading>
|
||||
A brief guide to grasp the basics of Twenty
|
||||
</StyledSubHeading>
|
||||
</StyledHeader>
|
||||
<StyledContent>
|
||||
{userGuideArticleCards.map((card) => {
|
||||
return <UserGuideCard key={card.title} card={card} />;
|
||||
})}
|
||||
</StyledContent>
|
||||
{isSection ? (
|
||||
<Breadcrumbs
|
||||
items={BREADCRUMB_ITEMS}
|
||||
activePage={sections[0].name}
|
||||
separator="/"
|
||||
/>
|
||||
) : (
|
||||
<StyledTitle>{label}</StyledTitle>
|
||||
)}
|
||||
{sections.map((section, index) => {
|
||||
const filteredArticles = isSection
|
||||
? docsArticleCards
|
||||
: filterDocsIndex(docsArticleCards, section.name);
|
||||
return (
|
||||
<StyledSection key={index}>
|
||||
<StyledHeader>
|
||||
<StyledHeading>{section.name}</StyledHeading>
|
||||
<StyledSubHeading>{section.info}</StyledSubHeading>
|
||||
</StyledHeader>
|
||||
<StyledContent>
|
||||
{filteredArticles.map((card) => (
|
||||
<DocsCard
|
||||
key={card.title}
|
||||
card={card}
|
||||
isSection={isSection}
|
||||
/>
|
||||
))}
|
||||
</StyledContent>
|
||||
</StyledSection>
|
||||
);
|
||||
})}
|
||||
</StyledWrapper>
|
||||
</StyledContainer>
|
||||
);
|
||||
@ -3,11 +3,15 @@ import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import DocsSidebar from '@/app/_components/docs/DocsSideBar';
|
||||
import DocsTableContents from '@/app/_components/docs/TableContent';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import UserGuideTableContents from '@/app/_components/user-guide/TableContent';
|
||||
import UserGuideSidebar from '@/app/_components/user-guide/UserGuideSidebar';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import {
|
||||
isPlaygroundPage,
|
||||
shouldShowEmptySidebar,
|
||||
} from '@/shared-utils/pathUtils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
@ -24,22 +28,23 @@ const StyledEmptySideBar = styled.div`
|
||||
})};
|
||||
`;
|
||||
|
||||
export const UserGuideMainLayout = ({
|
||||
export const DocsMainLayout = ({
|
||||
children,
|
||||
userGuideIndex,
|
||||
docsIndex,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
docsIndex: DocsArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<UserGuideSidebar userGuideIndex={userGuideIndex} />
|
||||
{!isPlaygroundPage(pathname) && <DocsSidebar docsIndex={docsIndex} />}
|
||||
{children}
|
||||
{pathname === '/user-guide' ? (
|
||||
{shouldShowEmptySidebar(pathname) ? (
|
||||
<StyledEmptySideBar />
|
||||
) : (
|
||||
<UserGuideTableContents />
|
||||
<DocsTableContents />
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
118
packages/twenty-website/src/app/_components/docs/DocsSideBar.tsx
Normal file
118
packages/twenty-website/src/app/_components/docs/DocsSideBar.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { AlgoliaDocSearch } from '@/app/_components/docs/AlgoliaDocSearch';
|
||||
import DocsSidebarSection from '@/app/_components/docs/DocsSidebarSection';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { getSectionIcon } from '@/shared-utils/getSectionIcons';
|
||||
|
||||
import '@docsearch/css';
|
||||
import '../../user-guide/algolia.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
display: ['none', 'flex', 'flex'],
|
||||
flexDirection: 'column',
|
||||
background: `${Theme.background.secondary}`,
|
||||
borderRight: `1px solid ${Theme.background.transparent.medium}`,
|
||||
borderBottom: `1px solid ${Theme.background.transparent.medium}`,
|
||||
padding: `${Theme.spacing(10)} ${Theme.spacing(4)}`,
|
||||
gap: `${Theme.spacing(6)}`,
|
||||
})}
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
overflow: scroll;
|
||||
height: calc(100vh - 60px);
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
padding: ${Theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledHeadingText = styled.h1`
|
||||
cursor: pointer;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
`;
|
||||
|
||||
const DocsSidebar = ({ docsIndex }: { docsIndex: DocsArticlesProps[] }) => {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const path = pathName.includes('user-guide')
|
||||
? '/user-guide'
|
||||
: pathName.includes('developers')
|
||||
? '/developers'
|
||||
: '/twenty-ui';
|
||||
|
||||
const sections = Array.from(
|
||||
new Set(docsIndex.map((guide) => guide.section)),
|
||||
).map((section) => ({
|
||||
name: section,
|
||||
icon: getSectionIcon(section),
|
||||
guides: docsIndex.filter((guide) => {
|
||||
const isInSection = guide.section === section;
|
||||
const hasFiles = guide.numberOfFiles > 0;
|
||||
const isNotSingleFileTopic = !(
|
||||
guide.numberOfFiles > 1 && guide.topic === guide.title
|
||||
);
|
||||
|
||||
return isInSection && hasFiles && isNotSingleFileTopic;
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<AlgoliaDocSearch pathname={pathName} />
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<StyledHeading>
|
||||
<StyledIconContainer>{section.icon}</StyledIconContainer>
|
||||
<StyledHeadingText
|
||||
onClick={() =>
|
||||
router.push(
|
||||
section.name === 'User Guide'
|
||||
? '/user-guide'
|
||||
: section.name === 'Developers'
|
||||
? '/developers'
|
||||
: path,
|
||||
)
|
||||
}
|
||||
>
|
||||
{section.name}
|
||||
</StyledHeadingText>
|
||||
</StyledHeading>
|
||||
<DocsSidebarSection docsIndex={section.guides} />
|
||||
</div>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsSidebar;
|
||||
@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconPoint } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { IconChevronDown, IconChevronRight } from '@/app/_components/ui/icons';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { DocsArticlesProps } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { groupArticlesByTopic } from '@/content/user-guide/constants/groupArticlesByTopic';
|
||||
import { getCardPath } from '@/shared-utils/getCardPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledIndex = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
margin-top: 8px;
|
||||
padding-bottom: ${Theme.spacing(2)};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledSubTopicItem = styled.a<{ isselected: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: ${Theme.spacing(8)};
|
||||
color: ${(props) =>
|
||||
props.isselected ? Theme.text.color.primary : Theme.text.color.secondary};
|
||||
font-weight: ${(props) =>
|
||||
props.isselected ? Theme.font.weight.medium : Theme.font.weight.regular};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
gap: 19px;
|
||||
padding: ${(props) =>
|
||||
props.isselected ? '6px 12px 6px 11px' : '0px 12px 0px 11px'};
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.background.transparent.light
|
||||
: Theme.background.secondary};
|
||||
border-radius: ${Theme.border.radius.md};
|
||||
text-decoration: none;
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:visited,
|
||||
&:link,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1414140a;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #1414140f;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
padding: 0px 4px 0px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
margin-top: 3px;
|
||||
margin-left: -8px;
|
||||
color: ${Theme.color.gray30};
|
||||
`;
|
||||
|
||||
const StyledCardTitle = styled.p`
|
||||
margin: 0px -5px;
|
||||
color: ${Theme.color.gray30};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledRectangle = styled.div<{ isselected: boolean; isHovered: boolean }>`
|
||||
height: ${(props) =>
|
||||
props.isselected ? '95%' : props.isHovered ? '70%' : '100%'};
|
||||
width: 2px;
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.border.color.plain
|
||||
: props.isHovered
|
||||
? Theme.background.transparent.strong
|
||||
: Theme.background.transparent.light};
|
||||
transition: height 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface TopicsState {
|
||||
[topic: string]: boolean;
|
||||
}
|
||||
|
||||
const DocsSidebarSection = ({
|
||||
docsIndex,
|
||||
}: {
|
||||
docsIndex: DocsArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const topics = groupArticlesByTopic(docsIndex);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const path = pathname.includes('user-guide')
|
||||
? '/user-guide/'
|
||||
: pathname.includes('developers')
|
||||
? '/developers/'
|
||||
: '/twenty-ui';
|
||||
|
||||
const initializeUnfoldedState = () => {
|
||||
const unfoldedState: TopicsState = {};
|
||||
Object.keys(topics).forEach((topic) => {
|
||||
const containsCurrentArticle = topics[topic].some((card) => {
|
||||
const topicPath = card.topic.toLowerCase().replace(/\s+/g, '-');
|
||||
return pathname.includes(topicPath);
|
||||
});
|
||||
unfoldedState[topic] = containsCurrentArticle;
|
||||
});
|
||||
return unfoldedState;
|
||||
};
|
||||
|
||||
const [unfolded, setUnfolded] = useState<TopicsState>(
|
||||
initializeUnfoldedState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newUnfoldedState = initializeUnfoldedState();
|
||||
setUnfolded(newUnfoldedState);
|
||||
}, [pathname]);
|
||||
|
||||
const toggleFold = (topic: string) => {
|
||||
setUnfolded((prev: TopicsState) => ({ ...prev, [topic]: !prev[topic] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{Object.entries(topics).map(([topic, cards]) => {
|
||||
const hasMultipleFiles = cards.some((card) => card.numberOfFiles > 1);
|
||||
|
||||
return (
|
||||
<StyledIndex key={topic}>
|
||||
{hasMultipleFiles ? (
|
||||
<StyledTitle onClick={() => toggleFold(topic)}>
|
||||
{unfolded[topic] ? (
|
||||
<StyledIcon>
|
||||
<IconChevronDown size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon>
|
||||
<IconChevronRight size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<div>{topic}</div>
|
||||
</StyledTitle>
|
||||
) : null}
|
||||
|
||||
{(unfolded[topic] || !hasMultipleFiles) &&
|
||||
cards.map((card) => {
|
||||
const isselected = pathname === `${path}${card.fileName}`;
|
||||
const sectionName = card.topic
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
const routerPath = getCardPath(card, path, false, sectionName);
|
||||
return (
|
||||
<StyledSubTopicItem
|
||||
key={card.title}
|
||||
isselected={isselected}
|
||||
href={routerPath}
|
||||
onClick={() => router.push(routerPath)}
|
||||
onMouseEnter={() => setHoveredItem(card.title)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
{card.numberOfFiles > 1 ? (
|
||||
<>
|
||||
<StyledRectangle
|
||||
isselected={isselected}
|
||||
isHovered={hoveredItem === card.title}
|
||||
/>
|
||||
{card.title}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StyledIconContainer>
|
||||
<IconPoint size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledCardTitle>{card.title}</StyledCardTitle>
|
||||
</>
|
||||
)}
|
||||
</StyledSubTopicItem>
|
||||
);
|
||||
})}
|
||||
</StyledIndex>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsSidebarSection;
|
||||
@ -88,7 +88,7 @@ interface HeadingType {
|
||||
level: number;
|
||||
}
|
||||
|
||||
const UserGuideTableContents = () => {
|
||||
const DocsTableContents = () => {
|
||||
const [headings, setHeadings] = useState<HeadingType[]>([]);
|
||||
const pathname = usePathname();
|
||||
const { activeText } = useHeadsObserver(pathname);
|
||||
@ -146,4 +146,4 @@ const UserGuideTableContents = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideTableContents;
|
||||
export default DocsTableContents;
|
||||
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { explorerPlugin } from '@graphiql/plugin-explorer';
|
||||
import { Theme, useTheme } from '@graphiql/react';
|
||||
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
||||
import { GraphiQL } from 'graphiql';
|
||||
|
||||
import { SubDoc } from '@/app/_components/playground/token-form';
|
||||
|
||||
import Playground from './playground';
|
||||
|
||||
const SubDocToPath = {
|
||||
core: 'graphql',
|
||||
metadata: 'metadata',
|
||||
};
|
||||
|
||||
const GraphQlComponent = ({ token, baseUrl, path }: any) => {
|
||||
const explorer = explorerPlugin({
|
||||
showAttribution: true,
|
||||
});
|
||||
|
||||
const fetcher = createGraphiQLFetcher({
|
||||
url: baseUrl + '/' + path,
|
||||
});
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fullHeightPlayground">
|
||||
<GraphiQL
|
||||
plugins={[explorer]}
|
||||
fetcher={fetcher}
|
||||
defaultHeaders={JSON.stringify({ Authorization: `Bearer ${token}` })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GraphQlPlayground = ({ subDoc }: { subDoc: SubDoc }) => {
|
||||
const [token, setToken] = useState<string>();
|
||||
const [baseUrl, setBaseUrl] = useState<string>();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'graphiql:theme',
|
||||
window.localStorage.getItem('theme') || 'light',
|
||||
);
|
||||
|
||||
const handleThemeChange = (ev: any) => {
|
||||
if (ev.key === 'theme') {
|
||||
setTheme(ev.newValue as Theme);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleThemeChange);
|
||||
|
||||
return () => window.removeEventListener('storage', handleThemeChange);
|
||||
}, []);
|
||||
|
||||
const children = (
|
||||
<GraphQlComponent
|
||||
token={token}
|
||||
baseUrl={baseUrl}
|
||||
path={SubDocToPath[subDoc]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<Playground
|
||||
children={children}
|
||||
setToken={setToken}
|
||||
setBaseUrl={setBaseUrl}
|
||||
subDoc={subDoc}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default GraphQlPlayground;
|
||||
@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import { TbLoader2 } from 'react-icons/tb';
|
||||
|
||||
import TokenForm, { TokenFormProps } from './token-form';
|
||||
|
||||
const Playground = ({
|
||||
children,
|
||||
setOpenApiJson,
|
||||
setToken,
|
||||
setBaseUrl,
|
||||
subDoc,
|
||||
}: Partial<React.PropsWithChildren> &
|
||||
Omit<
|
||||
TokenFormProps,
|
||||
'isTokenValid' | 'setIsTokenValid' | 'setLoadingState'
|
||||
>) => {
|
||||
const [isTokenValid, setIsTokenValid] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
paddingTop: 15,
|
||||
}}
|
||||
>
|
||||
<TokenForm
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
setToken={setToken}
|
||||
setBaseUrl={setBaseUrl}
|
||||
isTokenValid={isTokenValid}
|
||||
setIsTokenValid={setIsTokenValid}
|
||||
subDoc={subDoc}
|
||||
setLoadingState={setIsLoading}
|
||||
/>
|
||||
{!isTokenValid && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
background: 'rgba(23,23,23, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '50%',
|
||||
background: 'rgba(23,23,23, 0.8)',
|
||||
color: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
A token is required as APIs are dynamically generated for each
|
||||
workspace based on their unique metadata. <br /> Generate your token
|
||||
under{' '}
|
||||
<a
|
||||
className="link"
|
||||
href="https://app.twenty.com/settings/developers"
|
||||
>
|
||||
Settings > Developers
|
||||
</a>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="loader-container">
|
||||
<TbLoader2 className="loader" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playground;
|
||||
@ -0,0 +1,146 @@
|
||||
.form-container {
|
||||
height: 45px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--ifm-color-secondary-light);
|
||||
position: sticky;
|
||||
top: var(--ifm-navbar-height) + 10;
|
||||
padding: 0px 8px;
|
||||
background: var(--ifm-color-secondary-contrast-background);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
height: 45px;
|
||||
gap: 10px;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
flex: 0.7;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 6px;
|
||||
margin: 5px 0 5px 0;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding-left:30px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.input[disabled] {
|
||||
color: rgb(153, 153, 153)
|
||||
}
|
||||
|
||||
[data-theme='dark'] .input {
|
||||
background-color: #16233f;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
padding: 5px;
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .inputIcon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 6px;
|
||||
margin: 5px 0 5px 0;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #f3f3f3;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .select {
|
||||
background-color: #16233f;
|
||||
}
|
||||
|
||||
|
||||
.invalid {
|
||||
border: 1px solid #f83e3e;
|
||||
}
|
||||
|
||||
.token-invalid {
|
||||
color: #f83e3e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.not-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: #16233f;
|
||||
font-size: 2rem;
|
||||
animation: animate 2s infinite;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .loader {
|
||||
color: #a3c0f8;
|
||||
}
|
||||
|
||||
@keyframes animate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
|
||||
.backButton {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
left: 8px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #999999;
|
||||
|
||||
&:hover {
|
||||
color: #16233f;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TbApi, TbChevronLeft, TbLink } from 'react-icons/tb';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
// @ts-expect-error Migration loader as text not passing warnings
|
||||
import tokenForm from '!css-loader!./token-form.css';
|
||||
|
||||
export type SubDoc = 'core' | 'metadata';
|
||||
export type TokenFormProps = {
|
||||
setOpenApiJson?: (json: object) => void;
|
||||
setToken?: (token: string) => void;
|
||||
setBaseUrl?: (baseUrl: string) => void;
|
||||
isTokenValid?: boolean;
|
||||
setIsTokenValid?: (arg: boolean) => void;
|
||||
setLoadingState?: (arg: boolean) => void;
|
||||
subDoc?: SubDoc;
|
||||
};
|
||||
|
||||
const TokenForm = ({
|
||||
setOpenApiJson,
|
||||
setToken,
|
||||
setBaseUrl: submitBaseUrl,
|
||||
isTokenValid,
|
||||
setIsTokenValid,
|
||||
subDoc,
|
||||
setLoadingState,
|
||||
}: TokenFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [locationSetting, setLocationSetting] = useState(
|
||||
(window &&
|
||||
window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')
|
||||
?.locationSetting) ??
|
||||
'production',
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
(window.localStorage.getItem('baseUrl') &&
|
||||
JSON.parse(window.localStorage.getItem('baseUrl') ?? '')?.baseUrl) ??
|
||||
'https://api.twenty.com',
|
||||
);
|
||||
|
||||
const tokenLocal = window?.localStorage?.getItem?.(
|
||||
'TryIt_securitySchemeValues',
|
||||
) as string;
|
||||
|
||||
const token = JSON.parse(tokenLocal)?.bearerAuth ?? '';
|
||||
|
||||
const updateLoading = (loading = false) => {
|
||||
setIsLoading(loading);
|
||||
setLoadingState?.(!!loading);
|
||||
};
|
||||
|
||||
const updateToken = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
window.localStorage.setItem(
|
||||
'TryIt_securitySchemeValues',
|
||||
JSON.stringify({ bearerAuth: event.target.value }),
|
||||
);
|
||||
await submitToken(event.target.value);
|
||||
};
|
||||
|
||||
const updateBaseUrl = (baseUrl: string, locationSetting: string) => {
|
||||
let url: string;
|
||||
if (locationSetting === 'production') {
|
||||
url = 'https://api.twenty.com';
|
||||
} else if (locationSetting === 'demo') {
|
||||
url = 'https://api-demo.twenty.com';
|
||||
} else if (locationSetting === 'localhost') {
|
||||
url = 'http://localhost:3000';
|
||||
} else {
|
||||
url = baseUrl?.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
}
|
||||
|
||||
setBaseUrl(url);
|
||||
setLocationSetting(locationSetting);
|
||||
submitBaseUrl?.(url);
|
||||
window.localStorage.setItem(
|
||||
'baseUrl',
|
||||
JSON.stringify({ baseUrl: url, locationSetting }),
|
||||
);
|
||||
};
|
||||
|
||||
const validateToken = (openApiJson: any) => {
|
||||
setIsTokenValid?.(!!openApiJson.tags);
|
||||
};
|
||||
|
||||
const getJson = async (token: string) => {
|
||||
updateLoading(true);
|
||||
|
||||
return await fetch(baseUrl + '/open-api/' + (subDoc ?? 'core'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
validateToken(result);
|
||||
updateLoading(false);
|
||||
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
updateLoading(false);
|
||||
setIsTokenValid?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
const submitToken = async (token: any) => {
|
||||
if (isLoading) return;
|
||||
|
||||
const json = await getJson(token);
|
||||
|
||||
setToken && setToken(token);
|
||||
|
||||
setOpenApiJson && setOpenApiJson(json);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
updateBaseUrl(baseUrl, locationSetting);
|
||||
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 (
|
||||
<div className="form-container">
|
||||
<form className="form">
|
||||
<div className="backButton" onClick={() => router.back()}>
|
||||
<TbChevronLeft size={18} />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<select
|
||||
className="select"
|
||||
onChange={(event) => {
|
||||
updateBaseUrl(baseUrl, event.target.value);
|
||||
}}
|
||||
value={locationSetting}
|
||||
>
|
||||
<option value="production">Production API</option>
|
||||
<option value="demo">Demo API</option>
|
||||
<option value="localhost">Localhost</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<div className="inputIcon" title="Base URL">
|
||||
<TbLink size={20} />
|
||||
</div>
|
||||
<input
|
||||
className={'input'}
|
||||
type="text"
|
||||
readOnly={isLoading}
|
||||
disabled={locationSetting !== 'other'}
|
||||
placeholder="Base URL"
|
||||
value={baseUrl}
|
||||
onChange={(event) =>
|
||||
updateBaseUrl(event.target.value, locationSetting)
|
||||
}
|
||||
onBlur={() => submitToken(token)}
|
||||
/>
|
||||
</div>
|
||||
<div className="inputWrapper">
|
||||
<div className="inputIcon" title="Api Key">
|
||||
<TbApi size={20} />
|
||||
</div>
|
||||
<input
|
||||
className={!isTokenValid && !isLoading ? 'input invalid' : 'input'}
|
||||
type="text"
|
||||
readOnly={isLoading}
|
||||
placeholder="API Key"
|
||||
defaultValue={token}
|
||||
onChange={updateToken}
|
||||
/>
|
||||
</div>
|
||||
<div className="inputWrapper" style={{ maxWidth: '100px' }}>
|
||||
<select
|
||||
className="select"
|
||||
onChange={(event) =>
|
||||
router.replace(
|
||||
'/' + pathname.split('/').at(-2) + '/' + event.target.value,
|
||||
)
|
||||
}
|
||||
value={pathname.split('/').at(-1)}
|
||||
>
|
||||
<option value="core">Core</option>
|
||||
<option value="metadata">Metadata</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenForm;
|
||||
@ -65,6 +65,7 @@ interface BreadcrumbsProps {
|
||||
}[];
|
||||
activePage: string;
|
||||
separator: string;
|
||||
style?: boolean;
|
||||
}
|
||||
|
||||
export const Breadcrumbs = ({
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import {
|
||||
DiscordIcon,
|
||||
@ -67,11 +66,6 @@ const RightSideFooterColumnTitle = styled.div`
|
||||
`;
|
||||
|
||||
export const FooterDesktop = () => {
|
||||
const path = usePathname();
|
||||
const isTwentyDev = path.includes('developers');
|
||||
|
||||
if (isTwentyDev) return;
|
||||
|
||||
return (
|
||||
<FooterContainer>
|
||||
<div
|
||||
@ -95,8 +89,8 @@ export const FooterDesktop = () => {
|
||||
</RightSideFooterColumn>
|
||||
<RightSideFooterColumn>
|
||||
<RightSideFooterColumnTitle>Resources</RightSideFooterColumnTitle>
|
||||
<RightSideFooterLink href="https://docs.twenty.com">
|
||||
Documentation
|
||||
<RightSideFooterLink href="/developers">
|
||||
Developers
|
||||
</RightSideFooterLink>
|
||||
<RightSideFooterLink href="/releases">
|
||||
Changelog
|
||||
|
||||
@ -10,6 +10,16 @@ const StyledContent = styled.div`
|
||||
flex: 1;
|
||||
max-width: 950px;
|
||||
|
||||
code {
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${Theme.text.color.secondary};
|
||||
font-family: ${Theme.font.family};
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 32px;
|
||||
`;
|
||||
|
||||
const StyledTable = styled.table`
|
||||
width: fit-content;
|
||||
margin-top: 32px;
|
||||
border-collapse: collapse;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.th`
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-collapse: collapse;
|
||||
background-color: #f1f1f1;
|
||||
`;
|
||||
|
||||
const StyledTableRow = styled.tr<{ isEven: boolean }>`
|
||||
background-color: ${(props) => (props.isEven ? '#f1f1f1' : 'transparent')};
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.td`
|
||||
border: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
padding: 20px 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledVariable = styled.td`
|
||||
border: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
interface ArticleTableProps {
|
||||
options: [string, string, string, string][];
|
||||
}
|
||||
|
||||
const OptionTable = ({ options }: ArticleTableProps) => {
|
||||
let display = true;
|
||||
if (!options[0][3]) {
|
||||
display = false;
|
||||
}
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<StyledTableHeader>Props</StyledTableHeader>
|
||||
<StyledTableHeader>Type</StyledTableHeader>
|
||||
<StyledTableHeader>Description</StyledTableHeader>
|
||||
{display ? <StyledTableHeader>Default</StyledTableHeader> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map(([props, type, description, defaultValue], index) => (
|
||||
<StyledTableRow key={index} isEven={index % 2 === 1}>
|
||||
<StyledVariable>{props}</StyledVariable>
|
||||
<StyledVariable>{type}</StyledVariable>
|
||||
<StyledDescription>{description}</StyledDescription>
|
||||
{display ? <StyledVariable>{defaultValue}</StyledVariable> : null}
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
OptionTable.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired,
|
||||
};
|
||||
|
||||
export default OptionTable;
|
||||
@ -0,0 +1,5 @@
|
||||
const TabItem = ({ children }: any) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
export default TabItem;
|
||||
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const StyledTableContainer = styled.div`
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 32px;
|
||||
`;
|
||||
|
||||
const StyledTable = styled.table`
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
`;
|
||||
|
||||
const StyledTableHeader = styled.th`
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
`;
|
||||
|
||||
const StyledDescription = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
padding: 10px 0px 10px 20px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledExample = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-right: 20px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledVariable = styled.td`
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-right: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #538ce9;
|
||||
`;
|
||||
|
||||
const StyledTableRow = styled.tr<{ isEven: boolean }>`
|
||||
background-color: ${(props) => (props.isEven ? '#f1f1f1' : 'transparent')};
|
||||
`;
|
||||
|
||||
interface ArticleTableProps {
|
||||
options: [string, string, string][];
|
||||
}
|
||||
|
||||
const OptionTable = ({ options }: ArticleTableProps) => {
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<StyledTableHeader>Variable</StyledTableHeader>
|
||||
<StyledTableHeader>Example</StyledTableHeader>
|
||||
<StyledTableHeader>Description</StyledTableHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{options.map(([variable, defaultValue, description], index) => (
|
||||
<StyledTableRow key={index} isEven={index % 2 === 1}>
|
||||
<StyledVariable>{variable}</StyledVariable>
|
||||
<StyledExample>{defaultValue}</StyledExample>
|
||||
<StyledDescription>{description}</StyledDescription>
|
||||
</StyledTableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
OptionTable.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired,
|
||||
};
|
||||
|
||||
export default OptionTable;
|
||||
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
width: 80%;
|
||||
overflow: none;
|
||||
`;
|
||||
|
||||
const StyledTab = styled.div<{ active: boolean }>`
|
||||
padding: 10px 20px;
|
||||
border-bottom: 2px solid ${(props) => (props.active ? '#000' : 'transparent')};
|
||||
font-weight: ${(props) => (props.active ? 'bold' : 'normal')};
|
||||
`;
|
||||
|
||||
interface ArticleTabsProps {
|
||||
children: any;
|
||||
label1: string;
|
||||
label2: string;
|
||||
label3?: string;
|
||||
}
|
||||
|
||||
const Tabs = ({ children, label1, label2, label3 }: ArticleTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const labels = label3 ? [label1, label2, label3] : [label1, label2];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledContainer>
|
||||
{labels.map((label, index) => {
|
||||
return (
|
||||
<StyledTab
|
||||
onClick={() => setActiveTab(index)}
|
||||
key={label}
|
||||
active={activeTab === index}
|
||||
>
|
||||
{label}
|
||||
</StyledTab>
|
||||
);
|
||||
})}
|
||||
</StyledContainer>
|
||||
<div>{children[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
import {
|
||||
SandpackCodeEditor,
|
||||
SandpackLayout,
|
||||
SandpackProvider,
|
||||
} from '@codesandbox/sandpack-react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const Sandpack = styled.div`
|
||||
max-width: 600px;
|
||||
`;
|
||||
|
||||
const SandpackContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
interface SandpackEditorProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function SandpackEditor({ content }: SandpackEditorProps) {
|
||||
return (
|
||||
<Sandpack>
|
||||
<SandpackProvider
|
||||
template="react"
|
||||
files={{
|
||||
'/App.js': `${content}`,
|
||||
}}
|
||||
>
|
||||
<SandpackLayout>
|
||||
<SandpackContainer>
|
||||
<SandpackCodeEditor showTabs showInlineErrors wrapContent />
|
||||
</SandpackContainer>
|
||||
</SandpackLayout>
|
||||
</SandpackProvider>
|
||||
</Sandpack>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconBook, IconChevronDown, IconRobotFace } from '@tabler/icons-react';
|
||||
|
||||
import { ExternalArrow, GithubIcon } from '@/app/_components/ui/icons/SvgIcons';
|
||||
import { CallToAction } from '@/app/_components/ui/layout/header/callToAction';
|
||||
@ -11,13 +13,106 @@ import {
|
||||
LogoContainer,
|
||||
} from '@/app/_components/ui/layout/header/styled';
|
||||
import { Logo } from '@/app/_components/ui/layout/Logo';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { formatNumberOfStars } from '@/shared-utils/formatNumberOfStars';
|
||||
|
||||
const DropdownMenu = styled.ul<{ open: boolean }>`
|
||||
display: ${(props) => (props.open ? 'block' : 'none')};
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
background: white;
|
||||
border: 1px solid black;
|
||||
border-radius: 8px;
|
||||
padding: 2px 0;
|
||||
margin: 4px 0px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const DropdownItem = styled.a`
|
||||
color: rgb(71, 71, 71);
|
||||
text-decoration: none;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
margin: 0px 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
&:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = styled.div`
|
||||
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;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
const StyledChevron = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
color: rgb(179, 179, 179);
|
||||
`;
|
||||
|
||||
const Arrow = styled.div<{ open: boolean }>`
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
transition: transform 0.3s;
|
||||
transform: ${(props) => (props.open ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
numberOfStars: number;
|
||||
};
|
||||
|
||||
export const HeaderDesktop = ({ numberOfStars }: Props) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setDropdownOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<DesktopNav>
|
||||
<LogoContainer>
|
||||
@ -27,9 +122,32 @@ export const HeaderDesktop = ({ numberOfStars }: Props) => {
|
||||
<ListItem href="/story">Story</ListItem>
|
||||
<ListItem href="/pricing">Pricing</ListItem>
|
||||
<ListItem href="/releases">Releases</ListItem>
|
||||
<ListItem href="https://docs.twenty.com">
|
||||
Docs <ExternalArrow />
|
||||
</ListItem>
|
||||
<Dropdown
|
||||
ref={dropdownRef}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
Docs
|
||||
<Arrow open={dropdownOpen}>
|
||||
<StyledChevron>
|
||||
<IconChevronDown size={Theme.icon.size.sm} />
|
||||
</StyledChevron>
|
||||
</Arrow>
|
||||
<DropdownMenu open={dropdownOpen}>
|
||||
<DropdownItem href="/user-guide">
|
||||
<StyledIconContainer>
|
||||
<IconBook size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
User Guide
|
||||
</DropdownItem>
|
||||
<DropdownItem href="/developers">
|
||||
<StyledIconContainer>
|
||||
<IconRobotFace size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
Developers
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
<ListItem href="https://github.com/twentyhq/twenty">
|
||||
<GithubIcon color="rgb(71,71,71)" />
|
||||
{formatNumberOfStars(numberOfStars)}
|
||||
|
||||
@ -65,9 +65,8 @@ export const HeaderMobile = ({ numberOfStars }: Props) => {
|
||||
<ListItem href="/story">Story</ListItem>
|
||||
<ListItem href="/pricing">Pricing</ListItem>
|
||||
<ListItem href="/releases">Releases</ListItem>
|
||||
<ListItem href="https://docs.twenty.com">
|
||||
Docs <ExternalArrow />
|
||||
</ListItem>
|
||||
<ListItem href="/user-guide">User Guide</ListItem>
|
||||
<ListItem href="/developers">Developers</ListItem>
|
||||
<ListItem href="https://github.com/twentyhq/twenty">
|
||||
<GithubIcon color="rgb(71,71,71)" />{' '}
|
||||
{formatNumberOfStars(numberOfStars)} <ExternalArrow />
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import styled from '@emotion/styled';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { IconBook } from '@/app/_components/ui/icons';
|
||||
import mq from '@/app/_components/ui/theme/mq';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { AlgoliaDocSearch } from '@/app/_components/user-guide/AlgoliaDocSearch';
|
||||
import UserGuideSidebarSection from '@/app/_components/user-guide/UserGuideSidebarSection';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
|
||||
import '@docsearch/css';
|
||||
import '../../user-guide/algolia.css';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${mq({
|
||||
display: ['none', 'flex', 'flex'],
|
||||
flexDirection: 'column',
|
||||
background: `${Theme.background.secondary}`,
|
||||
borderRight: `1px solid ${Theme.background.transparent.medium}`,
|
||||
borderBottom: `1px solid ${Theme.background.transparent.medium}`,
|
||||
padding: `${Theme.spacing(10)} ${Theme.spacing(4)}`,
|
||||
gap: `${Theme.spacing(6)}`,
|
||||
})}
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
overflow: scroll;
|
||||
height: calc(100vh - 60px);
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
border: 1px solid ${Theme.text.color.secondary};
|
||||
border-radius: ${Theme.border.radius.sm};
|
||||
padding: ${Theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledHeadingText = styled.div`
|
||||
cursor: pointer;
|
||||
font-size: ${Theme.font.size.sm};
|
||||
font-weight: ${Theme.font.weight.medium};
|
||||
color: ${Theme.text.color.secondary};
|
||||
`;
|
||||
|
||||
const UserGuideSidebar = ({
|
||||
userGuideIndex,
|
||||
}: {
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<AlgoliaDocSearch />
|
||||
<StyledHeading>
|
||||
<StyledIconContainer>
|
||||
<IconBook size={Theme.icon.size.md} />
|
||||
</StyledIconContainer>
|
||||
<StyledHeadingText onClick={() => router.push('/user-guide')}>
|
||||
User Guide
|
||||
</StyledHeadingText>
|
||||
</StyledHeading>
|
||||
<UserGuideSidebarSection userGuideIndex={userGuideIndex} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideSidebar;
|
||||
@ -1,157 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { IconChevronDown, IconChevronRight } from '@/app/_components/ui/icons';
|
||||
import { Theme } from '@/app/_components/ui/theme/theme';
|
||||
import { UserGuideArticlesProps } from '@/content/user-guide/constants/getUserGuideArticles';
|
||||
import { groupArticlesByTopic } from '@/content/user-guide/constants/groupArticlesByTopic';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledIndex = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${Theme.spacing(2)};
|
||||
color: ${Theme.text.color.quarternary};
|
||||
padding-bottom: ${Theme.spacing(2)};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const StyledSubTopicItem = styled.a<{ isselected: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: ${Theme.spacing(8)};
|
||||
color: ${(props) =>
|
||||
props.isselected ? Theme.text.color.primary : Theme.text.color.secondary};
|
||||
font-weight: ${(props) =>
|
||||
props.isselected ? Theme.font.weight.medium : Theme.font.weight.regular};
|
||||
font-family: ${Theme.font.family};
|
||||
font-size: ${Theme.font.size.xs};
|
||||
gap: 19px;
|
||||
padding: ${(props) =>
|
||||
props.isselected ? '6px 12px 6px 11px' : '0px 12px 0px 11px'};
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.background.transparent.light
|
||||
: Theme.background.secondary};
|
||||
border-radius: ${Theme.border.radius.md};
|
||||
text-decoration: none;
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:visited,
|
||||
&:link,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1414140a;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #1414140f;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
padding: 0px 4px 0px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledRectangle = styled.div<{ isselected: boolean; isHovered: boolean }>`
|
||||
height: ${(props) =>
|
||||
props.isselected ? '95%' : props.isHovered ? '70%' : '100%'};
|
||||
width: 2px;
|
||||
background: ${(props) =>
|
||||
props.isselected
|
||||
? Theme.border.color.plain
|
||||
: props.isHovered
|
||||
? Theme.background.transparent.strong
|
||||
: Theme.background.transparent.light};
|
||||
transition: height 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface TopicsState {
|
||||
[topic: string]: boolean;
|
||||
}
|
||||
|
||||
const UserGuideSidebarSection = ({
|
||||
userGuideIndex,
|
||||
}: {
|
||||
userGuideIndex: UserGuideArticlesProps[];
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const topics = groupArticlesByTopic(userGuideIndex);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const [unfolded, setUnfolded] = useState<TopicsState>(() =>
|
||||
Object.keys(topics).reduce((acc: TopicsState, topic: string) => {
|
||||
acc[topic] = true;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const toggleFold = (topic: string) => {
|
||||
setUnfolded((prev: TopicsState) => ({ ...prev, [topic]: !prev[topic] }));
|
||||
};
|
||||
return (
|
||||
<StyledContainer>
|
||||
{Object.entries(topics).map(([topic, cards]) => (
|
||||
<StyledIndex key={topic}>
|
||||
<StyledTitle onClick={() => toggleFold(topic)}>
|
||||
{unfolded[topic] ? (
|
||||
<StyledIcon>
|
||||
<IconChevronDown size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon>
|
||||
<IconChevronRight size={Theme.icon.size.md} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<div>{topic}</div>
|
||||
</StyledTitle>
|
||||
{unfolded[topic] &&
|
||||
cards.map((card) => {
|
||||
const isselected = pathname === `/user-guide/${card.fileName}`;
|
||||
return (
|
||||
<StyledSubTopicItem
|
||||
key={card.title}
|
||||
isselected={isselected}
|
||||
href={`/user-guide/${card.fileName}`}
|
||||
onClick={() => router.push(`/user-guide/${card.fileName}`)}
|
||||
onMouseEnter={() => setHoveredItem(card.title)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
<StyledRectangle
|
||||
isselected={isselected}
|
||||
isHovered={hoveredItem === card.title}
|
||||
/>
|
||||
{card.title}
|
||||
</StyledSubTopicItem>
|
||||
);
|
||||
})}
|
||||
</StyledIndex>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserGuideSidebarSection;
|
||||
Reference in New Issue
Block a user