feat(cms): migrate from Directus to Payload v3 and remove contentlayer
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 3m43s
Build & Deploy / 🏗️ Build (push) Failing after 37s
Build & Deploy / 🧪 QA (push) Failing after 3m40s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-02-22 20:50:51 +01:00
parent b2f5e2dd4d
commit 072b6b13f1
30 changed files with 4299 additions and 2062 deletions

View File

@@ -1,22 +1,10 @@
"use client";
import { useMDXComponent } from "next-contentlayer2/hooks";
import { MDXRemote } from "next-mdx-remote/rsc";
import { mdxComponents } from "../content-engine/registry";
interface MDXContentProps {
code: string;
code: string;
}
export function MDXContent({ code }: MDXContentProps) {
// FIX: Contentlayer/MDX often appends hoisted functions *after* the `return MDXContent` statement,
// which causes Firefox to vomit hundreds of "unreachable code after return statement" warnings.
// We rewrite the generated IIFE string to move the return to the very end.
let patchedCode = code;
if (patchedCode.includes("return function MDXContent(")) {
patchedCode = patchedCode.replace("return function MDXContent(", "const MDXContent = function MDXContent(");
patchedCode += "\nreturn MDXContent;";
}
const Component = useMDXComponent(patchedCode);
return <Component components={mdxComponents} />;
return <MDXRemote source={code} components={mdxComponents} />;
}

View File

@@ -0,0 +1,210 @@
"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { MediumCard } from "../MediumCard";
import { BlogCommandBar } from "./BlogCommandBar";
import { Reveal } from "../Reveal";
import { Section } from "../Section";
import { AbstractCircuit, GradientMesh } from "../Effects";
import { useAnalytics } from "../analytics/useAnalytics";
import { motion, AnimatePresence } from "framer-motion";
interface PostType {
title: string;
description: string;
date: string;
tags: string[];
slug: string;
thumbnail: string;
body: { code: string };
}
export function BlogClient({ allPosts }: { allPosts: PostType[] }) {
const [searchQuery, setSearchQuery] = useState("");
const [activeTags, setActiveTags] = useState<string[]>([]);
const { trackEvent } = useAnalytics();
const [filteredPosts, setFilteredPosts] = useState(allPosts);
// Memoize allTags
const allTags = React.useMemo(
() => Array.from(new Set(allPosts.flatMap((post) => post.tags || []))),
[allPosts],
);
const [visibleCount, setVisibleCount] = useState(8);
const handleTagToggle = (tag: string) => {
setActiveTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
);
setVisibleCount(8); // Reset pagination on filter change
};
useEffect(() => {
const query = searchQuery.toLowerCase().trim();
let filtered = allPosts;
if (query) {
filtered = filtered.filter((post) => {
const title = post.title.toLowerCase();
const description = post.description.toLowerCase();
const pTagString = (post.tags || []).join(" ").toLowerCase();
return (
title.includes(query) ||
description.includes(query) ||
pTagString.includes(query)
);
});
}
if (activeTags.length > 0) {
filtered = filtered.filter((post) =>
post.tags?.some((tag) => activeTags.includes(tag)),
);
}
setFilteredPosts(filtered);
}, [searchQuery, activeTags, allPosts]);
const loadMore = () => {
setVisibleCount((prev) => prev + 6);
};
const hasMore = visibleCount < filteredPosts.length;
const postsToShow = filteredPosts.slice(0, visibleCount);
return (
<div className="flex flex-col bg-slate-50/30 overflow-hidden relative min-h-screen">
<AbstractCircuit />
{/* Header Section */}
<header className="relative pt-32 pb-8 md:pt-44 md:pb-12 z-20 overflow-hidden">
<GradientMesh
variant="metallic"
className="opacity-20 absolute inset-0 -z-10"
/>
<div className="narrow-container">
<div className="space-y-4 text-center">
<Reveal direction="down" delay={0.1}>
<div className="flex items-center justify-center gap-4 text-[10px] font-bold uppercase tracking-[0.3em] text-slate-400">
<div className="w-6 h-px bg-slate-200" />
<span>Knowledge Base</span>
<div className="w-12 h-px bg-slate-100" />
</div>
</Reveal>
<Reveal delay={0.2}>
<div className="flex flex-col items-center gap-6">
<h1 className="text-5xl md:text-7xl font-bold text-slate-900 tracking-tighter leading-none">
Alle Artikel<span className="text-slate-300">.</span>
</h1>
<p className="font-serif italic text-slate-400 text-sm md:text-xl max-w-sm">
Gedanken über Engineering, Design und die Architektur der
Zukunft.
</p>
</div>
</Reveal>
</div>
</div>
</header>
{/* Sticky Filter Bar */}
<div className="sticky top-0 z-40 bg-slate-50/80 backdrop-blur-xl border-y border-slate-200/50 py-4 shadow-sm transition-all duration-300">
<div className="narrow-container">
<Reveal width="100%">
<BlogCommandBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
tags={allTags}
activeTags={activeTags}
onTagToggle={handleTagToggle}
/>
</Reveal>
</div>
</div>
<Section
className="pb-32 pt-12"
containerVariant="narrow"
variant="white"
>
<div className="space-y-12 relative z-10 p-4 md:p-0">
{/* Posts List (Vertical & Minimal) */}
<div id="posts-container" className="space-y-12">
<AnimatePresence mode="popLayout" initial={false}>
{postsToShow.length === 0 ? (
<motion.div
key="no-results"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="py-24 text-center border border-dashed border-slate-200 rounded-3xl bg-white/50"
>
<p className="text-slate-400 font-mono text-sm uppercase tracking-widest">
Keine Beiträge gefunden.
</p>
<button
onClick={() => {
setSearchQuery("");
setActiveTags([]);
}}
className="mt-4 text-xs font-bold text-slate-900 underline underline-offset-4 hover:text-slate-600"
>
Filter zurücksetzen
</button>
</motion.div>
) : (
<motion.div
key="post-grid"
layout
className="grid grid-cols-1 gap-6 w-full"
>
<AnimatePresence mode="popLayout">
{postsToShow.map((post, i) => (
<motion.div
key={post.slug}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
duration: 0.4,
delay: i < 8 ? i * 0.05 : 0,
ease: [0.16, 1, 0.3, 1],
}}
>
<MediumCard post={post as any} />
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
{/* Pagination */}
{hasMore && (
<div className="flex justify-center pt-8">
<Reveal delay={0.1} width="fit-content">
<button
onClick={loadMore}
className="group relative px-8 py-4 bg-white border border-slate-200 text-slate-600 rounded-full overflow-hidden transition-all hover:border-slate-400 hover:text-slate-900 hover:shadow-lg"
>
<span className="relative z-10 font-mono text-xs uppercase tracking-widest flex items-center gap-3">
Mehr laden
<div className="w-1 h-1 bg-slate-300 rounded-full group-hover:bg-slate-900 transition-colors" />
<div className="w-1 h-1 bg-slate-300 rounded-full group-hover:bg-slate-900 transition-colors delay-75" />
<div className="w-1 h-1 bg-slate-300 rounded-full group-hover:bg-slate-900 transition-colors delay-150" />
</span>
</button>
</Reveal>
</div>
)}
</div>
</div>
</Section>
</div>
);
}

30
apps/web/src/lib/posts.ts Normal file
View File

@@ -0,0 +1,30 @@
import { getPayload } from "payload";
import configPromise from "@payload-config";
export async function getAllPosts() {
if (!process.env.DATABASE_URI && !process.env.POSTGRES_URI) {
console.warn(
"⚠️ Bypassing Payload fetch during Next.js build: DATABASE_URI is missing.",
);
return [];
}
const payload = await getPayload({ config: configPromise });
const { docs } = await payload.find({
collection: "posts",
limit: 1000,
sort: "-date",
});
return docs.map((doc) => ({
title: doc.title as string,
description: doc.description as string,
date: doc.date as string,
tags: (doc.tags || []).map((t) =>
typeof t === "object" && t !== null ? t.tag : t,
) as string[],
slug: doc.slug as string,
thumbnail: doc.thumbnail as string,
body: { code: doc.content as string },
}));
}

View File

@@ -0,0 +1,47 @@
import type { CollectionConfig } from "payload";
import path from "path";
import { fileURLToPath } from "url";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export const Media: CollectionConfig = {
slug: "media",
admin: {
useAsTitle: "alt",
},
access: {
read: () => true, // Publicly readable
},
upload: {
staticDir: path.resolve(dirname, "../../../../public/media"),
adminThumbnail: "thumbnail",
imageSizes: [
{
name: "thumbnail",
width: 400,
height: 300,
position: "centre",
},
{
name: "card",
width: 768,
height: 1024,
position: "centre",
},
{
name: "tablet",
width: 1024,
height: undefined,
position: "centre",
},
],
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
};

View File

@@ -0,0 +1,74 @@
import type { CollectionConfig } from "payload";
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
},
access: {
read: () => true, // Publicly readable API
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (value) return value;
if (data?.title) {
return data.title
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
return value;
},
],
},
},
{
name: "description",
type: "text",
required: true,
},
{
name: "date",
type: "date",
required: true,
},
{
name: "tags",
type: "array",
required: true,
fields: [
{
name: "tag",
type: "text",
},
],
},
{
name: "thumbnail",
type: "text", // Keeping as text for now to match current MDX strings like "/blog/green-it.png"
},
{
name: "content",
type: "code",
admin: {
language: "markdown",
},
required: true,
},
],
};

