Files
einfach-produktiv/app/blog/page.tsx
T
Marco a085a75dea Allow blog meta row (category/readtime/date) to wrap on narrow cards
Long category names no longer force horizontal overflow — the row
wraps to a second line instead, with each segment kept from breaking
mid-word via whitespace-nowrap.
2026-08-24 22:53:12 +00:00

225 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// 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.
<Reveal key={featured.id} 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 flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{featured.categories.join(", ")}</span>
<span></span>
<span className="whitespace-nowrap">{featured.readTime} Min</span>
<span></span>
<span className="uppercase whitespace-nowrap">{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 && (
// 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.
<RevealGroup
key={rest.map((post) => 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) => (
<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 flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{post.categories.join(", ")}</span>
<span></span>
<span className="whitespace-nowrap">{post.readTime} Min</span>
<span></span>
<span className="uppercase whitespace-nowrap">{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 />
</>
);
}