Files
einfach-produktiv/app/blog/page.tsx
T
Marco bc40e22910 Gate search behind CompanySettings.searchEnabled; fix two reported bugs
Search icon now gated behind the new backend toggle, same pattern as
wishlistEnabled — off by default.

Fixed: blog category filter bar was invisible — it was wrapped in
<Reveal>, which sits right at/just past the hero's bottom edge, the
exact "already near the initial viewport" position Reveal.tsx's own
comment documents as a whileInView(margin:"-80px") trap (an element
already visible without scrolling can permanently never register as
"entered view", since `once: true` never gets a second chance). Now a
plain div, no scroll-reveal animation needed for a filter bar anyway.

Fixed: wishlist count showing one behind on the Navbar badge —
useWishlist.ts's toggle() broadcast the "refetch me" event immediately
after the optimistic local update, before the POST request was even
sent. Another instance's resulting refetch could race the actual
server-side write, get the pre-toggle list, and then never get told to
refetch again. Broadcast now only fires after the request settles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:51:37 +00:00

219 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 } 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 = await getBlogPosts(100);
const { categories: categoriesParam } = await searchParams;
const activeCategories = categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
const allCategories = 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>
</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>
{/* Plain div, not <Reveal> — this bar sits right at/just past the
hero's bottom edge, exactly the "already near the initial
viewport top" position Reveal.tsx's own comment documents as a
whileInView(margin:"-80px") trap: the shrunk viewport can
permanently miss triggering "entered view" for an element
that's already visible without any further scroll, since
`once: true` never gets a second chance. The Hero brand dot
hit the identical bug and switched to a plain animate — this
filter bar doesn't need a scroll-reveal animation at all, so
it's simplest to just not wrap it in Reveal in the first place. */}
{allCategories.length > 1 && (
<div className="flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
<Link
key={category}
href={buildCategoryHref(activeCategories, category)}
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-base border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{category}
</Link>
);
})}
{activeCategories.length > 0 && (
<Link
href="/blog"
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</Link>
)}
</div>
)}
{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 />
</>
);
}