B2B-88: add starter kit structure and elements
This commit is contained in:
78
app/(marketing)/blog/[slug]/page.tsx
Normal file
78
app/(marketing)/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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 { Post } from '../../blog/_components/post';
|
||||
|
||||
interface BlogPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const getPostBySlug = cache(postLoader);
|
||||
|
||||
async function postLoader(slug: string) {
|
||||
const client = await createCmsClient();
|
||||
|
||||
return client.getContentItemBySlug({ slug, collection: 'posts' });
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: BlogPageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
const post = await getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { title, publishedAt, description, image } = post;
|
||||
|
||||
return Promise.resolve({
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: publishedAt,
|
||||
url: post.url,
|
||||
images: image
|
||||
? [
|
||||
{
|
||||
url: image,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: image ? [image] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function BlogPost({ params }: BlogPageProps) {
|
||||
const slug = (await params).slug;
|
||||
const post = await getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'container sm:max-w-none sm:p-0'}>
|
||||
<Post post={post} content={post.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPost);
|
||||
58
app/(marketing)/blog/_components/blog-pagination.tsx
Normal file
58
app/(marketing)/blog/_components/blog-pagination.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function BlogPagination(props: {
|
||||
currentPage: number;
|
||||
canGoToNextPage: boolean;
|
||||
canGoToPreviousPage: boolean;
|
||||
}) {
|
||||
const navigate = useGoToPage();
|
||||
|
||||
return (
|
||||
<div className={'flex items-center space-x-2'}>
|
||||
<If condition={props.canGoToPreviousPage}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
navigate(props.currentPage - 1);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className={'mr-2 h-4'} />
|
||||
<Trans i18nKey={'marketing:blogPaginationPrevious'} />
|
||||
</Button>
|
||||
</If>
|
||||
|
||||
<If condition={props.canGoToNextPage}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
onClick={() => {
|
||||
navigate(props.currentPage + 1);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey={'marketing:blogPaginationNext'} />
|
||||
<ArrowRight className={'ml-2 h-4'} />
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useGoToPage() {
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
return (page: number) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
});
|
||||
|
||||
router.push(path + '?' + searchParams.toString());
|
||||
};
|
||||
}
|
||||
28
app/(marketing)/blog/_components/cover-image.tsx
Normal file
28
app/(marketing)/blog/_components/cover-image.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
src: string;
|
||||
preloadImage?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CoverImage({ title, src, preloadImage, className }: Props) {
|
||||
return (
|
||||
<Image
|
||||
className={cn(
|
||||
'block rounded-xl object-cover duration-250' +
|
||||
' transition-all hover:opacity-90',
|
||||
{
|
||||
className,
|
||||
},
|
||||
)}
|
||||
src={src}
|
||||
priority={preloadImage}
|
||||
alt={`Cover Image for ${title}`}
|
||||
fill
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
app/(marketing)/blog/_components/date-formatter.tsx
Normal file
11
app/(marketing)/blog/_components/date-formatter.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
type Props = {
|
||||
dateString: string;
|
||||
};
|
||||
|
||||
export const DateFormatter = ({ dateString }: Props) => {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return <time dateTime={dateString}>{format(date, 'PP')}</time>;
|
||||
};
|
||||
9
app/(marketing)/blog/_components/draft-post-badge.tsx
Normal file
9
app/(marketing)/blog/_components/draft-post-badge.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function DraftPostBadge({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<span className="dark:text-dark-800 rounded-md bg-yellow-200 px-4 py-2 font-semibold">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
50
app/(marketing)/blog/_components/post-header.tsx
Normal file
50
app/(marketing)/blog/_components/post-header.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CoverImage } from './cover-image';
|
||||
import { DateFormatter } from './date-formatter';
|
||||
|
||||
export function PostHeader({ post }: { post: Cms.ContentItem }) {
|
||||
const { title, publishedAt, description, image } = post;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<div className={cn('border-b py-8')}>
|
||||
<div className={'mx-auto flex max-w-3xl flex-col space-y-4'}>
|
||||
<h1
|
||||
className={
|
||||
'font-heading text-3xl font-semibold tracking-tighter xl:text-5xl dark:text-white'
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className={'text-muted-foreground text-base xl:text-lg'}
|
||||
dangerouslySetInnerHTML={{ __html: description ?? '' }}
|
||||
></h2>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
65
app/(marketing)/blog/_components/post-preview.tsx
Normal file
65
app/(marketing)/blog/_components/post-preview.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Cms } from '@kit/cms';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
|
||||
|
||||
type Props = {
|
||||
post: Cms.ContentItem;
|
||||
preloadImage?: boolean;
|
||||
imageHeight?: string | number;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_HEIGHT = 250;
|
||||
|
||||
export function PostPreview({
|
||||
post,
|
||||
preloadImage,
|
||||
imageHeight,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const { title, image, publishedAt, description } = post;
|
||||
const height = imageHeight ?? DEFAULT_IMAGE_HEIGHT;
|
||||
|
||||
const slug = `/blog/${post.slug}`;
|
||||
|
||||
return (
|
||||
<div className="transition-shadow-sm flex flex-col gap-y-4 rounded-lg duration-500">
|
||||
<If condition={image}>
|
||||
{(imageUrl) => (
|
||||
<div className="relative mb-2 w-full" style={{ height }}>
|
||||
<Link href={slug}>
|
||||
<CoverImage
|
||||
preloadImage={preloadImage}
|
||||
title={title}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4 px-1'}>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<h2 className="text-xl leading-snug font-semibold tracking-tight">
|
||||
<Link href={slug} className="hover:underline">
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-3 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
<DateFormatter dateString={publishedAt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mb-4 text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: description ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
app/(marketing)/blog/_components/post.tsx
Normal file
24
app/(marketing)/blog/_components/post.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Cms } from '@kit/cms';
|
||||
import { ContentRenderer } from '@kit/cms';
|
||||
|
||||
import { PostHeader } from './post-header';
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
content,
|
||||
}: {
|
||||
post: Cms.ContentItem;
|
||||
content: unknown;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<PostHeader post={post} />
|
||||
|
||||
<div className={'mx-auto flex max-w-3xl flex-col space-y-6 py-8'}>
|
||||
<article className="markdoc">
|
||||
<ContentRenderer content={content} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
app/(marketing)/blog/page.tsx
Normal file
105
app/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
// local imports
|
||||
import { SitePageHeader } from '../_components/site-page-header';
|
||||
import { BlogPagination } from './_components/blog-pagination';
|
||||
import { PostPreview } from './_components/post-preview';
|
||||
|
||||
interface BlogPageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const { t } = await createI18nServerInstance();
|
||||
|
||||
return {
|
||||
title: t('marketing:blog'),
|
||||
description: t('marketing:blogSubtitle'),
|
||||
};
|
||||
};
|
||||
|
||||
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: 'posts',
|
||||
limit,
|
||||
offset,
|
||||
language,
|
||||
content: false,
|
||||
sortBy: 'publishedAt',
|
||||
sortDirection: 'desc',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to load blog posts');
|
||||
|
||||
return { total: 0, items: [] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function BlogPage(props: BlogPageProps) {
|
||||
const { t, resolvedLanguage: language } = await createI18nServerInstance();
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const page = searchParams.page ? parseInt(searchParams.page) : 0;
|
||||
const limit = 10;
|
||||
const offset = page * limit;
|
||||
|
||||
const { total, items: posts } = await getContentItems(
|
||||
language,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SitePageHeader
|
||||
title={t('marketing:blog')}
|
||||
subtitle={t('marketing:blogSubtitle')}
|
||||
/>
|
||||
|
||||
<div className={'container flex flex-col space-y-6 py-12'}>
|
||||
<If
|
||||
condition={posts.length > 0}
|
||||
fallback={<Trans i18nKey="marketing:noPosts" />}
|
||||
>
|
||||
<PostsGridList>
|
||||
{posts.map((post, idx) => {
|
||||
return <PostPreview key={idx} post={post} />;
|
||||
})}
|
||||
</PostsGridList>
|
||||
|
||||
<div>
|
||||
<BlogPagination
|
||||
currentPage={page}
|
||||
canGoToNextPage={offset + limit < total}
|
||||
canGoToPreviousPage={page > 0}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(BlogPage);
|
||||
|
||||
function PostsGridList({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-y-8 md:grid-cols-2 md:gap-x-8 md:gap-y-12 lg:grid-cols-3 lg:gap-x-12">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user