Changelog (#399)
* feat: add changelog feature and update site navigation - Introduced a new Changelog page with pagination and detailed entry views. - Added components for displaying changelog entries, pagination, and entry details. - Updated site navigation to include a link to the new Changelog page. - Enhanced localization for changelog-related texts in marketing.json. * refactor: enhance Changelog page layout and entry display - Increased the number of changelog entries displayed per page from 2 to 20 for better visibility. - Improved the layout of the Changelog page by adjusting the container styles and removing unnecessary divs. - Updated the ChangelogEntry component to enhance the visual presentation of entries, including a new date badge with an icon. - Refined the CSS styles for Markdoc headings to improve typography and spacing. * refactor: enhance Changelog page functionality and layout - Increased the number of changelog entries displayed per page from 20 to 50 for improved user experience. - Updated ChangelogEntry component to make the highlight prop optional and refined the layout for better visual clarity. - Adjusted styles in ChangelogHeader and ChangelogPagination components for a more cohesive design. - Removed unnecessary order fields from changelog markdown files to streamline content management. * feat: enhance Changelog entry navigation and data loading - Refactored ChangelogEntry page to load previous and next entries for improved navigation. - Introduced ChangelogNavigation component to facilitate navigation between changelog entries. - Updated ChangelogDetail component to display navigation links and entry details. - Enhanced data fetching logic to retrieve all changelog entries alongside the current entry. - Added localization keys for navigation text in marketing.json. * Update package dependencies and enhance documentation layout - Upgraded various packages including @turbo/gen and turbo to version 2.6.0, and react-hook-form to version 7.66.0. - Updated lucide-react to version 0.552.0 across multiple packages. - Refactored documentation layout components for improved styling and structure. - Removed deprecated loading components and adjusted navigation elements for better user experience. - Added placeholder notes in changelog entries for clarity.
This commit is contained in:
committed by
GitHub
parent
a920dea2b3
commit
116d41a284
@@ -18,6 +18,10 @@ const links = {
|
||||
label: 'marketing:blog',
|
||||
path: '/blog',
|
||||
},
|
||||
Changelog: {
|
||||
label: 'marketing:changelog',
|
||||
path: '/changelog',
|
||||
},
|
||||
Docs: {
|
||||
label: 'marketing:documentation',
|
||||
path: '/docs',
|
||||
@@ -30,10 +34,6 @@ const links = {
|
||||
label: 'marketing:faq',
|
||||
path: '/faq',
|
||||
},
|
||||
Contact: {
|
||||
label: 'marketing:contact',
|
||||
path: '/contact',
|
||||
},
|
||||
};
|
||||
|
||||
export function SiteNavigation() {
|
||||
|
||||
110
apps/web/app/(marketing)/changelog/[slug]/page.tsx
Normal file
110
apps/web/app/(marketing)/changelog/[slug]/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { ChangelogDetail } from '../_components/changelog-detail';
|
||||
|
||||
interface ChangelogEntryPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const getChangelogData = cache(changelogEntryLoader);
|
||||
|
||||
async function changelogEntryLoader(slug: string) {
|
||||
const client = await createCmsClient();
|
||||
|
||||
const [entry, allEntries] = await Promise.all([
|
||||
client.getContentItemBySlug({ slug, collection: 'changelog' }),
|
||||
client.getContentItems({
|
||||
collection: 'changelog',
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
content: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find previous and next entries in the timeline
|
||||
const currentIndex = allEntries.items.findIndex((item) => item.slug === slug);
|
||||
const previousEntry =
|
||||
currentIndex > 0 ? allEntries.items[currentIndex - 1] : null;
|
||||
const nextEntry =
|
||||
currentIndex < allEntries.items.length - 1
|
||||
? allEntries.items[currentIndex + 1]
|
||||
: null;
|
||||
|
||||
return {
|
||||
entry,
|
||||
previousEntry,
|
||||
nextEntry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ChangelogEntryPageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
const data = await getChangelogData(slug);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, publishedAt, description, image } = data.entry;
|
||||
|
||||
return Promise.resolve({
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: publishedAt,
|
||||
url: data.entry.url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getChangelogData(slug);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container sm:max-w-none sm:p-0">
|
||||
<ChangelogDetail
|
||||
entry={data.entry}
|
||||
content={data.entry.content}
|
||||
previousEntry={data.previousEntry ?? null}
|
||||
nextEntry={data.nextEntry ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ChangelogEntryPage);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { ContentRenderer } from '@kit/cms';
|
||||
|
||||
import { ChangelogHeader } from './changelog-header';
|
||||
import { ChangelogNavigation } from './changelog-navigation';
|
||||
|
||||
interface ChangelogDetailProps {
|
||||
entry: Cms.ContentItem;
|
||||
content: unknown;
|
||||
previousEntry: Cms.ContentItem | null;
|
||||
nextEntry: Cms.ContentItem | null;
|
||||
}
|
||||
|
||||
export function ChangelogDetail({
|
||||
entry,
|
||||
content,
|
||||
previousEntry,
|
||||
nextEntry,
|
||||
}: ChangelogDetailProps) {
|
||||
return (
|
||||
<div>
|
||||
<ChangelogHeader entry={entry} />
|
||||
|
||||
<div className="mx-auto flex max-w-3xl flex-col space-y-6 py-8">
|
||||
<article className="markdoc">
|
||||
<ContentRenderer content={content} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ChangelogNavigation
|
||||
previousEntry={previousEntry}
|
||||
nextEntry={nextEntry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { type Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { DateBadge } from './date-badge';
|
||||
|
||||
interface ChangelogEntryProps {
|
||||
entry: Cms.ContentItem;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function ChangelogEntry({
|
||||
entry,
|
||||
highlight = false,
|
||||
}: ChangelogEntryProps) {
|
||||
const { title, slug, publishedAt, description } = entry;
|
||||
const entryUrl = `/changelog/${slug}`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 md:gap-8">
|
||||
<div className="md:border-border relative flex flex-1 flex-col space-y-0 gap-y-2.5 border-l border-dashed border-transparent pb-4 md:pl-8 lg:pl-12">
|
||||
{highlight ? (
|
||||
<span className="absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 md:flex">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-red-400"></span>
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted absolute top-5.5 left-0 hidden h-2.5 w-2.5 -translate-x-1/2 rounded-full md:block',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="hover:bg-muted/50 active:bg-muted rounded-md transition-colors">
|
||||
<Link href={entryUrl} className="block space-y-2 p-4">
|
||||
<div>
|
||||
<DateBadge date={publishedAt} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl leading-tight font-semibold tracking-tight group-hover/link:underline">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<If condition={description}>
|
||||
{(desc) => (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{desc}
|
||||
</p>
|
||||
)}
|
||||
</If>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CoverImage } from '../../blog/_components/cover-image';
|
||||
import { DateFormatter } from '../../blog/_components/date-formatter';
|
||||
|
||||
export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) {
|
||||
const { title, publishedAt, description, image } = entry;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="border-border/50 border-b py-4">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<Trans i18nKey="marketing:changelog" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('border-border/50 border-b py-8')}>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-y-2.5">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-heading text-2xl font-medium tracking-tighter xl:text-4xl dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{description && (
|
||||
<h2
|
||||
className="text-muted-foreground text-base"
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
|
||||
<CoverImage
|
||||
preloadImage
|
||||
className="rounded-md"
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { DateFormatter } from '../../blog/_components/date-formatter';
|
||||
|
||||
interface ChangelogNavigationProps {
|
||||
previousEntry: Cms.ContentItem | null;
|
||||
nextEntry: Cms.ContentItem | null;
|
||||
}
|
||||
|
||||
interface NavLinkProps {
|
||||
entry: Cms.ContentItem;
|
||||
direction: 'previous' | 'next';
|
||||
}
|
||||
|
||||
function NavLink({ entry, direction }: NavLinkProps) {
|
||||
const isPrevious = direction === 'previous';
|
||||
|
||||
const Icon = isPrevious ? ChevronLeft : ChevronRight;
|
||||
const i18nKey = isPrevious
|
||||
? 'marketing:changelogNavigationPrevious'
|
||||
: 'marketing:changelogNavigationNext';
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/changelog/${entry.slug}`}
|
||||
className={cn(
|
||||
'border-border/50 hover:bg-muted/50 group flex flex-col gap-2 rounded-lg border p-4 transition-all',
|
||||
!isPrevious && 'text-right md:items-end',
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
{isPrevious && <Icon className="h-3 w-3" />}
|
||||
|
||||
<span className="font-medium tracking-wider uppercase">
|
||||
<Trans i18nKey={i18nKey} />
|
||||
</span>
|
||||
{!isPrevious && <Icon className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="group-hover:text-primary text-sm leading-tight font-semibold transition-colors">
|
||||
{entry.title}
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<DateFormatter dateString={entry.publishedAt} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogNavigation({
|
||||
previousEntry,
|
||||
nextEntry,
|
||||
}: ChangelogNavigationProps) {
|
||||
return (
|
||||
<div className="border-border/50 border-t py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<If condition={previousEntry} fallback={<div />}>
|
||||
{(prev) => <NavLink entry={prev} direction="previous" />}
|
||||
</If>
|
||||
|
||||
<If condition={nextEntry} fallback={<div />}>
|
||||
{(next) => <NavLink entry={next} direction="next" />}
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
interface ChangelogPaginationProps {
|
||||
currentPage: number;
|
||||
canGoToNextPage: boolean;
|
||||
canGoToPreviousPage: boolean;
|
||||
}
|
||||
|
||||
export function ChangelogPagination({
|
||||
currentPage,
|
||||
canGoToNextPage,
|
||||
canGoToPreviousPage,
|
||||
}: ChangelogPaginationProps) {
|
||||
const nextPage = currentPage + 1;
|
||||
const previousPage = currentPage - 1;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{canGoToPreviousPage && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/changelog?page=${previousPage}`}>
|
||||
<ArrowLeft className="mr-2 h-3 w-3" />
|
||||
<span>
|
||||
<Trans i18nKey="marketing:changelogPaginationPrevious" />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canGoToNextPage && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/changelog?page=${nextPage}`}>
|
||||
<span>
|
||||
<Trans i18nKey="marketing:changelogPaginationNext" />
|
||||
</span>
|
||||
<ArrowRight className="ml-2 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
interface DateBadgeProps {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function DateBadge({ date }: DateBadgeProps) {
|
||||
const formattedDate = format(new Date(date), 'MMMM d, yyyy');
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-shrink-0 items-center gap-2 text-sm">
|
||||
<CalendarIcon className="size-3" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
apps/web/app/(marketing)/changelog/page.tsx
Normal file
117
apps/web/app/(marketing)/changelog/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../_components/site-page-header';
|
||||
import { ChangelogEntry } from './_components/changelog-entry';
|
||||
import { ChangelogPagination } from './_components/changelog-pagination';
|
||||
|
||||
interface ChangelogPageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
const CHANGELOG_ENTRIES_PER_PAGE = 50;
|
||||
|
||||
export const generateMetadata = async (
|
||||
props: ChangelogPageProps,
|
||||
): Promise<Metadata> => {
|
||||
const { t, resolvedLanguage } = await createI18nServerInstance();
|
||||
const searchParams = await props.searchParams;
|
||||
const limit = CHANGELOG_ENTRIES_PER_PAGE;
|
||||
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total } = await getContentItems(resolvedLanguage, limit, offset);
|
||||
|
||||
return {
|
||||
title: t('marketing:changelog'),
|
||||
description: t('marketing:changelogSubtitle'),
|
||||
pagination: {
|
||||
previous: page > 0 ? `/changelog?page=${page - 1}` : undefined,
|
||||
next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getContentItems = cache(
|
||||
async (language: string | undefined, limit: number, offset: number) => {
|
||||
const client = await createCmsClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
return await client.getContentItems({
|
||||
collection: 'changelog',
|
||||
limit,
|
||||
offset,
|
||||
content: false,
|
||||
language,
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load changelog entries');
|
||||
|
||||
return { total: 0, items: [] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function ChangelogPage(props: ChangelogPageProps) {
|
||||
const { t, resolvedLanguage: language } = await createI18nServerInstance();
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const limit = CHANGELOG_ENTRIES_PER_PAGE;
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total, items: entries } = await getContentItems(
|
||||
language,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SitePageHeader
|
||||
title={t('marketing:changelog')}
|
||||
subtitle={t('marketing:changelogSubtitle')}
|
||||
/>
|
||||
|
||||
<div className="container flex max-w-4xl flex-col space-y-12 py-12">
|
||||
<If
|
||||
condition={entries.length > 0}
|
||||
fallback={<Trans i18nKey="marketing:noChangelogEntries" />}
|
||||
>
|
||||
<div className="space-y-0">
|
||||
{entries.map((entry, index) => {
|
||||
return (
|
||||
<ChangelogEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
highlight={index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ChangelogPagination
|
||||
currentPage={page}
|
||||
canGoToNextPage={offset + limit < total}
|
||||
canGoToPreviousPage={page > 0}
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ChangelogPage);
|
||||
@@ -11,8 +11,6 @@ import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
// local imports
|
||||
import { DocsCards } from '../_components/docs-cards';
|
||||
import { DocsTableOfContents } from '../_components/docs-table-of-contents';
|
||||
import { extractHeadingsFromJSX } from '../_lib/utils';
|
||||
|
||||
const getPageBySlug = cache(pageLoader);
|
||||
|
||||
@@ -52,38 +50,36 @@ async function DocumentationPage({ params }: DocumentationPageProps) {
|
||||
|
||||
const description = page?.description ?? '';
|
||||
|
||||
const headings = extractHeadingsFromJSX(
|
||||
page.content as {
|
||||
props: { children: React.ReactElement[] };
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden py-4'}>
|
||||
<div className={'flex overflow-y-hidden'}>
|
||||
<article className={cn('gap-y-12 overflow-y-auto px-4')}>
|
||||
<section
|
||||
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
|
||||
<div className={'flex flex-1 flex-col gap-y-4 overflow-y-hidden'}>
|
||||
<div className={'flex size-full overflow-y-hidden'}>
|
||||
<div className="relative size-full">
|
||||
<article
|
||||
className={cn(
|
||||
'absolute size-full w-full gap-y-12 overflow-y-auto pt-4 pb-36',
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={
|
||||
'text-foreground text-3xl font-semibold tracking-tighter'
|
||||
}
|
||||
<section
|
||||
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
|
||||
>
|
||||
{page.title}
|
||||
</h1>
|
||||
<h1
|
||||
className={
|
||||
'text-foreground text-3xl font-semibold tracking-tighter'
|
||||
}
|
||||
>
|
||||
{page.title}
|
||||
</h1>
|
||||
|
||||
<h2 className={'text-secondary-foreground/80 text-lg'}>
|
||||
{description}
|
||||
</h2>
|
||||
</section>
|
||||
<h2 className={'text-secondary-foreground/80 text-lg'}>
|
||||
{description}
|
||||
</h2>
|
||||
</section>
|
||||
|
||||
<div className={'markdoc'}>
|
||||
<ContentRenderer content={page.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<DocsTableOfContents data={headings} />
|
||||
<div className={'markdoc'}>
|
||||
<ContentRenderer content={page.content} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<If condition={page.children.length > 0}>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DocsCard({
|
||||
return (
|
||||
<Link href={link.url} className="flex flex-col">
|
||||
<div
|
||||
className={`bg-muted/50 hover:bg-muted/70 flex grow flex-col gap-y-2 rounded p-4`}
|
||||
className={`hover:bg-muted/70 flex grow flex-col gap-y-0.5 rounded border p-4`}
|
||||
>
|
||||
<h3 className="mt-0 text-lg font-medium hover:underline dark:text-white">
|
||||
{title}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
|
||||
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className={'grid grid-cols-1 gap-4 lg:grid-cols-2'}>
|
||||
<div className={'absolute flex w-full flex-col gap-4 pb-48 lg:max-w-2xl'}>
|
||||
{cardsSortedByOrder.map((item) => {
|
||||
return (
|
||||
<DocsCard
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@@ -14,7 +12,6 @@ export function DocsNavLink({
|
||||
children,
|
||||
}: React.PropsWithChildren<{ label: string; url: string }>) {
|
||||
const currentPath = usePathname();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const isCurrent = isRouteActive(url, currentPath, true);
|
||||
|
||||
return (
|
||||
@@ -22,14 +19,10 @@ export function DocsNavLink({
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrent}
|
||||
className={cn('transition-background font-normal!', {
|
||||
'text-secondary-foreground font-bold': isCurrent,
|
||||
})}
|
||||
className={cn('text-secondary-foreground transition-all')}
|
||||
>
|
||||
<Link href={url}>
|
||||
<span ref={ref} className="block max-w-full truncate">
|
||||
{label}
|
||||
</span>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { Collapsible } from '@kit/ui/collapsible';
|
||||
import { isRouteActive } from '@kit/ui/utils';
|
||||
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||
|
||||
export function DocsNavigationCollapsible(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -21,7 +21,9 @@ export function DocsNavigationCollapsible(
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={'group/collapsible'}
|
||||
className={cn('group/collapsible', {
|
||||
'group/active': isChildActive,
|
||||
})}
|
||||
defaultOpen={isChildActive ? true : !props.node.collapsed}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -139,12 +139,12 @@ export function DocsNavigation({
|
||||
<Sidebar
|
||||
variant={'ghost'}
|
||||
className={
|
||||
'sticky z-1 mt-4 max-h-full overflow-y-auto border-r-transparent'
|
||||
'border-border/50 sticky z-1 mt-4 max-h-full overflow-y-auto pr-4'
|
||||
}
|
||||
>
|
||||
<SidebarGroup>
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu className={'pb-48'}>
|
||||
<Tree pages={pages} level={0} prefix={prefix} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export function DocsPageLink({
|
||||
page,
|
||||
before,
|
||||
after,
|
||||
}: React.PropsWithChildren<{
|
||||
page: {
|
||||
url: string;
|
||||
title: string;
|
||||
};
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
`ring-muted hover:ring-primary flex w-full items-center space-x-8 rounded-xl p-6 font-medium text-current ring-2 transition-all`,
|
||||
{
|
||||
'justify-start': before,
|
||||
'justify-end self-end': after,
|
||||
},
|
||||
)}
|
||||
href={page.url}
|
||||
>
|
||||
<If condition={before}>{(node) => <>{node}</>}</If>
|
||||
|
||||
<span className={'flex flex-col space-y-1.5'}>
|
||||
<span
|
||||
className={'text-muted-foreground text-xs font-semibold uppercase'}
|
||||
>
|
||||
{before ? `Previous` : ``}
|
||||
{after ? `Next` : ``}
|
||||
</span>
|
||||
|
||||
<span>{page.title}</span>
|
||||
</span>
|
||||
|
||||
<If condition={after}>{(node) => <>{node}</>}</If>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NavItem {
|
||||
text: string;
|
||||
level: number;
|
||||
href: string;
|
||||
children: NavItem[];
|
||||
}
|
||||
|
||||
export function DocsTableOfContents(props: { data: NavItem[] }) {
|
||||
const navData = props.data;
|
||||
|
||||
return (
|
||||
<div className="bg-background sticky inset-y-0 hidden h-svh max-h-full min-w-[14em] p-2.5 lg:block">
|
||||
<ol
|
||||
role="list"
|
||||
className="relative text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{navData.map((item) => (
|
||||
<li key={item.href} className="group/item relative mt-3 first:mt-0">
|
||||
<a
|
||||
href={item.href}
|
||||
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
{item.children && (
|
||||
<ol role="list" className="relative mt-3 pl-4">
|
||||
{item.children.map((child) => (
|
||||
<li
|
||||
key={child.href}
|
||||
className="group/subitem relative mt-3 first:mt-0"
|
||||
>
|
||||
<Link
|
||||
href={child.href}
|
||||
className="block transition-colors **:[font:inherit] hover:text-gray-950 dark:hover:text-white"
|
||||
>
|
||||
{child.text}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
|
||||
interface HeadingNode {
|
||||
text: string;
|
||||
level: number;
|
||||
href: string;
|
||||
children: HeadingNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @name buildDocumentationTree
|
||||
* @description Build a tree structure for the documentation pages.
|
||||
@@ -38,109 +31,3 @@ export function buildDocumentationTree(pages: Cms.ContentItem[]) {
|
||||
|
||||
return tree.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name extractHeadingsFromJSX
|
||||
* @description Extract headings from JSX. This is used to generate the table of contents for the documentation pages.
|
||||
* @param jsx
|
||||
*/
|
||||
export function extractHeadingsFromJSX(jsx: {
|
||||
props: { children: React.ReactElement[] };
|
||||
}) {
|
||||
const headings: HeadingNode[] = [];
|
||||
let currentH2: HeadingNode | null = null;
|
||||
|
||||
function getTextContent(
|
||||
children: React.ReactElement[] | string | React.ReactElement,
|
||||
): string {
|
||||
try {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child) => getTextContent(child)).join('');
|
||||
}
|
||||
|
||||
if (
|
||||
(
|
||||
children.props as {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
).children
|
||||
) {
|
||||
return getTextContent(
|
||||
(children.props as { children: React.ReactElement }).children,
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
jsx.props.children.forEach((node) => {
|
||||
if (!node || typeof node !== 'object' || !('type' in node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeType = node.type as string;
|
||||
|
||||
const text = getTextContent(
|
||||
(
|
||||
node.props as {
|
||||
children: React.ReactElement[];
|
||||
}
|
||||
).children,
|
||||
);
|
||||
|
||||
if (nodeType === 'h1') {
|
||||
const slug = generateSlug(text);
|
||||
|
||||
headings.push({
|
||||
text,
|
||||
level: 1,
|
||||
href: `#${slug}`,
|
||||
children: [],
|
||||
});
|
||||
} else if (nodeType === 'h2') {
|
||||
const slug = generateSlug(text);
|
||||
|
||||
currentH2 = {
|
||||
text,
|
||||
level: 2,
|
||||
href: `#${slug}`,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (headings.length > 0) {
|
||||
headings[headings.length - 1]!.children.push(currentH2);
|
||||
} else {
|
||||
headings.push(currentH2);
|
||||
}
|
||||
} else if (nodeType === 'h3' && currentH2) {
|
||||
const slug = generateSlug(text);
|
||||
|
||||
currentH2.children.push({
|
||||
text,
|
||||
level: 3,
|
||||
href: `#${slug}`,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return headings;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
@@ -13,14 +13,32 @@ async function DocsLayout({ children }: React.PropsWithChildren) {
|
||||
const tree = buildDocumentationTree(docs);
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={{ '--sidebar-width': '18em' } as React.CSSProperties}
|
||||
className={'h-[calc(100vh-72px)] overflow-y-hidden lg:container'}
|
||||
>
|
||||
<DocsNavigation pages={tree} />
|
||||
<div className={'container h-[calc(100vh-56px)] overflow-y-hidden'}>
|
||||
<SidebarProvider
|
||||
className="lg:gap-x-6"
|
||||
style={{ '--sidebar-width': '17em' } as React.CSSProperties}
|
||||
>
|
||||
<HideFooterStyles />
|
||||
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
<DocsNavigation pages={tree} />
|
||||
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HideFooterStyles() {
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.site-footer {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -21,16 +21,14 @@ async function DocsPage() {
|
||||
const cards = items.filter((item) => !item.parentId);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col gap-y-6 xl:gap-y-8'}>
|
||||
<div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
|
||||
<SitePageHeader
|
||||
title={t('marketing:documentation')}
|
||||
subtitle={t('marketing:documentationSubtitle')}
|
||||
/>
|
||||
|
||||
<div className={'flex flex-col items-center'}>
|
||||
<div className={'container mx-auto max-w-5xl'}>
|
||||
<DocsCards cards={cards} />
|
||||
</div>
|
||||
<div className={'relative flex size-full justify-center overflow-y-auto'}>
|
||||
<DocsCards cards={cards} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -25,7 +25,7 @@ export default async function RootLayout({
|
||||
const nonce = await getCspNonce();
|
||||
|
||||
return (
|
||||
<html lang={language} className={className}>
|
||||
<html lang={language} className={`${className} overscroll-y-none`}>
|
||||
<body>
|
||||
<RootProviders theme={theme} lang={language} nonce={nonce}>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user