e49dacf057
SearchOverlay was rendered as a <header> descendant, so the header's conditional backdrop-blur-md (once scrolled) made it the containing block for the overlay's fixed positioning, clipping the opaque background to the header's height and letting page content show through underneath. Now rendered as a header sibling, same pattern already used for NewsletterModal. Blog category chips are now a client component gating navigation behind useTransition, disabling the chips while a navigation is pending so rapid clicks can't fire overlapping RSC navigations that commit out of order and briefly show an empty result.
205 lines
10 KiB
TypeScript
205 lines
10 KiB
TypeScript
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import Image from "next/image";
|
||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||
import { Newsletter } from "../components/Newsletter";
|
||
import { Footer } from "../components/Footer";
|
||
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
|
||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||
import { formatDate } from "../lib/format";
|
||
|
||
export const metadata: Metadata = {
|
||
title: "Blog",
|
||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||
alternates: { canonical: "/blog" },
|
||
openGraph: {
|
||
title: "Blog | einfach produktiv.",
|
||
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
|
||
url: "/blog",
|
||
type: "website",
|
||
images: ["/blog-featured.jpg"],
|
||
},
|
||
};
|
||
|
||
// Every distinct category name in DOM order — Payload's `categories` field
|
||
// stores related docs, no separate slug on this side, but since this page
|
||
// already fetches every published post (limit 100, no pagination), a
|
||
// plain client-agnostic array filter is enough; no separate Payload query
|
||
// per category needed.
|
||
function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||
const seen = new Set<string>();
|
||
for (const post of posts) for (const c of post.categories) seen.add(c);
|
||
return Array.from(seen);
|
||
}
|
||
|
||
export default async function BlogOverviewPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{ categories?: string }>;
|
||
}) {
|
||
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
|
||
const { categories: categoriesParam } = await searchParams;
|
||
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
|
||
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
|
||
const posts =
|
||
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
|
||
// getBlogPosts sorts "-featured,-publishedAt", so index 0 IS the actual
|
||
// featured post when the unfiltered list is shown — but a category
|
||
// filter can (and often will) exclude it entirely, in which case index 0
|
||
// is just the newest matching post, not an editorially featured one. It
|
||
// still doesn't deserve the big hero-card treatment (implies "the
|
||
// featured post", not "whatever sorted first"), so that layout is gated
|
||
// on the post's own `featured` flag, not on array position.
|
||
const [first, ...restAll] = posts;
|
||
const featured = first?.featured ? first : undefined;
|
||
const rest = featured ? restAll : posts;
|
||
|
||
return (
|
||
<>
|
||
<main className="flex flex-col flex-1 bg-bg-base">
|
||
{/* Header — copy left, photo bleeds to the viewport edge on the
|
||
right with a left-edge fade into bg-base, matching Figma's
|
||
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
|
||
<Reveal className="relative flex flex-col sm:flex-row items-center w-full min-h-[20rem] sm:min-h-[27.5rem] border-b border-border overflow-hidden">
|
||
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 sm:py-0 w-full sm:w-auto sm:max-w-[26rem]">
|
||
{/* Breadcrumb + text-h-feature heading — same treatment as
|
||
/shop's ShopHeader.tsx and every legal page, this hero was
|
||
the one page missing both (its own bespoke photo-bleed
|
||
layout predates that convention being established
|
||
elsewhere). */}
|
||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||
<span>›</span>
|
||
<span className="text-text-primary">Blog</span>
|
||
</p>
|
||
<p
|
||
className="font-semibold text-display text-text-primary"
|
||
style={{ fontFamily: "var(--font-lora)" }}
|
||
>
|
||
Blog
|
||
</p>
|
||
<p className="text-body text-text-muted">
|
||
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
|
||
</p>
|
||
{/* Moved into the hero's own text column (was a separate full-
|
||
width bar below the entire hero, including the photo) — on
|
||
mobile especially (text stacks above the photo here), that
|
||
pushed the filters below a full extra screen's worth of
|
||
hero image before they were even visible. Living right
|
||
under the description keeps them in view immediately, on
|
||
every width, no scrolling past the photo needed. */}
|
||
{allCategories.length > 1 && (
|
||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||
)}
|
||
</div>
|
||
<div className="relative w-full sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
|
||
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
|
||
<div className="hidden sm:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
|
||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
|
||
</div>
|
||
</Reveal>
|
||
|
||
{posts.length === 0 && (
|
||
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
|
||
Keine Beiträge in dieser Kategorie gefunden.
|
||
</p>
|
||
)}
|
||
|
||
{featured && (
|
||
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
|
||
// hero section's bottom edge instead of sitting flush below it.
|
||
// relative z-10 keeps it stacked above the hero's own photo.
|
||
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
|
||
<Link
|
||
href={`/blog/${featured.slug}`}
|
||
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
|
||
>
|
||
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
|
||
{featured.thumbnail && (
|
||
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
|
||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||
<span>{featured.categories.join(", ")}</span>
|
||
<span>•</span>
|
||
<span>{featured.readTime} Min</span>
|
||
<span>•</span>
|
||
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
|
||
</div>
|
||
<p
|
||
className="font-semibold text-h-section text-text-primary"
|
||
style={{ fontFamily: "var(--font-lora)" }}
|
||
>
|
||
{featured.title}
|
||
</p>
|
||
<p className="text-body text-text-muted line-clamp-3">{featured.excerpt}</p>
|
||
<span className="flex items-center gap-1.5 font-bold text-body text-text-primary mt-1">
|
||
Zum Beitrag
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
className="size-4 transition-transform duration-200 group-hover:translate-x-1"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
</Reveal>
|
||
)}
|
||
|
||
{rest.length > 0 && (
|
||
<RevealGroup className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border">
|
||
{rest.map((post) => (
|
||
<RevealItem key={post.id} className="py-8 first:pt-0 last:pb-0">
|
||
<Link href={`/blog/${post.slug}`} className="group flex flex-col sm:flex-row gap-6 items-start">
|
||
<div className="relative w-full sm:w-[18.75rem] aspect-video sm:aspect-[300/192] shrink-0 rounded-md overflow-hidden bg-bg-muted">
|
||
{post.thumbnail && (
|
||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 640px) 18.75rem, 100vw" className="object-cover" />
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||
<span>{post.categories.join(", ")}</span>
|
||
<span>•</span>
|
||
<span>{post.readTime} Min</span>
|
||
<span>•</span>
|
||
<span className="uppercase">{formatDate(post.publishedAt)}</span>
|
||
</div>
|
||
<p
|
||
className="font-semibold text-h-small text-text-primary"
|
||
style={{ fontFamily: "var(--font-lora)" }}
|
||
>
|
||
{post.title}
|
||
</p>
|
||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||
<span className="flex items-center gap-1.5 font-bold text-body-sm text-text-primary mt-1">
|
||
Zum Beitrag
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
</RevealItem>
|
||
))}
|
||
</RevealGroup>
|
||
)}
|
||
|
||
<Newsletter
|
||
title="Neue Impulse im richtigen Moment."
|
||
description="Melde dich zum Newsletter an und erhalte regelmäßig Gedanken, Methoden und praktische Tipps."
|
||
/>
|
||
</main>
|
||
<Footer />
|
||
</>
|
||
);
|
||
}
|