TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { cn } from '@crux-kit/utils';
|
||||
import { getWebRuntimeAPI } from '#web-runtime/api/web-runtime-api';
|
||||
import { MediaImage } from '#web-runtime/components/media-image';
|
||||
import type { PageDocument, PageSearchParams, PageTeaser, PageTeaserPagination } from '#web-runtime/types';
|
||||
import { toPublicHref } from '#i18n/content-href';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { pagerPages, requestedPage } from './blog-pager';
|
||||
import Breadcrumbs from './Breadcrumbs';
|
||||
import PageHero from './PageHero';
|
||||
|
||||
const POSTS_PER_PAGE = 12;
|
||||
|
||||
function PostCard({
|
||||
post,
|
||||
locale,
|
||||
readMoreLabel
|
||||
}: {
|
||||
post: PageTeaser;
|
||||
locale: string;
|
||||
readMoreLabel: string;
|
||||
}) {
|
||||
const href = toPublicHref(`/${locale}/${post.path}`);
|
||||
const publishedAt = post.publishedAt ? new Date(post.publishedAt) : null;
|
||||
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col rounded-[1.25rem] border border-[#d6d7cf] bg-white/50 transition duration-150 hover:-translate-y-0.5 hover:border-[#172019]/35 motion-reduce:transform-none motion-reduce:transition-none">
|
||||
<a
|
||||
href={href}
|
||||
className="flex flex-1 flex-col gap-4 p-[clamp(1.25rem,2.5vw,1.75rem)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
>
|
||||
{post.media ? (
|
||||
<MediaImage media={post.media} className="aspect-8/5 rounded-xl" />
|
||||
) : (
|
||||
<div className="aspect-8/5 rounded-xl bg-[#e8e6dc]" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col items-start gap-2">
|
||||
{publishedAt && !Number.isNaN(publishedAt.getTime()) ? (
|
||||
<time
|
||||
dateTime={post.publishedAt ?? undefined}
|
||||
className="font-mono text-[0.7rem] font-bold tracking-[0.08em] text-[#5d675f] uppercase"
|
||||
>
|
||||
{new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(publishedAt)}
|
||||
</time>
|
||||
) : null}
|
||||
|
||||
<h2 className="text-[1.35rem] leading-[1.18] font-semibold tracking-[-0.035em]">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.description ? (
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-[#5d675f]">
|
||||
{post.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* The whole card is the link; this is its visual call to action. */}
|
||||
<Button asChild variant="inline" size="sm" className="mt-auto pt-2">
|
||||
<span aria-hidden="true">{readMoreLabel} →</span>
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
pagination,
|
||||
basePath,
|
||||
labels
|
||||
}: {
|
||||
pagination: PageTeaserPagination;
|
||||
basePath: string;
|
||||
labels: { pagination: string; previous: string; next: string };
|
||||
}) {
|
||||
if (pagination.lastPage <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageHref = (page: number) => (page <= 1 ? basePath : `${basePath}?page=${page}`);
|
||||
const controlClassName = 'size-11 p-0';
|
||||
const pages = pagerPages(pagination.page, pagination.lastPage);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="mt-[clamp(2.5rem,5vw,4rem)] flex flex-wrap items-center justify-center gap-2"
|
||||
aria-label={labels.pagination}
|
||||
>
|
||||
{pagination.page > 1 ? (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(pagination.page - 1)} rel="prev" aria-label={labels.previous}>
|
||||
←
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className={controlClassName} disabled aria-hidden="true">
|
||||
←
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{pages.map((page, index) => {
|
||||
const previousPage = pages[index - 1];
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2" key={page}>
|
||||
{previousPage !== undefined && page - previousPage > 1 ? (
|
||||
<span className="text-[#5d675f]" aria-hidden="true">
|
||||
…
|
||||
</span>
|
||||
) : null}
|
||||
{page === pagination.page ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="primary"
|
||||
className={cn(controlClassName, 'pointer-events-none')}
|
||||
>
|
||||
<span aria-current="page">{page}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(page)}>{page}</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{pagination.page < pagination.lastPage ? (
|
||||
<Button asChild variant="outline" className={controlClassName}>
|
||||
<a href={pageHref(pagination.page + 1)} rel="next" aria-label={labels.next}>
|
||||
→
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className={controlClassName} disabled aria-hidden="true">
|
||||
→
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BlogPageLayout({
|
||||
page,
|
||||
searchParams
|
||||
}: {
|
||||
page: PageDocument;
|
||||
searchParams?: PageSearchParams;
|
||||
}) {
|
||||
const t = await getTranslations('Pages.BlogPage');
|
||||
const { items: posts, pagination } = await getWebRuntimeAPI().listBlogPosts({
|
||||
locale: page.locale,
|
||||
page: requestedPage(searchParams),
|
||||
perPage: POSTS_PER_PAGE
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1" data-page-layout={page.layout}>
|
||||
<Breadcrumbs items={page.breadcrumbs} />
|
||||
<PageHero page={page} />
|
||||
|
||||
<section
|
||||
className="border-b border-[#d6d7cf] py-[clamp(4.5rem,9vw,8rem)]"
|
||||
data-page-block="blog-posts"
|
||||
>
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-lg text-[#5d675f]">{t('empty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-[45rem]:grid-cols-1">
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
locale={page.locale}
|
||||
readMoreLabel={t('readMore')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pager
|
||||
pagination={pagination}
|
||||
basePath={toPublicHref(`/${page.locale}/${page.path}`)}
|
||||
labels={{
|
||||
pagination: t('pagination'),
|
||||
previous: t('previous'),
|
||||
next: t('next')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user