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
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);
|
||||
Reference in New Issue
Block a user