Integrate Keystatic to edit twenty.com content (#10709)
This PR introduces Keystatic to let us edit twenty.com's content with a CMS. For now, we'll focus on creating release notes through Keystatic as it uses quite simple Markdown. Other types of content will need some refactoring to work with Keystatic. https://github.com/user-attachments/assets/e9f85bbf-daff-4b41-bc97-d1baf63758b2 --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
committed by
GitHub
parent
6b4d3ed025
commit
2c465bd42e
@ -0,0 +1,84 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { getContributorActivity } from '@/app/(public)/contributors/utils/get-contributor-activity';
|
||||
import { ActivityLog } from '@/app/_components/contributors/ActivityLog';
|
||||
import { Breadcrumb } from '@/app/_components/contributors/Breadcrumb';
|
||||
import { ContentContainer } from '@/app/_components/contributors/ContentContainer';
|
||||
import { ProfileCard } from '@/app/_components/contributors/ProfileCard';
|
||||
import { ProfileInfo } from '@/app/_components/contributors/ProfileInfo';
|
||||
import { ProfileSharing } from '@/app/_components/contributors/ProfileSharing';
|
||||
import { PullRequests } from '@/app/_components/contributors/PullRequests';
|
||||
import { ThankYou } from '@/app/_components/contributors/ThankYou';
|
||||
import { Background } from '@/app/_components/oss-friends/Background';
|
||||
|
||||
export function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Metadata {
|
||||
return {
|
||||
metadataBase: new URL(`https://twenty.com`),
|
||||
title: 'Twenty - ' + params.slug,
|
||||
description:
|
||||
'Explore the impactful contributions of ' +
|
||||
params.slug +
|
||||
' on the Twenty Github Repo. Discover their merged pull requests, ongoing work, and top ranking. Join and contribute to the #1 Open-Source CRM thriving community!',
|
||||
openGraph: {
|
||||
images: [`https://twenty.com/api/contributors/${params.slug}/og.png`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ({ params }: { params: { slug: string } }) {
|
||||
try {
|
||||
const contributorActivity = await getContributorActivity(params.slug);
|
||||
if (contributorActivity) {
|
||||
const {
|
||||
firstContributionAt,
|
||||
mergedPRsCount,
|
||||
rank,
|
||||
activeDays,
|
||||
pullRequestActivityArray,
|
||||
contributorPullRequests,
|
||||
contributor,
|
||||
} = contributorActivity;
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ContentContainer>
|
||||
<Breadcrumb active={contributor.id} />
|
||||
<ProfileCard
|
||||
username={contributor.id}
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
firstContributionAt={firstContributionAt}
|
||||
/>
|
||||
<ProfileInfo
|
||||
mergedPRsCount={mergedPRsCount}
|
||||
rank={rank}
|
||||
activeDays={activeDays}
|
||||
/>
|
||||
<ProfileSharing username={contributor.id} />
|
||||
<ActivityLog data={pullRequestActivityArray} />
|
||||
<PullRequests
|
||||
list={
|
||||
contributorPullRequests.slice(0, 9) as {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
mergedAt: string | null;
|
||||
authorId: string;
|
||||
}[]
|
||||
}
|
||||
/>
|
||||
<ThankYou username={contributor.id} />
|
||||
</ContentContainer>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error: ', error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import AvatarGrid from '@/app/_components/contributors/AvatarGrid';
|
||||
import { Header } from '@/app/_components/contributors/Header';
|
||||
import { Background } from '@/app/_components/oss-friends/Background';
|
||||
import { ContentContainer } from '@/app/_components/oss-friends/ContentContainer';
|
||||
import { findAll } from '@/database/database';
|
||||
import { pullRequestModel, userModel } from '@/database/model';
|
||||
import { TWENTY_TEAM_MEMBERS } from '@/shared-utils/listTeamMembers';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - Contributors',
|
||||
description:
|
||||
'Discover the brilliant minds behind Twenty.com. Meet our contributors and explore how their expertise contributes to making Twenty the leading open-source CRM. Join our community today.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
interface Contributor {
|
||||
id: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
const Contributors = async () => {
|
||||
const contributors = await findAll(userModel);
|
||||
const pullRequests = await findAll(pullRequestModel);
|
||||
|
||||
const pullRequestByAuthor = pullRequests.reduce((acc, pr) => {
|
||||
acc[pr.authorId] = acc[pr.authorId] ? acc[pr.authorId] + 1 : 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const fitlerContributors = contributors
|
||||
.filter((contributor) => contributor.isEmployee === '0')
|
||||
.filter((contributor) => !TWENTY_TEAM_MEMBERS.includes(contributor.id))
|
||||
.map((contributor) => {
|
||||
contributor.pullRequestCount = pullRequestByAuthor[contributor.id] || 0;
|
||||
|
||||
return contributor;
|
||||
})
|
||||
.sort((a, b) => b.pullRequestCount - a.pullRequestCount)
|
||||
.filter((contributor) => contributor.pullRequestCount > 0);
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ContentContainer>
|
||||
<Header />
|
||||
<AvatarGrid users={fitlerContributors as Contributor[]} />
|
||||
</ContentContainer>
|
||||
</Background>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contributors;
|
||||
@ -0,0 +1,22 @@
|
||||
const TOTAL_DAYS_TO_FULL_WIDTH = 232;
|
||||
|
||||
export const getActivityEndDate = (
|
||||
activityDates: { value: number; day: string }[],
|
||||
) => {
|
||||
const startDate = new Date(activityDates[0].day);
|
||||
const endDate = new Date(activityDates[activityDates.length - 1].day);
|
||||
|
||||
const differenceInMilliseconds = endDate.getTime() - startDate.getTime();
|
||||
|
||||
const numberOfDays = Math.floor(
|
||||
differenceInMilliseconds / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
const daysToAdd = TOTAL_DAYS_TO_FULL_WIDTH - numberOfDays;
|
||||
|
||||
if (daysToAdd > 0) {
|
||||
endDate.setDate(endDate.getDate() + daysToAdd);
|
||||
}
|
||||
|
||||
return endDate;
|
||||
};
|
||||
@ -0,0 +1,76 @@
|
||||
import { findAll } from '@/database/database';
|
||||
import { pullRequestModel, userModel } from '@/database/model';
|
||||
import { TWENTY_TEAM_MEMBERS } from '@/shared-utils/listTeamMembers';
|
||||
|
||||
export const getContributorActivity = async (username: string) => {
|
||||
const contributors = await findAll(userModel);
|
||||
|
||||
const contributor = contributors.find((contributor) => {
|
||||
return contributor.id === username;
|
||||
});
|
||||
|
||||
if (!contributor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pullRequests = await findAll(pullRequestModel);
|
||||
const mergedPullRequests = pullRequests
|
||||
.filter((pr) => pr.mergedAt !== null)
|
||||
.filter((pr) => !TWENTY_TEAM_MEMBERS.includes(pr.authorId));
|
||||
|
||||
const contributorPullRequests = pullRequests.filter(
|
||||
(pr) => pr.authorId === contributor.id,
|
||||
);
|
||||
const mergedContributorPullRequests = contributorPullRequests.filter(
|
||||
(pr) => pr.mergedAt !== null,
|
||||
);
|
||||
|
||||
const mergedContributorPullRequestsByContributor = mergedPullRequests.reduce(
|
||||
(acc, pr) => {
|
||||
acc[pr.authorId] = (acc[pr.authorId] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const mergedContributorPullRequestsByContributorArray = Object.entries(
|
||||
mergedContributorPullRequestsByContributor,
|
||||
)
|
||||
.map(([authorId, value]) => ({ authorId, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const contributorRank =
|
||||
((mergedContributorPullRequestsByContributorArray.findIndex(
|
||||
(contributor) => contributor.authorId === username,
|
||||
) +
|
||||
1) /
|
||||
contributors.length) *
|
||||
100;
|
||||
|
||||
const pullRequestActivity = contributorPullRequests.reduce((acc, pr) => {
|
||||
const date = new Date(pr.createdAt).toISOString().split('T')[0];
|
||||
acc[date] = (acc[date] || 0) + 1;
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const pullRequestActivityArray = Object.entries(pullRequestActivity)
|
||||
.map(([day, value]) => ({ day, value }))
|
||||
.sort((a, b) => new Date(a.day).getTime() - new Date(b.day).getTime());
|
||||
|
||||
const firstContributionAt = pullRequestActivityArray[0]?.day;
|
||||
const mergedPRsCount = mergedContributorPullRequests.length;
|
||||
const rank = Math.ceil(Number(contributorRank)).toFixed(0);
|
||||
const activeDays = pullRequestActivityArray.length;
|
||||
const contributorAvatar = contributor.avatarUrl;
|
||||
|
||||
return {
|
||||
firstContributionAt,
|
||||
mergedPRsCount,
|
||||
rank,
|
||||
activeDays,
|
||||
pullRequestActivityArray,
|
||||
contributorPullRequests,
|
||||
contributorAvatar,
|
||||
contributor,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.slug);
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsContent item={mainPost} />;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const GraphQlPlayground = dynamic(
|
||||
() => import('@/app/_components/playground/graphql-playground'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const CoreGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'core'} />;
|
||||
};
|
||||
|
||||
export default CoreGraphql;
|
||||
@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const GraphQlPlayground = dynamic(
|
||||
() => import('@/app/_components/playground/graphql-playground'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const MetadataGraphql = () => {
|
||||
return <GraphQlPlayground subDoc={'metadata'} />;
|
||||
};
|
||||
|
||||
export default MetadataGraphql;
|
||||
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function DocsLayout({ children }: { children: ReactNode }) {
|
||||
const filePath = 'src/content/developers/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
||||
15
packages/twenty-website/src/app/(public)/developers/page.tsx
Normal file
15
packages/twenty-website/src/app/(public)/developers/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - Docs',
|
||||
description: 'Twenty is a CRM designed to fit your unique business needs.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export default async function DocsHome() {
|
||||
const filePath = 'src/content/developers/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
|
||||
|
||||
const RestApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = <RestApiWrapper openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw' }}>
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="core"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestApi;
|
||||
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Playground from '@/app/_components/playground/playground';
|
||||
import { RestApiWrapper } from '@/app/_components/playground/rest-api-wrapper';
|
||||
|
||||
const restApi = () => {
|
||||
const [openApiJson, setOpenApiJson] = useState({});
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = <RestApiWrapper openApiJson={openApiJson} />;
|
||||
|
||||
return (
|
||||
<Playground
|
||||
children={children}
|
||||
setOpenApiJson={setOpenApiJson}
|
||||
subDoc="metadata"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default restApi;
|
||||
@ -0,0 +1,35 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/developers/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/developers/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsContent item={mainPost} />;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/developers';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DocsSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/developers/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
const hasOnlyEmptySections = docsArticleCards.every(
|
||||
(article) => article.topic === 'Empty Section',
|
||||
);
|
||||
if (!docsArticleCards || hasOnlyEmptySections) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
||||
126
packages/twenty-website/src/app/(public)/layout.css
Normal file
126
packages/twenty-website/src/app/(public)/layout.css
Normal file
@ -0,0 +1,126 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
font-smooth: antialiased;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
word-wrap: break-word;
|
||||
font-family: var(--font-gabarito);
|
||||
}
|
||||
|
||||
@media (max-width: 810px) {
|
||||
body {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 53px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: rgb(94, 30, 4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(129, 129, 129);
|
||||
&:hover {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
nav.toc {
|
||||
width: 20%;
|
||||
position: fixed;
|
||||
top: 135px;
|
||||
right: 0;
|
||||
font-family: var(--font-gabarito);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
nav.toc ol {
|
||||
list-style-type: none;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
nav.toc li {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toc-link.toc-link-h2 {
|
||||
color: rgb(129, 129, 129);
|
||||
margin-left: -16px;
|
||||
}
|
||||
|
||||
.toc-link.toc-link-h2:hover,.toc-link.toc-link-h3:hover, .toc-link.toc-link-h4:hover, .toc-link.toc-link-h5:hover {
|
||||
background: #1414140a;
|
||||
}
|
||||
|
||||
.toc-link.toc-link-h2:active,.toc-link.toc-link-h3:active, .toc-link.toc-link-h4:active, .toc-link.toc-link-h5:active {
|
||||
background: #1414140f;
|
||||
}
|
||||
|
||||
|
||||
.toc-link.toc-link-h3 {
|
||||
color: rgb(129, 129, 129);
|
||||
margin-left: -40px;
|
||||
}
|
||||
|
||||
.toc-link.toc-link-h4 {
|
||||
color: rgb(129, 129, 129);
|
||||
margin-left: -64px;
|
||||
}
|
||||
|
||||
.toc-link.toc-link-h5 {
|
||||
color: rgb(129, 129, 129);
|
||||
margin-left: -88px;
|
||||
}
|
||||
|
||||
nav.toc a {
|
||||
color: #141414;
|
||||
font-family: var(--font-inter);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
color: #141414;
|
||||
-webkit-font-smoothing: auto;
|
||||
font-family: var(--font-inter);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
53
packages/twenty-website/src/app/(public)/layout.tsx
Normal file
53
packages/twenty-website/src/app/(public)/layout.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Metadata } from 'next';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
import { Gabarito, Inter } from 'next/font/google';
|
||||
|
||||
import { AppHeader } from '@/app/_components/ui/layout/header';
|
||||
|
||||
import { FooterDesktop } from '../_components/ui/layout/FooterDesktop';
|
||||
import EmotionRootStyleRegistry from '../emotion-root-style-registry';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Twenty.com',
|
||||
description: 'Open Source CRM',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
const gabarito = Gabarito({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
adjustFontFallback: false,
|
||||
variable: '--font-gabarito',
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
weight: ['400', '500', '600', '700'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
adjustFontFallback: false,
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${gabarito.variable} ${inter.variable}`}>
|
||||
<body>
|
||||
<PublicEnvScript />
|
||||
<EmotionRootStyleRegistry>
|
||||
<AppHeader />
|
||||
<div className="container">{children}</div>
|
||||
<FooterDesktop />
|
||||
</EmotionRootStyleRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Background } from '@/app/_components/oss-friends/Background';
|
||||
import { Card, OssData } from '@/app/_components/oss-friends/Card';
|
||||
import { CardContainer } from '@/app/_components/oss-friends/CardContainer';
|
||||
import { ContentContainer } from '@/app/_components/oss-friends/ContentContainer';
|
||||
import { Header } from '@/app/_components/oss-friends/Header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - OSS friends',
|
||||
description:
|
||||
'At Twenty, we are proud to be part of a global open-source movement. Here are some of our fellow open source friends.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function OssFriends() {
|
||||
const ossList = await fetch('https://formbricks.com/api/oss-friends');
|
||||
|
||||
const listJson = await ossList.json();
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ContentContainer>
|
||||
<Header />
|
||||
<CardContainer>
|
||||
{listJson.data.map((data: OssData, index: number) => (
|
||||
<Card key={index} data={data} />
|
||||
))}
|
||||
</CardContainer>
|
||||
</ContentContainer>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
15
packages/twenty-website/src/app/(public)/page.tsx
Normal file
15
packages/twenty-website/src/app/(public)/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { ContentContainer } from '../_components/ui/layout/ContentContainer';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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 Cloudflare to split the traffic between the two sites.
|
||||
</div>
|
||||
</ContentContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getReleases } from '@/app/(public)/releases/utils/get-releases';
|
||||
|
||||
export interface ReleaseNote {
|
||||
slug: string;
|
||||
date: string;
|
||||
release: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const host = request.nextUrl.hostname;
|
||||
const protocol = request.nextUrl.protocol;
|
||||
const baseUrl = `${protocol}//${host}`;
|
||||
|
||||
console.log(baseUrl);
|
||||
|
||||
return NextResponse.json(await getReleases(baseUrl), { status: 200 });
|
||||
}
|
||||
52
packages/twenty-website/src/app/(public)/releases/page.tsx
Normal file
52
packages/twenty-website/src/app/(public)/releases/page.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import {
|
||||
getMdxReleasesContent,
|
||||
getReleases,
|
||||
} from '@/app/(public)/releases/utils/get-releases';
|
||||
import { getVisibleReleases } from '@/app/(public)/releases/utils/get-visible-releases';
|
||||
import { ReleaseContainer } from '@/app/_components/releases/ReleaseContainer';
|
||||
import { Title } from '@/app/_components/releases/StyledTitle';
|
||||
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
|
||||
import { findAll } from '@/database/database';
|
||||
import { GithubReleases, githubReleasesModel } from '@/database/model';
|
||||
import { pgGithubReleasesModel } from '@/database/schema-postgres';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Twenty - Releases',
|
||||
description:
|
||||
'Discover the newest features and improvements in Twenty, the #1 open-source CRM.',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const Home = async () => {
|
||||
const githubReleases = (await findAll(
|
||||
githubReleasesModel,
|
||||
desc(pgGithubReleasesModel.publishedAt),
|
||||
)) as GithubReleases[];
|
||||
|
||||
const latestGithubRelease = githubReleases[0];
|
||||
const releaseNotes = await getReleases();
|
||||
|
||||
const visibleReleasesNotes = getVisibleReleases(
|
||||
releaseNotes,
|
||||
latestGithubRelease.tagName,
|
||||
);
|
||||
|
||||
const mdxReleasesContent = await getMdxReleasesContent(releaseNotes);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<Title />
|
||||
<ReleaseContainer
|
||||
visibleReleasesNotes={visibleReleasesNotes}
|
||||
githubReleases={githubReleases}
|
||||
mdxReleasesContent={mdxReleasesContent}
|
||||
/>
|
||||
</ContentContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@ -0,0 +1,19 @@
|
||||
export const getFormattedReleaseNumber = (versionNumber: string) => {
|
||||
const formattedVersion = versionNumber.replace('v', '');
|
||||
|
||||
const parts = formattedVersion.split('.').map(Number);
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Version must be in the format major.minor.patch');
|
||||
}
|
||||
|
||||
// Assign weights. Adjust these based on your needs.
|
||||
const majorWeight = 10000;
|
||||
const minorWeight = 100;
|
||||
const patchWeight = 1;
|
||||
|
||||
const numericVersion =
|
||||
parts[0] * majorWeight + parts[1] * minorWeight + parts[2] * patchWeight;
|
||||
|
||||
return numericVersion;
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { GithubReleases } from '@/database/model';
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return formatter.format(date) + getOrdinal(date.getDate());
|
||||
}
|
||||
|
||||
function getOrdinal(day: number) {
|
||||
if (day > 3 && day < 21) return 'th';
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return 'st';
|
||||
case 2:
|
||||
return 'nd';
|
||||
case 3:
|
||||
return 'rd';
|
||||
default:
|
||||
return 'th';
|
||||
}
|
||||
}
|
||||
|
||||
export const getGithubReleaseDateFromReleaseNote = (
|
||||
githubReleases: GithubReleases[],
|
||||
noteTagName: string,
|
||||
noteDate: string,
|
||||
) => {
|
||||
const formattedNoteTagName = `v${noteTagName}`;
|
||||
const date = githubReleases?.find?.(
|
||||
(githubRelease) => githubRelease?.tagName === formattedNoteTagName,
|
||||
)?.publishedAt;
|
||||
|
||||
if (date) {
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
return noteDate;
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import { compileMDX } from 'next-mdx-remote/rsc';
|
||||
import { JSXElementConstructor, ReactElement } from 'react';
|
||||
import gfm from 'remark-gfm';
|
||||
|
||||
import { ReleaseNote } from '@/app/(public)/releases/api/route';
|
||||
import { compareSemanticVersions } from '@/shared-utils/compareSemanticVersions';
|
||||
|
||||
// WARNING: This API is used by twenty-front, not just by twenty-website
|
||||
// Make sure you don't change it without updating twenty-front at the same time
|
||||
export async function getReleases(baseUrl?: string): Promise<ReleaseNote[]> {
|
||||
const files = fs.readdirSync('src/content/releases');
|
||||
const releasenotes: ReleaseNote[] = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
|
||||
continue;
|
||||
}
|
||||
const file = fs.readFileSync(`src/content/releases/${fileName}`, 'utf-8');
|
||||
const { data: frontmatter, content } = matter(file);
|
||||
|
||||
let updatedContent;
|
||||
if (baseUrl) {
|
||||
updatedContent = content.replace(
|
||||
/!\[(.*?)\]\((?!http)(.*?)\)/g,
|
||||
(match: string, alt: string, src: string) => {
|
||||
// Check if src is a relative path (not starting with http:// or https://)
|
||||
if (!src.startsWith('/')) {
|
||||
src = `${baseUrl}/${src}`;
|
||||
} else {
|
||||
src = `${baseUrl}${src}`;
|
||||
}
|
||||
return ``;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
releasenotes.push({
|
||||
slug: fileName.slice(0, -4),
|
||||
date: frontmatter.Date,
|
||||
release: frontmatter.release,
|
||||
content: updatedContent ?? content,
|
||||
});
|
||||
}
|
||||
|
||||
releasenotes.sort((a, b) => compareSemanticVersions(b.release, a.release));
|
||||
|
||||
return releasenotes;
|
||||
}
|
||||
|
||||
export async function getMdxReleasesContent(
|
||||
releases: ReleaseNote[],
|
||||
): Promise<ReactElement<any, string | JSXElementConstructor<any>>[]> {
|
||||
const mdxSourcesPromises = releases.map(async (release) => {
|
||||
const mdxSource = await compileMDX<{ title: string; position?: number }>({
|
||||
source: release.content,
|
||||
options: {
|
||||
parseFrontmatter: true,
|
||||
mdxOptions: {
|
||||
development: process.env.NODE_ENV === 'development',
|
||||
remarkPlugins: [gfm],
|
||||
},
|
||||
},
|
||||
});
|
||||
return mdxSource.content;
|
||||
});
|
||||
|
||||
return await Promise.all(mdxSourcesPromises);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { ReleaseNote } from '@/app/(public)/releases/api/route';
|
||||
import { getFormattedReleaseNumber } from '@/app/(public)/releases/utils/get-formatted-release-number';
|
||||
|
||||
export const getVisibleReleases = (
|
||||
releaseNotes: ReleaseNote[],
|
||||
publishedReleaseVersion: string,
|
||||
) => {
|
||||
if (process.env.NODE_ENV !== 'production') return releaseNotes;
|
||||
|
||||
const publishedVersionNumber = getFormattedReleaseNumber(
|
||||
publishedReleaseVersion,
|
||||
);
|
||||
|
||||
return releaseNotes.filter(
|
||||
(releaseNote) =>
|
||||
getFormattedReleaseNumber(releaseNote.release) <= publishedVersionNumber,
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.slug);
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function TwentyUILayout({ children }: { children: ReactNode }) {
|
||||
const filePath = 'src/content/twenty-ui/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
||||
17
packages/twenty-website/src/app/(public)/twenty-ui/page.tsx
Normal file
17
packages/twenty-website/src/app/(public)/twenty-ui/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - Twenty UI',
|
||||
description: 'Twenty is a CRM designed to fit your unique business needs.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function TwentyUIHome() {
|
||||
const filePath = 'src/content/twenty-ui/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/twenty-ui/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/twenty-ui/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/twenty-ui';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TwentyUISlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/twenty-ui/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
const hasOnlyEmptySections = docsArticleCards.every(
|
||||
(article) => article.topic === 'Empty Section',
|
||||
);
|
||||
if (!docsArticleCards || hasOnlyEmptySections) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.slug);
|
||||
const basePath = '/src/content/user-guide';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserGuideSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const basePath = '/src/content/user-guide';
|
||||
const mainPost = await fetchArticleFromSlug(params.slug, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsContent item={mainPost} />;
|
||||
}
|
||||
122
packages/twenty-website/src/app/(public)/user-guide/algolia.css
Normal file
122
packages/twenty-website/src/app/(public)/user-guide/algolia.css
Normal file
@ -0,0 +1,122 @@
|
||||
.DocSearch-Hit-source{
|
||||
color: #1c1e21;
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
color: #1c1e21;
|
||||
}
|
||||
|
||||
.DocSearch-Logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Footer{
|
||||
flex-direction: row;
|
||||
box-shadow: none;
|
||||
border: 1px solid #14141414;
|
||||
}
|
||||
|
||||
.DocSearch-Form {
|
||||
box-shadow: none;
|
||||
border: 1px solid #141414;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.DocSearch-Modal {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.DocSearch-Hits {
|
||||
width: 100%;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.DocSearch-Hit[aria-selected=true] mark {
|
||||
color: #1961ED !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hit a {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.DocSearch-Hits mark {
|
||||
background-color: #E8EFFD;
|
||||
color: #1961ED;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action h2{
|
||||
font-size: .9em;
|
||||
margin: 0 0 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-action p{
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-inter);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.DocSearch-Button {
|
||||
margin: 0px;
|
||||
min-height: 36px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #14141414;
|
||||
}
|
||||
|
||||
.DocSearch-Button:hover {
|
||||
color: #B3B3B3;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.DocSearch-Button-Placeholder{
|
||||
color: #B3B3B3;
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
color: #B3B3B3 !important;
|
||||
}
|
||||
|
||||
.DocSearch-Hit-source {
|
||||
background: none;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-inter);
|
||||
}
|
||||
|
||||
.DocSearch-Button-Placeholder {
|
||||
font-weight: 500;
|
||||
font-family: var(--font-gabarito);
|
||||
}
|
||||
|
||||
.DocSearch-Button-Keys {
|
||||
display: none
|
||||
}
|
||||
|
||||
:root {
|
||||
--docsearch-primary-color: #1c1e21;
|
||||
--docsearch-highlight-color: #1414140F;
|
||||
--docsearch-hit-active-color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.anchor {
|
||||
scroll-margin-top: calc(80px);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useHeadsObserver(location: string) {
|
||||
const [activeText, setActiveText] = useState('');
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
useEffect(() => {
|
||||
const handleObsever = (entries: any[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setActiveText(entry.target.innerText);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
observer.current = new IntersectionObserver(handleObsever, {
|
||||
rootMargin: '0% 0% -85% 0px',
|
||||
});
|
||||
|
||||
const elements = document.querySelectorAll('h2, h3, h4, h5');
|
||||
elements.forEach((elem) => observer.current?.observe(elem));
|
||||
return () => observer.current?.disconnect();
|
||||
}, [location]);
|
||||
|
||||
return { activeText };
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DocsMainLayout } from '@/app/_components/docs/DocsMainLayout';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export default function UserGuideLayout({ children }: { children: ReactNode }) {
|
||||
const filePath = 'src/content/user-guide/';
|
||||
const getAllArticles = true;
|
||||
const docsIndex = getDocsArticles(filePath, getAllArticles);
|
||||
return <DocsMainLayout docsIndex={docsIndex}>{children}</DocsMainLayout>;
|
||||
}
|
||||
16
packages/twenty-website/src/app/(public)/user-guide/page.tsx
Normal file
16
packages/twenty-website/src/app/(public)/user-guide/page.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Twenty - User Guide',
|
||||
description:
|
||||
'Discover how to use Twenty CRM effectively with our detailed user guide. Explore ways to customize features, manage tasks, integrate emails, and navigate the system with ease.',
|
||||
icons: '/images/core/logo.svg',
|
||||
};
|
||||
|
||||
export default async function UserGuideHome() {
|
||||
const filePath = 'src/content/user-guide/';
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
|
||||
return <DocsMain docsArticleCards={docsArticleCards} />;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsContent from '@/app/_components/docs/DocsContent';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string; documentation: string };
|
||||
}): Promise<Metadata> {
|
||||
const basePath = `/src/content/user-guide/${params.folder}`;
|
||||
const formattedSlug = formatSlug(params.documentation);
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserGuideSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { documentation: string; folder: string };
|
||||
}) {
|
||||
const basePath = `/src/content/user-guide/${params.folder}`;
|
||||
const mainPost = await fetchArticleFromSlug(params.documentation, basePath);
|
||||
if (!mainPost) {
|
||||
notFound();
|
||||
}
|
||||
return mainPost && <DocsContent item={mainPost} />;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import DocsMain from '@/app/_components/docs/DocsMain';
|
||||
import { getDocsArticles } from '@/content/user-guide/constants/getDocsArticles';
|
||||
import { fetchArticleFromSlug } from '@/shared-utils/fetchArticleFromSlug';
|
||||
import { formatSlug } from '@/shared-utils/formatSlug';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}): Promise<Metadata> {
|
||||
const formattedSlug = formatSlug(params.folder);
|
||||
const basePath = '/src/content/user-guide';
|
||||
const mainPost = await fetchArticleFromSlug(params.folder, basePath);
|
||||
return {
|
||||
title: 'Twenty - ' + formattedSlug,
|
||||
description: mainPost?.itemInfo?.info,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserGuideSlug({
|
||||
params,
|
||||
}: {
|
||||
params: { folder: string };
|
||||
}) {
|
||||
const filePath = `src/content/user-guide/${params.folder}/`;
|
||||
const docsArticleCards = getDocsArticles(filePath);
|
||||
const isSection = true;
|
||||
const hasOnlyEmptySections = docsArticleCards.every(
|
||||
(article) => article.topic === 'Empty Section',
|
||||
);
|
||||
if (!docsArticleCards || hasOnlyEmptySections) {
|
||||
notFound();
|
||||
}
|
||||
return <DocsMain docsArticleCards={docsArticleCards} isSection={isSection} />;
|
||||
}
|
||||
Reference in New Issue
Block a user