Add initial project files
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PageDocument, PageSearchParams } from '#web-runtime/types';
|
||||
import BlogPageLayout from './components/BlogPageLayout';
|
||||
import EditorialPageLayout from './components/EditorialPageLayout';
|
||||
import StandardPageLayout from './components/StandardPageLayout';
|
||||
|
||||
export interface PageLayoutProps {
|
||||
page: PageDocument;
|
||||
searchParams?: PageSearchParams;
|
||||
}
|
||||
|
||||
type PageLayout = ComponentType<PageLayoutProps>;
|
||||
|
||||
export const PAGE_LAYOUT_REGISTRY: Readonly<Record<string, PageLayout>> = {
|
||||
'landing': StandardPageLayout,
|
||||
'blog': BlogPageLayout,
|
||||
'blog-item': EditorialPageLayout
|
||||
};
|
||||
|
||||
export function RegisteredPageLayout({ page, searchParams }: PageLayoutProps) {
|
||||
const Layout = PAGE_LAYOUT_REGISTRY[page.layout] ?? StandardPageLayout;
|
||||
|
||||
return <Layout page={page} searchParams={searchParams} />;
|
||||
}
|
||||
Reference in New Issue
Block a user