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:
Baptiste Devessier
2025-03-07 07:59:06 +01:00
committed by GitHub
parent 6b4d3ed025
commit 2c465bd42e
52 changed files with 3059 additions and 63 deletions

View File

@ -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} />;
}

View 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);
}

View File

@ -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 };
}

View File

@ -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>;
}

View 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} />;
}

View File

@ -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} />;
}

View File

@ -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} />;
}