TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -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>
);
}
@@ -0,0 +1,42 @@
import { breadcrumbListJsonLd, JsonLdScript } from '#web-runtime/lib/structured-data';
import type { Breadcrumb } from '#web-runtime/types';
import { toPublicHref } from '#i18n/content-href';
export default function Breadcrumbs({ items }: { items: Breadcrumb[] }) {
if (items.length <= 1) {
return null;
}
return (
<nav aria-label="Breadcrumb" className="border-b border-[#d6d7cf]">
<ol className="mx-auto flex w-[min(calc(100%-2rem),76rem)] flex-wrap items-center gap-x-2 gap-y-1 py-3.5 font-mono text-xs font-medium tracking-[0.02em] text-[#5d675f] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={`${item.href}-${index}`} className="flex items-center gap-2">
{index > 0 ? (
<span aria-hidden="true" className="text-[#aab4aa]">
/
</span>
) : null}
{isLast ? (
<span aria-current="page" className="text-[#172019]">
{item.label}
</span>
) : (
<a
href={toPublicHref(item.href)}
className="rounded-sm underline-offset-4 transition-colors hover:text-[#172019] hover:underline focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
>
{item.label}
</a>
)}
</li>
);
})}
</ol>
<JsonLdScript data={breadcrumbListJsonLd(items)} />
</nav>
);
}
@@ -0,0 +1,20 @@
import { PageBlocks } from '#web-runtime/blocks/registry';
import { articleJsonLd, JsonLdScript } from '#web-runtime/lib/structured-data';
import type { PageDocument } from '#web-runtime/types';
import Breadcrumbs from './Breadcrumbs';
import PageHero from './PageHero';
export default function EditorialPageLayout({ page }: { page: PageDocument }) {
const article = articleJsonLd(page);
return (
<main className="flex-1" data-page-layout={page.layout}>
{article && <JsonLdScript data={article} />}
<Breadcrumbs items={page.breadcrumbs} />
<PageHero page={page} />
<div className="[&_header]:max-w-184 [&_section_div.text-base]:max-w-184">
<PageBlocks blocks={page.blocks} />
</div>
</main>
);
}
@@ -0,0 +1,89 @@
import { LinkList } from '#web-runtime/components/links';
import { MediaImage } from '#web-runtime/components/media-image';
import { RichTextContent } from '#web-runtime/components/rich-text';
import type { PageDocument } from '#web-runtime/types';
export default function PageHero({ page }: { page: PageDocument }) {
const hero = page.hero;
if (hero?.type === 'none') {
return null;
}
const isEditorial = ['blog', 'blog-item'].includes(page.layout);
const containerClasses = `mx-auto w-[min(calc(100%_-_2rem),76rem)] max-[45rem]:w-[min(calc(100%_-_1.25rem),76rem)]`;
const eyebrowClasses =
'mb-5 font-mono text-xs font-bold tracking-[0.12em] text-[#517000] uppercase';
const titleClasses = `max-w-[15ch] font-sans font-semibold tracking-[-0.065em] leading-[0.98] ${
hero?.type === 'low'
? 'text-[clamp(2.4rem,4.7vw,4.5rem)]'
: hero?.type === 'middle'
? 'text-[clamp(2.7rem,5.4vw,5.2rem)]'
: 'text-[clamp(2.75rem,6.2vw,6rem)]'
}`;
if (!hero) {
return (
<section className="overflow-hidden border-b border-[#d6d7cf]">
<div
className={`${containerClasses} relative z-10 max-w-200 py-[clamp(2.75rem,6vw,5rem)] ${isEditorial ? 'max-w-184' : ''}`}
>
<p className={eyebrowClasses}>{page.layout.replace('-', ' ')}</p>
<h1 className="max-w-[15ch] text-[clamp(2.4rem,4.7vw,4.5rem)] leading-[0.98] font-semibold tracking-[-0.065em]">
{page.title}
</h1>
</div>
</section>
);
}
const hasMedia = Boolean(hero.media?.url);
const impactClasses = {
low: 'py-[clamp(2.75rem,6vw,5rem)]',
middle: 'py-[clamp(4rem,8vw,7rem)]',
high: 'min-h-[min(49rem,calc(100dvh-5rem))] py-[clamp(4rem,9vw,8rem)] max-lg:min-h-0'
}[hero.type];
return (
<section
className={`overflow-hidden border-b border-[#d6d7cf] ${
hero.type === 'high'
? 'bg-[radial-gradient(circle_at_85%_15%,rgb(199_255_63/23%),transparent_26rem)]'
: ''
}`}
data-hero-impact={hero.type}
>
<div
className={`${containerClasses} grid items-center gap-[clamp(2rem,5vw,5rem)] ${impactClasses} max-[45rem]:py-14 ${
hasMedia
? 'grid-cols-[minmax(0,1fr)_minmax(20rem,0.85fr)] max-lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.75fr)] max-[45rem]:grid-cols-1'
: ''
}`}
>
<div className={`relative z-10 max-w-200 ${isEditorial ? 'max-w-184' : ''}`}>
<p className={eyebrowClasses}>{page.layout.replace('-', ' ')}</p>
<h1 className={titleClasses}>{hero.title || page.title}</h1>
{hero.subtitle ? (
<p className="mt-6 max-w-152 text-[clamp(1.1rem,2vw,1.35rem)] leading-[1.55] text-[#5d675f]">
{hero.subtitle}
</p>
) : null}
<RichTextContent
value={hero.content}
className={`mt-5 max-w-156 ${isEditorial ? 'max-w-184' : ''}`}
/>
<LinkList links={hero.ctaItems} className="mt-8" />
</div>
{hasMedia ? (
<figure className="relative m-0 before:absolute before:-inset-3 before:rotate-2 before:rounded-[2.75rem] before:border before:border-[#172019]/15 before:content-['']">
<MediaImage
media={hero.media}
eager
className="relative aspect-4/5 rounded-4xl shadow-[0_1.5rem_4rem_rgb(23_32_25/12%)] max-[45rem]:aspect-4/3"
/>
</figure>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,12 @@
import { PageBlocks } from '#web-runtime/blocks/registry';
import type { PageDocument } from '#web-runtime/types';
import PageHero from './PageHero';
export default function StandardPageLayout({ page }: { page: PageDocument }) {
return (
<main className="flex-1" data-page-layout={page.layout}>
<PageHero page={page} />
<PageBlocks blocks={page.blocks} />
</main>
);
}
@@ -0,0 +1,15 @@
import type { PageSearchParams } from '#web-runtime/types';
export function requestedPage(searchParams?: PageSearchParams): number {
const raw = searchParams?.['page'];
const value = Number.parseInt(Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? ''), 10);
return Number.isNaN(value) || value < 1 ? 1 : value;
}
export function pagerPages(current: number, lastPage: number): number[] {
const pages = new Set<number>([1, lastPage]);
for (let page = current - 1; page <= current + 1; page++) {
if (page >= 1 && page <= lastPage) pages.add(page);
}
return [...pages].sort((left, right) => left - right);
}