diff --git a/apps/dev-tool/package.json b/apps/dev-tool/package.json index c927d41ea..bffe494e6 100644 --- a/apps/dev-tool/package.json +++ b/apps/dev-tool/package.json @@ -8,12 +8,12 @@ "format": "prettier --check --write \"**/*.{ts,tsx}\" --ignore-path=\"../../.prettierignore\"" }, "dependencies": { - "@ai-sdk/openai": "^2.0.58", + "@ai-sdk/openai": "^2.0.59", "@faker-js/faker": "^10.1.0", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "5.90.5", - "ai": "5.0.83", - "lucide-react": "^0.548.0", + "ai": "5.0.86", + "lucide-react": "^0.552.0", "next": "catalog:", "nodemailer": "^7.0.10", "react": "19.2.0", @@ -36,7 +36,7 @@ "@types/react-dom": "19.2.2", "babel-plugin-react-compiler": "1.0.0", "pino-pretty": "13.0.0", - "react-hook-form": "^7.65.0", + "react-hook-form": "^7.66.0", "recharts": "2.15.3", "tailwindcss": "4.1.16", "tailwindcss-animate": "^1.0.7", diff --git a/apps/web/app/(marketing)/_components/site-navigation.tsx b/apps/web/app/(marketing)/_components/site-navigation.tsx index f4e235cdd..c8f055de9 100644 --- a/apps/web/app/(marketing)/_components/site-navigation.tsx +++ b/apps/web/app/(marketing)/_components/site-navigation.tsx @@ -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() { diff --git a/apps/web/app/(marketing)/changelog/[slug]/page.tsx b/apps/web/app/(marketing)/changelog/[slug]/page.tsx new file mode 100644 index 000000000..15f3d35c4 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+ ); +} + +export default withI18n(ChangelogEntryPage); diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx new file mode 100644 index 000000000..2eda089a6 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx @@ -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 ( +
+ + +
+
+ +
+
+ + +
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx new file mode 100644 index 000000000..d045a6e1d --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx @@ -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 ( +
+
+ {highlight ? ( + + + + + ) : ( + +
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx new file mode 100644 index 000000000..3e689a317 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx @@ -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 ( +
+
+
+ + + + +
+
+ +
+
+
+ + + +
+ +

+ {title} +

+ + {description && ( +

+ )} +

+
+ + + {(imageUrl) => ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx new file mode 100644 index 000000000..3cb115ed2 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx @@ -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 ( + +
+ {isPrevious && } + + + + + {!isPrevious && } +
+ +
+

+ {entry.title} +

+ +
+ +
+
+ + ); +} + +export function ChangelogNavigation({ + previousEntry, + nextEntry, +}: ChangelogNavigationProps) { + return ( +
+
+
+ }> + {(prev) => } + + + }> + {(next) => } + +
+
+
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx new file mode 100644 index 000000000..700684a38 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/changelog-pagination.tsx @@ -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 ( +
+ {canGoToPreviousPage && ( + + )} + + {canGoToNextPage && ( + + )} +
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/_components/date-badge.tsx b/apps/web/app/(marketing)/changelog/_components/date-badge.tsx new file mode 100644 index 000000000..8c3ccdaf3 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/_components/date-badge.tsx @@ -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 ( +
+ + {formattedDate} +
+ ); +} diff --git a/apps/web/app/(marketing)/changelog/page.tsx b/apps/web/app/(marketing)/changelog/page.tsx new file mode 100644 index 000000000..024f830f2 --- /dev/null +++ b/apps/web/app/(marketing)/changelog/page.tsx @@ -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 => { + 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 ( + <> + + +
+ 0} + fallback={} + > +
+ {entries.map((entry, index) => { + return ( + + ); + })} +
+ + 0} + /> +
+
+ + ); +} + +export default withI18n(ChangelogPage); diff --git a/apps/web/app/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/(marketing)/docs/[...slug]/page.tsx index 3fa54c2fa..3b030aba3 100644 --- a/apps/web/app/(marketing)/docs/[...slug]/page.tsx +++ b/apps/web/app/(marketing)/docs/[...slug]/page.tsx @@ -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 ( -
-
-
-
+
+
+
-

- {page.title} -

+

+ {page.title} +

-

- {description} -

-
+

+ {description} +

+ -
- -
-
- - +
+ +
+ +
0}> diff --git a/apps/web/app/(marketing)/docs/_components/docs-card.tsx b/apps/web/app/(marketing)/docs/_components/docs-card.tsx index 4b30eeeac..0a64598e5 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-card.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-card.tsx @@ -13,7 +13,7 @@ export function DocsCard({ return (

{title} diff --git a/apps/web/app/(marketing)/docs/_components/docs-cards.tsx b/apps/web/app/(marketing)/docs/_components/docs-cards.tsx index 54c9765c9..068118a08 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-cards.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-cards.tsx @@ -6,7 +6,7 @@ export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) { const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order); return ( -
+
{cardsSortedByOrder.map((item) => { return ( ) { const currentPath = usePathname(); - const ref = useRef(null); const isCurrent = isRouteActive(url, currentPath, true); return ( @@ -22,14 +19,10 @@ export function DocsNavLink({ - - {label} - + {label} {children} diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx b/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx index 5a36aa498..9bd35f05f 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx @@ -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 ( {props.children} diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx index e565fd263..d07eb6dd9 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx @@ -139,12 +139,12 @@ export function DocsNavigation({ - + - + diff --git a/apps/web/app/(marketing)/docs/_components/docs-page-link.tsx b/apps/web/app/(marketing)/docs/_components/docs-page-link.tsx deleted file mode 100644 index 52cdad12c..000000000 --- a/apps/web/app/(marketing)/docs/_components/docs-page-link.tsx +++ /dev/null @@ -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 ( - - {(node) => <>{node}} - - - - {before ? `Previous` : ``} - {after ? `Next` : ``} - - - {page.title} - - - {(node) => <>{node}} - - ); -} diff --git a/apps/web/app/(marketing)/docs/_components/docs-table-of-contents.tsx b/apps/web/app/(marketing)/docs/_components/docs-table-of-contents.tsx deleted file mode 100644 index 0d30d0417..000000000 --- a/apps/web/app/(marketing)/docs/_components/docs-table-of-contents.tsx +++ /dev/null @@ -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 ( -
-
    - {navData.map((item) => ( -
  1. - - {item.text} - - {item.children && ( -
      - {item.children.map((child) => ( -
    1. - - {child.text} - -
    2. - ))} -
    - )} -
  2. - ))} -
-
- ); -} diff --git a/apps/web/app/(marketing)/docs/_lib/utils.ts b/apps/web/app/(marketing)/docs/_lib/utils.ts index eac429814..033ee4919 100644 --- a/apps/web/app/(marketing)/docs/_lib/utils.ts +++ b/apps/web/app/(marketing)/docs/_lib/utils.ts @@ -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, ''); -} diff --git a/apps/web/app/(marketing)/docs/layout.tsx b/apps/web/app/(marketing)/docs/layout.tsx index bba657dfb..2a5e3b914 100644 --- a/apps/web/app/(marketing)/docs/layout.tsx +++ b/apps/web/app/(marketing)/docs/layout.tsx @@ -13,14 +13,32 @@ async function DocsLayout({ children }: React.PropsWithChildren) { const tree = buildDocumentationTree(docs); return ( - - +
+ + - {children} - + + + {children} + +
+ ); +} + +function HideFooterStyles() { + return ( +