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,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