View File

@@ -0,0 +1,13 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
admin: {
useAsTitle: "email",
},
auth: true,
fields: [
// Email added by default due to auth
// Add more fields as needed
],
};

View File

@@ -69,29 +69,23 @@ export function getImgproxyUrl(
"http://directus:8055",
);
}
}
const {
width = 0,
height = 0,
resizing_type = "fit",
gravity = "sm", // Default to smart gravity
enlarge = false,
extension = "",
} = options;
const { width = 0, height = 0, enlarge = false, extension = "" } = options;
// Processing options
// Format: /rs:<type>:<width>:<height>:<enlarge>/g:<gravity>
const processingOptions = [
`rs:${resizing_type}:${width}:${height}:${enlarge ? 1 : 0}`,
`g:${gravity}`,
].join("/");
let quality = 80;
if (extension) quality = 90;
// Using /unsafe/ for now as we don't handle signatures yet
// Format: <base_url>/unsafe/<options>/<base64_url>
const suffix = extension ? `@${extension}` : "";
const encodedSrc = encodeBase64(absoluteSrc + suffix);
// Re-map imgproxy URL to our new parameter structure
// e.g. /process?url=...&w=...&h=...&q=...&format=...
const queryParams = new URLSearchParams({
url: absoluteSrc,
});
return `${baseUrl}/unsafe/${processingOptions}/${encodedSrc}`;
if (width > 0) queryParams.set("w", width.toString());
if (height > 0) queryParams.set("h", height.toString());
if (extension) queryParams.set("format", extension.replace(".", ""));
if (quality) queryParams.set("q", quality.toString());
return `${baseUrl}/process?${queryParams.toString()}`;
}