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(); 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 ( <>
{/* 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). */}
{/* 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). */}

Startseite Blog

Blog

Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.

{/* 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 && ( )}
{posts.length === 0 && (

Keine Beiträge in dieser Kategorie gefunden.

)} {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. // key'd for the same reason as RevealGroup below — guards against // the same stuck-at-opacity-0 failure mode if a future filter // combination ever swaps in a *different* featured post without // an intervening moment where `featured` was falsy.
{featured.thumbnail && ( )}
{featured.categories.join(", ")} {featured.readTime} Min {formatDate(featured.publishedAt)}

{featured.title}

{featured.excerpt}

Zum Beitrag
)} {rest.length > 0 && ( // key'd on the rendered post set — RevealGroup's `whileInView` // only fires once per component instance (viewport.once=true), // and a category-filter change re-renders this same page // component in place (only `searchParams` differs, no full // remount). Since `rest.length > 0` stays true across most // filter transitions, RevealGroup itself never naturally // unmounts, so once it has already fired "show" for one // filter's list, freshly swapped-in RevealItems (new post ids) // mount into an already-settled parent that has no reason to // re-fire the reveal trigger — they were stuck at opacity 0 // forever, reported as the list appearing blank after refiltering. // Forcing a remount on every distinct post set restarts // RevealGroup's viewport tracking from scratch each time. post.id).join(",")} 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) => (
{post.thumbnail && ( )}
{post.categories.join(", ")} {post.readTime} Min {formatDate(post.publishedAt)}

{post.title}

{post.excerpt}

Zum Beitrag
))}
)}