Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
80
apps/web/app/[locale]/docs/[...slug]/page.tsx
Normal file
80
apps/web/app/[locale]/docs/[...slug]/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { ContentRenderer, createCmsClient } from '@kit/cms';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
const getPageBySlug = cache(pageLoader);
|
||||
|
||||
interface DocumentationPageProps {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
async function pageLoader(slug: string) {
|
||||
const client = await createCmsClient();
|
||||
|
||||
return client.getContentItemBySlug({ slug, collection: 'documentation' });
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: DocumentationPageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug.join('/');
|
||||
const page = await getPageBySlug(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, description } = page;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
async function DocumentationPage({ params }: DocumentationPageProps) {
|
||||
const slug = (await params).slug.join('/');
|
||||
const page = await getPageBySlug(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const description = page?.description ?? '';
|
||||
|
||||
return (
|
||||
<div className={'container flex flex-1 flex-col gap-y-4'}>
|
||||
<div className={'flex flex-1'}>
|
||||
<div className="relative mx-auto max-w-3xl flex-1 flex-col overflow-x-hidden">
|
||||
<article
|
||||
className={cn('mx-auto h-full w-full flex-1 gap-y-12 pt-4 pb-36')}
|
||||
>
|
||||
<section className={'mt-4 flex flex-col gap-y-1 pb-4'}>
|
||||
<h1
|
||||
className={
|
||||
'text-foreground text-3xl font-semibold tracking-tighter'
|
||||
}
|
||||
>
|
||||
{page.title}
|
||||
</h1>
|
||||
|
||||
<h2 className={'text-secondary-foreground/80 text-lg'}>
|
||||
{description}
|
||||
</h2>
|
||||
</section>
|
||||
|
||||
<div className={'markdoc'}>
|
||||
<ContentRenderer content={page.content} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentationPage;
|
||||
33
apps/web/app/[locale]/docs/_components/docs-back-button.tsx
Normal file
33
apps/web/app/[locale]/docs/_components/docs-back-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { getSafeRedirectPath } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
export function DocsBackButton() {
|
||||
const searchParams = useSearchParams();
|
||||
const returnPath = searchParams.get('returnPath');
|
||||
const parsedPath = getSafeRedirectPath(returnPath, '/');
|
||||
|
||||
return (
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="link"
|
||||
render={
|
||||
<Link href={parsedPath || '/'}>
|
||||
<ArrowLeftIcon className="size-4" />{' '}
|
||||
<span className={'hidden sm:block'}>
|
||||
<Trans i18nKey="common.back" values={{ product: appConfig.name }} />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
apps/web/app/[locale]/docs/_components/docs-card.tsx
Normal file
32
apps/web/app/[locale]/docs/_components/docs-card.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function DocsCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
link,
|
||||
}: React.PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
link: { url: string; label?: string };
|
||||
}>) {
|
||||
return (
|
||||
<Link href={link.url} className="flex flex-col">
|
||||
<div
|
||||
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}
|
||||
</h3>
|
||||
|
||||
{subtitle && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p dangerouslySetInnerHTML={{ __html: subtitle }}></p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className="text-sm">{children}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
24
apps/web/app/[locale]/docs/_components/docs-cards.tsx
Normal file
24
apps/web/app/[locale]/docs/_components/docs-cards.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
|
||||
import { DocsCard } from './docs-card';
|
||||
|
||||
export function DocsCards({ cards }: { cards: Cms.ContentItem[] }) {
|
||||
const cardsSortedByOrder = [...cards].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className={'absolute flex w-full flex-col gap-4 pb-48 lg:max-w-2xl'}>
|
||||
{cardsSortedByOrder.map((item) => {
|
||||
return (
|
||||
<DocsCard
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
subtitle={item.description}
|
||||
link={{
|
||||
url: `/docs/${item.slug}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/web/app/[locale]/docs/_components/docs-header.tsx
Normal file
36
apps/web/app/[locale]/docs/_components/docs-header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Header } from '@kit/ui/marketing';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
import { DocsBackButton } from './docs-back-button';
|
||||
|
||||
export function DocsHeader() {
|
||||
return (
|
||||
<Header
|
||||
logo={
|
||||
<div className={'flex w-full flex-1 justify-between'}>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<AppLogo href="/" />
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<Link
|
||||
href="/help"
|
||||
className="font-semibold tracking-tight hover:underline"
|
||||
>
|
||||
<Trans i18nKey="marketing.documentation" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DocsBackButton />
|
||||
</div>
|
||||
}
|
||||
centered={false}
|
||||
className="border-border/50 border-b px-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/app/[locale]/docs/_components/docs-nav-link.tsx
Normal file
30
apps/web/app/[locale]/docs/_components/docs-nav-link.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar';
|
||||
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||
|
||||
export function DocsNavLink({
|
||||
label,
|
||||
url,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ label: string; url: string }>) {
|
||||
const currentPath = usePathname();
|
||||
const isCurrent = isRouteActive(url, currentPath);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
render={<Link href={url} />}
|
||||
isActive={isCurrent}
|
||||
className={cn('text-secondary-foreground transition-all')}
|
||||
>
|
||||
<span className="block max-w-full truncate">{label}</span>
|
||||
|
||||
{children}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { Collapsible } from '@kit/ui/collapsible';
|
||||
import { cn, isRouteActive } from '@kit/ui/utils';
|
||||
|
||||
export function DocsNavigationCollapsible(
|
||||
props: React.PropsWithChildren<{
|
||||
node: Cms.ContentItem;
|
||||
prefix: string;
|
||||
}>,
|
||||
) {
|
||||
const currentPath = usePathname();
|
||||
const prefix = props.prefix;
|
||||
|
||||
const isChildActive = props.node.children.some((child) =>
|
||||
isRouteActive(prefix + '/' + child.url, currentPath),
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('group/collapsible', {
|
||||
'group/active': isChildActive,
|
||||
})}
|
||||
defaultOpen={isChildActive ? true : !props.node.collapsed}
|
||||
>
|
||||
{props.children}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
149
apps/web/app/[locale]/docs/_components/docs-navigation.tsx
Normal file
149
apps/web/app/[locale]/docs/_components/docs-navigation.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { CollapsibleContent, CollapsibleTrigger } from '@kit/ui/collapsible';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
} from '@kit/ui/sidebar';
|
||||
|
||||
import { DocsNavLink } from '../_components/docs-nav-link';
|
||||
import { DocsNavigationCollapsible } from '../_components/docs-navigation-collapsible';
|
||||
import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button';
|
||||
|
||||
function Node({
|
||||
node,
|
||||
level,
|
||||
prefix,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
level: number;
|
||||
prefix: string;
|
||||
}) {
|
||||
const url = `${prefix}/${node.slug}`;
|
||||
const label = node.label ? node.label : node.title;
|
||||
|
||||
return (
|
||||
<NodeContainer node={node} prefix={prefix}>
|
||||
<NodeTrigger node={node} label={label} url={url} />
|
||||
|
||||
<NodeContentContainer node={node}>
|
||||
<Tree pages={node.children ?? []} level={level + 1} prefix={prefix} />
|
||||
</NodeContentContainer>
|
||||
</NodeContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeContentContainer({
|
||||
node,
|
||||
children,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return <CollapsibleContent>{children}</CollapsibleContent>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function NodeContainer({
|
||||
node,
|
||||
prefix,
|
||||
children,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
prefix: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return (
|
||||
<DocsNavigationCollapsible node={node} prefix={prefix}>
|
||||
{children}
|
||||
</DocsNavigationCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function NodeTrigger({
|
||||
node,
|
||||
label,
|
||||
url,
|
||||
}: {
|
||||
node: Cms.ContentItem;
|
||||
label: string;
|
||||
url: string;
|
||||
}) {
|
||||
if (node.collapsible) {
|
||||
return (
|
||||
<CollapsibleTrigger render={<SidebarMenuItem />}>
|
||||
<SidebarMenuButton>
|
||||
{label}
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocsNavLink label={label} url={url} />;
|
||||
}
|
||||
|
||||
function Tree({
|
||||
pages,
|
||||
level,
|
||||
prefix,
|
||||
}: {
|
||||
pages: Cms.ContentItem[];
|
||||
level: number;
|
||||
prefix: string;
|
||||
}) {
|
||||
if (level === 0) {
|
||||
return pages.map((treeNode, index) => (
|
||||
<Node key={index} node={treeNode} level={level} prefix={prefix} />
|
||||
));
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSub>
|
||||
{pages.map((treeNode, index) => (
|
||||
<Node key={index} node={treeNode} level={level} prefix={prefix} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsNavigation({
|
||||
pages,
|
||||
prefix = '/docs',
|
||||
}: {
|
||||
pages: Cms.ContentItem[];
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant={'floating'}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className={'pb-48'}>
|
||||
<Tree pages={pages} level={0} prefix={prefix} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
|
||||
<FloatingDocumentationNavigationButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { useSidebar } from '@kit/ui/sidebar';
|
||||
|
||||
export function FloatingDocumentationNavigationButton() {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className={
|
||||
'bg-primary fixed right-5 bottom-5 z-10 h-16! w-16! rounded-full! lg:hidden'
|
||||
}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Menu className={'text-primary-foreground size-6'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/[locale]/docs/_lib/server/docs.loader.ts
Normal file
31
apps/web/app/[locale]/docs/_lib/server/docs.loader.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { createCmsClient } from '@kit/cms';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
/**
|
||||
* @name getDocs
|
||||
* @description Load the documentation pages.
|
||||
* @param language
|
||||
*/
|
||||
export const getDocs = cache(docsLoader);
|
||||
|
||||
async function docsLoader(language: string | undefined) {
|
||||
const cms = await createCmsClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
try {
|
||||
const data = await cms.getContentItems({
|
||||
collection: 'documentation',
|
||||
language,
|
||||
limit: Infinity,
|
||||
content: false,
|
||||
});
|
||||
|
||||
return data.items;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load documentation pages');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
33
apps/web/app/[locale]/docs/_lib/utils.ts
Normal file
33
apps/web/app/[locale]/docs/_lib/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
|
||||
/**
|
||||
* @name buildDocumentationTree
|
||||
* @description Build a tree structure for the documentation pages.
|
||||
* @param pages
|
||||
*/
|
||||
export function buildDocumentationTree(pages: Cms.ContentItem[]) {
|
||||
const tree: Cms.ContentItem[] = [];
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page.parentId) {
|
||||
const parent = pages.find((item) => item.slug === page.parentId);
|
||||
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
parent.children.push(page);
|
||||
|
||||
// sort children by order
|
||||
parent.children.sort((a, b) => a.order - b.order);
|
||||
} else {
|
||||
tree.push(page);
|
||||
}
|
||||
});
|
||||
|
||||
return tree.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
49
apps/web/app/[locale]/docs/layout.tsx
Normal file
49
apps/web/app/[locale]/docs/layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getLocale } from 'next-intl/server';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@kit/ui/sidebar';
|
||||
|
||||
import { DocsHeader } from './_components/docs-header';
|
||||
// local imports
|
||||
import { DocsNavigation } from './_components/docs-navigation';
|
||||
import { getDocs } from './_lib/server/docs.loader';
|
||||
import { buildDocumentationTree } from './_lib/utils';
|
||||
|
||||
type DocsLayoutProps = React.PropsWithChildren<{
|
||||
params: Promise<{ locale?: string }>;
|
||||
}>;
|
||||
|
||||
async function DocsLayout({ children, params }: DocsLayoutProps) {
|
||||
let { locale } = await params;
|
||||
|
||||
if (!locale) {
|
||||
locale = await getLocale();
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '300px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<DocsSidebar locale={locale} />
|
||||
|
||||
<SidebarInset className="h-screen overflow-y-auto overscroll-y-none">
|
||||
<DocsHeader />
|
||||
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function DocsSidebar({ locale }: { locale: string }) {
|
||||
const pages = await getDocs(locale);
|
||||
const tree = buildDocumentationTree(pages);
|
||||
|
||||
return <DocsNavigation pages={tree} />;
|
||||
}
|
||||
|
||||
export default DocsLayout;
|
||||
54
apps/web/app/[locale]/docs/page.tsx
Normal file
54
apps/web/app/[locale]/docs/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
import { SitePageHeader } from '../(marketing)/_components/site-page-header';
|
||||
import { DocsCards } from './_components/docs-cards';
|
||||
import { getDocs } from './_lib/server/docs.loader';
|
||||
|
||||
type DocsPageProps = {
|
||||
params: Promise<{ locale?: string }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const t = await getTranslations('marketing');
|
||||
|
||||
return {
|
||||
title: t('documentation'),
|
||||
};
|
||||
};
|
||||
|
||||
async function DocsPage({ params }: DocsPageProps) {
|
||||
const t = await getTranslations('marketing');
|
||||
let { locale } = await params;
|
||||
|
||||
if (!locale) {
|
||||
locale = await getLocale();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-1 flex-col gap-y-6 xl:gap-y-8'}>
|
||||
<SitePageHeader
|
||||
title={t('documentation')}
|
||||
subtitle={t('documentationSubtitle')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'relative container flex size-full justify-center overflow-y-auto'
|
||||
}
|
||||
>
|
||||
<DocaCardsList locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function DocaCardsList({ locale }: { locale: string }) {
|
||||
const items = await getDocs(locale);
|
||||
|
||||
// Filter out any docs that have a parentId, as these are children of other docs
|
||||
const cards = items.filter((item) => !item.parentId);
|
||||
|
||||
return <DocsCards cards={cards} />;
|
||||
}
|
||||
|
||||
export default DocsPage;
|
||||
Reference in New Issue
Block a user