821fbb0207
Same breadcrumb/title/description block as every other page, no more bespoke photo-bleed layout. Featured post card goes back to sitting flush below the header (no more negative-margin overlap trick, which only made sense with the photo's bottom edge to overlap).
203 lines
10 KiB
TypeScript
203 lines
10 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 { 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 — plain text, same treatment as /shop's ShopHeader.tsx
|
||
and every legal page (the bespoke photo-bleed hero this used to
|
||
be predated that convention being established elsewhere). */}
|
||
<Reveal className="flex flex-col gap-4 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full border-b border-border">
|
||
<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>
|
||
{allCategories.length > 1 && (
|
||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||
)}
|
||
</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 && (
|
||
// 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="w-full px-[var(--layout-padding-x)] pt-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 />
|
||
</>
|
||
);
|
||
}
|