Files
einfach-produktiv/app/blog/page.tsx
T
Marco bf6c5d079a Fix order-filter dropdowns, restructure Konto identity line, drop blog reset link
- CustomSelect.tsx: mousedown on an option blurred the trigger button
  before the click landed, unmounting the list before anything could be
  selected — add preventDefault on the list's mousedown to stop that.
- KontoShell no longer renders "Eingeloggt als ..." above each page's
  content; each page renders its own title then AccountIdentity right
  below it, and ProfileForm's now-duplicate email/Kundennummer line is
  removed.
- Blog category filter: drop the separate "Zurücksetzen" link — clicking
  an active category badge again already deactivates it.
- Werkzeuge cards: more vertical gap between stacked cards below sm:.
- Legal pages' "Stand: ..." line is now derived from the LegalPages doc's
  own updatedAt instead of a hand-typed string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:29:36 +00:00

212 lines
11 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)));
const [featured, ...rest] = 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]">
<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 />
</>
);
}