9f61322604
Blog's own hero already used text-display; ShopHeader.tsx used the smaller text-h-feature, reading as inconsistent next to it. Both now use text-display. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
231 lines
12 KiB
TypeScript
231 lines
12 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 { 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);
|
||
}
|
||
|
||
function buildCategoryHref(active: string[], category: string): string {
|
||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||
}
|
||
|
||
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 && (
|
||
<div className="flex flex-wrap gap-2 w-full">
|
||
{allCategories.map((category) => {
|
||
const active = activeCategories.includes(category);
|
||
return (
|
||
<Link
|
||
key={category}
|
||
href={buildCategoryHref(activeCategories, category)}
|
||
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
|
||
// on bg-base (#f8f5f1) is a ~2% lightness difference,
|
||
// nearly invisible as a pill outline; a filled muted
|
||
// background makes the chip read as a discrete control
|
||
// regardless of the border's own low contrast.
|
||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
|
||
active
|
||
? "bg-brand border-brand text-text-primary"
|
||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||
}`}
|
||
>
|
||
{category}
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</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 />
|
||
</>
|
||
);
|
||
}
|