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
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:
@@ -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} />;
|
||||
}
|
||||
|
||||
210
apps/web/src/components/blog/BlogClient.tsx
Normal file
210
apps/web/src/components/blog/BlogClient.tsx
Normal 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
30
apps/web/src/lib/posts.ts
Normal 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 },
|
||||
}));
|
||||
}
|
||||
47
apps/web/src/payload/collections/Media.ts
Normal file
47
apps/web/src/payload/collections/Media.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
74
apps/web/src/payload/collections/Posts.ts
Normal file
74
apps/web/src/payload/collections/Posts.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
13
apps/web/src/payload/collections/Users.ts
Normal file
13
apps/web/src/payload/collections/Users.ts
Normal 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
|
||||
],
|
||||
};
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user