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

@@ -210,15 +210,12 @@ jobs:
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
# Secrets mapping (Directus)
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
# Database configuration
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
DATABASE_URI: postgres://${{ env.postgres_DB_USER }}:${{ env.postgres_DB_PASSWORD }}@postgres-db:5432/${{ env.postgres_DB_NAME }}
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
# Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
@@ -275,18 +272,12 @@ jobs:
PROJECT_COLOR=$PROJECT_COLOR
LOG_LEVEL=$LOG_LEVEL
# Directus
DIRECTUS_URL=$DIRECTUS_URL
DIRECTUS_HOST=$DIRECTUS_HOST
DIRECTUS_KEY=$DIRECTUS_KEY
DIRECTUS_SECRET=$DIRECTUS_SECRET
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
INTERNAL_DIRECTUS_URL=http://directus:8055
# Payload DB
postgres_DB_NAME=$postgres_DB_NAME
postgres_DB_USER=$postgres_DB_USER
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
DATABASE_URI=$DATABASE_URI
PAYLOAD_SECRET=$PAYLOAD_SECRET
# Mail
MAIL_HOST=$MAIL_HOST
@@ -337,8 +328,7 @@ jobs:
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
# Apply Directus Schema Snapshot if available
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"

View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import configPromise from "@payload-config";
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = async ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config: configPromise, params, searchParams });
const Page = async ({ params, searchParams }: Args) =>
RootPage({ config: configPromise, importMap, params, searchParams });
export default Page;

View File

@@ -0,0 +1,75 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards":
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -0,0 +1,16 @@
import config from "@payload-config";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const OPTIONS = REST_OPTIONS(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);

View File

@@ -0,0 +1,20 @@
import configPromise from "@payload-config";
import "@payloadcms/next/css";
import { RootLayout } from "@payloadcms/next/layouts";
import React from "react";
// @ts-expect-error - Export exists in JS but TS types are missing in this version
import { handleServerFunctions } from "@payloadcms/next/utilities";
import { importMap } from "./admin/importMap";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<RootLayout
config={configPromise}
importMap={importMap}
serverFunction={handleServerFunctions}
>
{children}
</RootLayout>
);
}

View File

@@ -1,5 +1,5 @@
import { ImageResponse } from "next/og";
import { allPosts } from "contentlayer/generated";
import { getAllPosts } from "../../../src/lib/posts";
import { blogThumbnails } from "../../../src/components/blog/blogThumbnails";
import { BlogOGImageTemplate } from "../../../src/components/BlogOGImageTemplate";
import { getOgFonts, OG_IMAGE_SIZE } from "../../../src/lib/og-helper";
@@ -16,6 +16,7 @@ export default async function Image({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
let backgroundImageSrc: string | undefined = undefined;
@@ -27,11 +28,15 @@ export default async function Image({
const fileBuffer = await fs.readFile(filePath);
const ext = path.extname(post.thumbnail).substring(1).toLowerCase();
const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
const mimeType =
ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
backgroundImageSrc = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
} catch (err) {
console.warn(`[OG Image Generator] Could not read thumbnail file for ${slug} to use as background:`, err);
console.warn(
`[OG Image Generator] Could not read thumbnail file for ${slug} to use as background:`,
err,
);
// Fall through to standard plain background
}
}

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { allPosts } from "contentlayer/generated";
import { getAllPosts } from "../../../src/lib/posts";
import { BlogPostHeader } from "../../../src/components/blog/BlogPostHeader";
import { Section } from "../../../src/components/Section";
import { Reveal } from "../../../src/components/Reveal";
@@ -11,6 +11,7 @@ import { BlogPostStickyBar } from "../../../src/components/blog/BlogPostStickyBa
import { MDXContent } from "../../../src/components/MDXContent";
export async function generateStaticParams() {
const allPosts = await getAllPosts();
return allPosts.map((post) => ({
slug: post.slug,
}));
@@ -22,6 +23,7 @@ export async function generateMetadata({
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
if (!post) return {};
@@ -48,6 +50,7 @@ export default async function BlogPostPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = await getAllPosts();
const post = allPosts.find((p) => p.slug === slug);
if (!post) {

View File

@@ -1,211 +1,14 @@
"use client";
import { getAllPosts } from "../../src/lib/posts";
import { BlogClient } from "../../src/components/blog/BlogClient";
import type { Metadata } from "next";
import * as React from "react";
import { useState, useEffect } from "react";
import { MediumCard } from "../../src/components/MediumCard";
import { BlogCommandBar } from "../../src/components/blog/BlogCommandBar";
import { allPosts as contentPosts } from "contentlayer/generated";
import { SectionHeader } from "../../src/components/SectionHeader";
import { Reveal } from "../../src/components/Reveal";
import { Section } from "../../src/components/Section";
import { AbstractCircuit, GradientMesh } from "../../src/components/Effects";
import { useAnalytics } from "../../src/components/analytics/useAnalytics";
export const metadata: Metadata = {
title: "Blog | Mintel.me",
description:
"Gedanken über Engineering, Design und die Architektur der Zukunft.",
};
import { motion, AnimatePresence } from "framer-motion";
export default function BlogPage() {
const [searchQuery, setSearchQuery] = useState("");
const [activeTags, setActiveTags] = useState<string[]>([]);
const { trackEvent } = useAnalytics();
// Memoize allPosts
const allPosts = React.useMemo(
() =>
[...contentPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
),
[],
);
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} />
</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>
);
export default async function BlogPage() {
const posts = await getAllPosts();
return <BlogClient allPosts={posts as any} />;
}

View File

@@ -1,6 +1,6 @@
import { MetadataRoute } from 'next';
import { allPosts } from 'contentlayer/generated';
import { technologies } from './technologies/[slug]/data';
import { MetadataRoute } from "next";
import { getAllPosts } from "../src/lib/posts";
import { technologies } from "./technologies/[slug]/data";
/**
* Sitemap Generator
@@ -8,30 +8,31 @@ import { technologies } from './technologies/[slug]/data';
* Standard Next.js 15 App Router sitemap generation.
* This file dynamically generates /sitemap.xml
*/
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const allPosts = await getAllPosts();
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
// 1. Core Pages
const routes = [
'',
'/about',
'/blog',
'/case-studies',
'/case-studies/klz-cables',
'/contact',
'/websites',
"",
"/about",
"/blog",
"/case-studies",
"/case-studies/klz-cables",
"/contact",
"/websites",
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: route === '' ? 1.0 : 0.8,
changeFrequency: "monthly" as const,
priority: route === "" ? 1.0 : 0.8,
}));
// 2. Dynamic Blog Posts
const blogRoutes = allPosts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
changeFrequency: "monthly" as const,
priority: 0.7,
}));
@@ -39,7 +40,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
const techRoutes = Object.keys(technologies).map((slug) => ({
url: `${baseUrl}/technologies/${slug}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
changeFrequency: "monthly" as const,
priority: 0.6,
}));
@@ -48,7 +49,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
const tagRoutes = allTags.map((tag) => ({
url: `${baseUrl}/tags/${tag}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
changeFrequency: "weekly" as const,
priority: 0.3,
}));

View File

@@ -1,9 +1,10 @@
import Link from "next/link";
import { allPosts } from "contentlayer/generated";
import { getAllPosts } from "../../../src/lib/posts";
import { MediumCard } from "../../../src/components/MediumCard";
import { Reveal } from "../../../src/components/Reveal";
export async function generateStaticParams() {
const allPosts = await getAllPosts();
const allTags = Array.from(
new Set(allPosts.flatMap((post) => post.tags || [])),
);
@@ -18,6 +19,7 @@ export default async function TagPage({
params: Promise<{ tag: string }>;
}) {
const { tag } = await params;
const allPosts = await getAllPosts();
const posts = allPosts.filter((post) => post.tags?.includes(tag));
return (

View File

@@ -1,29 +0,0 @@
import { defineDocumentType, makeSource } from 'contentlayer2/source-files'
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `blog/**/*.mdx`,
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'string', required: true },
description: { type: 'string', required: true },
tags: { type: 'list', of: { type: 'string' }, required: true },
thumbnail: { type: 'string', required: false },
},
computedFields: {
slug: {
type: 'string',
resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx$/, ''),
},
url: {
type: 'string',
resolve: (post) => `/blog/${post._raw.sourceFileName.replace(/\.mdx$/, '')}`,
},
},
}))
export default makeSource({
contentDirPath: 'content',
documentTypes: [Post],
})

View File

@@ -1,5 +1,5 @@
import { withContentlayer } from 'next-contentlayer2';
import withMintelConfig from "@mintel/next-config";
import { withPayload } from '@payloadcms/next/withPayload';
import createMDX from '@next/mdx';
@@ -32,5 +32,4 @@ const nextConfig = {
const withMDX = createMDX({
// Add markdown plugins here, as desired
});
export default withContentlayer(withMintelConfig(withMDX(nextConfig)));
export default withPayload(withMintelConfig(withMDX(nextConfig)));

View File

@@ -19,17 +19,9 @@
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
"video:render:all": "npm run video:render:contact && npm run video:render:button",
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
"cms:push:staging": "../../scripts/sync-directus.sh push staging",
"cms:pull:staging": "../../scripts/sync-directus.sh pull staging",
"cms:push:testing": "../../scripts/sync-directus.sh push testing",
"cms:pull:testing": "../../scripts/sync-directus.sh pull testing",
"cms:push:prod": "../../scripts/sync-directus.sh push production",
"cms:pull:prod": "../../scripts/sync-directus.sh pull production",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@directus/sdk": "21.0.0",
"@emotion/is-prop-valid": "^1.4.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
@@ -44,6 +36,9 @@
"@opentelemetry/context-async-hooks": "^2.1.0",
"@opentelemetry/core": "^2.1.0",
"@opentelemetry/sdk-trace-base": "^2.1.0",
"@payloadcms/db-postgres": "^3.77.0",
"@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0",
"@react-pdf/renderer": "^4.3.2",
"@remotion/bundler": "^4.0.414",
"@remotion/cli": "^4.0.414",
@@ -60,18 +55,18 @@
"axios": "^1.13.4",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"contentlayer2": "^0.5.8",
"crawlee": "^3.15.3",
"esbuild": "^0.27.3",
"framer-motion": "^12.29.2",
"graphql": "^16.12.0",
"html-to-image": "^1.11.13",
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",
"mermaid": "^11.12.2",
"next": "^16.1.6",
"next-contentlayer2": "^0.5.8",
"next-mdx-remote": "^6.0.0",
"nodemailer": "^8.0.1",
"payload": "^3.77.0",
"playwright": "^1.58.1",
"prismjs": "^1.30.0",
"puppeteer": "^24.36.1",
@@ -82,6 +77,7 @@
"react-tweet": "^3.3.0",
"recharts": "^3.7.0",
"remotion": "^4.0.414",
"sharp": "^0.34.5",
"shiki": "^1.24.2",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.0",

436
apps/web/payload-types.ts Normal file
View File

@@ -0,0 +1,436 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| "Pacific/Midway"
| "Pacific/Niue"
| "Pacific/Honolulu"
| "Pacific/Rarotonga"
| "America/Anchorage"
| "Pacific/Gambier"
| "America/Los_Angeles"
| "America/Tijuana"
| "America/Denver"
| "America/Phoenix"
| "America/Chicago"
| "America/Guatemala"
| "America/New_York"
| "America/Bogota"
| "America/Caracas"
| "America/Santiago"
| "America/Buenos_Aires"
| "America/Sao_Paulo"
| "Atlantic/South_Georgia"
| "Atlantic/Azores"
| "Atlantic/Cape_Verde"
| "Europe/London"
| "Europe/Berlin"
| "Africa/Lagos"
| "Europe/Athens"
| "Africa/Cairo"
| "Europe/Moscow"
| "Asia/Riyadh"
| "Asia/Dubai"
| "Asia/Baku"
| "Asia/Karachi"
| "Asia/Tashkent"
| "Asia/Calcutta"
| "Asia/Dhaka"
| "Asia/Almaty"
| "Asia/Jakarta"
| "Asia/Bangkok"
| "Asia/Shanghai"
| "Asia/Singapore"
| "Asia/Tokyo"
| "Asia/Seoul"
| "Australia/Brisbane"
| "Australia/Sydney"
| "Pacific/Guam"
| "Pacific/Noumea"
| "Pacific/Auckland"
| "Pacific/Fiji";
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
posts: Post;
"payload-kv": PayloadKv;
"payload-locked-documents": PayloadLockedDocument;
"payload-preferences": PayloadPreference;
"payload-migrations": PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
"payload-locked-documents":
| PayloadLockedDocumentsSelect<false>
| PayloadLockedDocumentsSelect<true>;
"payload-preferences":
| PayloadPreferencesSelect<false>
| PayloadPreferencesSelect<true>;
"payload-migrations":
| PayloadMigrationsSelect<false>
| PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: "users";
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
card?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
tablet?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
slug: string;
description: string;
date: string;
tags: {
tag?: string | null;
id?: string | null;
}[];
thumbnail?: string | null;
content: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: "users";
value: number | User;
} | null)
| ({
relationTo: "media";
value: number | Media;
} | null)
| ({
relationTo: "posts";
value: number | Post;
} | null);
globalSlug?: string | null;
user: {
relationTo: "users";
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: "users";
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
card?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
tablet?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
description?: T;
date?: T;
tags?:
| T
| {
tag?: T;
id?: T;
};
thumbnail?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module "payload" {
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,36 @@
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path";
import { fileURLToPath } from "url";
import sharp from "sharp";
import { Users } from "./src/payload/collections/Users";
import { Media } from "./src/payload/collections/Media";
import { Posts } from "./src/payload/collections/Posts";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media, Posts],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "fallback-secret-for-dev",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
},
}),
sharp,
plugins: [],
});

View File

@@ -0,0 +1,72 @@
import { getPayload } from "payload";
import configPromise from "../payload.config";
import fs from "fs";
import path from "path";
function parseMatter(content: string) {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { data: {}, content };
const data: Record<string, any> = {};
match[1].split("\n").forEach((line) => {
const [key, ...rest] = line.split(":");
if (key && rest.length) {
const field = key.trim();
let val = rest.join(":").trim();
if (val.startsWith("[")) {
// basic array parsing
data[field] = val
.slice(1, -1)
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
} else {
data[field] = val.replace(/^["']|["']$/g, "");
}
}
});
return { data, content: match[2].trim() };
}
async function run() {
const payload = await getPayload({ config: configPromise });
const contentDir = path.join(process.cwd(), "content", "blog");
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
for (const file of files) {
const filePath = path.join(contentDir, file);
const content = fs.readFileSync(filePath, "utf-8");
const { data, content: body } = parseMatter(content);
const slug = file.replace(/\.mdx$/, "");
console.log(`Migrating ${slug}...`);
const existing = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
});
if (existing.docs.length === 0) {
await payload.create({
collection: "posts",
data: {
title: data.title || slug,
slug,
description: data.description || "",
date: data.date
? new Date(data.date).toISOString()
: new Date().toISOString(),
tags: (data.tags || []).map((t: string) => ({ tag: t })),
thumbnail: data.thumbnail || "",
content: body,
},
});
console.log(`✔ Inserted ${slug}`);
} else {
console.log(`⚠ Skipped ${slug} (already exists)`);
}
}
console.log("Migration complete.");
process.exit(0);
}
run().catch(console.error);

View File

@@ -1,72 +0,0 @@
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
import { updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient();
async function setupBranding() {
const prjName = process.env.PROJECT_NAME || "Mintel.me";
const prjColor = process.env.PROJECT_COLOR || "#ff00ff";
console.log(`🎨 Refining Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Outfit', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
.v-list-item--active {
color: ${prjColor} !important;
background: rgba(255, 0, 255, 0.1) !important;
}
</style>
<div style="font-family: 'Outfit', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.6); font-size: 11px; letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; text-transform: uppercase;">Mintel Infrastructure Engine</p>
<h1 style="color: #ffffff; font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.5px;">${prjName.toUpperCase()} <span style="color: ${prjColor};">SYNC.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
module_bar_background: "#00081a",
theme_light_overrides: {
primary: prjColor,
borderRadius: "12px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
moduleBarBackground: "#00081a",
},
} as any),
);
console.log("✨ Branding applied!");
} catch (error) {
console.error("❌ Error during bootstrap:", error);
}
}
setupBranding()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error("🚨 Fatal bootstrap error:", err);
process.exit(1);
});

View File

@@ -1,6 +1,4 @@
"use client";
import { useMDXComponent } from "next-contentlayer2/hooks";
import { MDXRemote } from "next-mdx-remote/rsc";
import { mdxComponents } from "../content-engine/registry";
interface MDXContentProps {
@@ -8,15 +6,5 @@ interface MDXContentProps {
}
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()}`;
}

View File

@@ -3,12 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
],
"contentlayer/generated": [
"./.contentlayer/generated"
]
"@/*": ["./*"],
"@payload-config": ["./payload.config.ts"]
}
},
"include": [
@@ -18,7 +14,5 @@
".next/types/**/*.ts",
".contentlayer/generated"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View File

@@ -34,35 +34,7 @@ services:
- "caddy=http://gatekeeper.${TRAEFIK_HOST:-mintel.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- default
- infra
env_file:
- .env
environment:
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
DB_CLIENT: "pg"
DB_HOST: "directus-db"
DB_PORT: "5432"
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.mintel.me}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/migrations:/directus/migrations
labels:
- "traefik.http.services.${PROJECT_NAME:-mintel-me}-directus.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
- "caddy=http://${DIRECTUS_HOST:-cms.mintel.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
directus-db:
postgres-db:
image: postgres:15-alpine
restart: always
networks:
@@ -70,9 +42,11 @@ services:
env_file:
- .env
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
POSTGRES_DB: ${postgres_DB_NAME:-directus}
POSTGRES_USER: ${postgres_DB_USER:-directus}
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-directus}
ports:
- "5432:5432"
volumes:
- directus-db-data:/var/lib/postgresql/data
@@ -84,10 +58,9 @@ services:
- infra
extra_hosts:
- "mintel.localhost:host-gateway"
- "cms.mintel.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
IMGPROXY_URL_MAPPING: "http://mintel.localhost/:http://app:3000/,http://cms.mintel.localhost/:http://directus:8055/"
IMGPROXY_URL_MAPPING: "http://mintel.localhost/:http://app:3000/"
IMGPROXY_USE_ETAG: "true"
IMGPROXY_MAX_SRC_RESOLUTION: 20
IMGPROXY_ALLOWED_NETWORKS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"

View File

@@ -55,7 +55,7 @@ services:
- "traefik.http.middlewares.mintel-me-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
mintel-me-gatekeeper:
profiles: [ "gatekeeper" ]
profiles: ["gatekeeper"]
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
container_name: ${PROJECT_NAME:-mintel-me}-gatekeeper
restart: always
@@ -79,52 +79,7 @@ services:
- "caddy=gatekeeper.${TRAEFIK_HOST:-mintel.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
mintel-me-cms:
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- default
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
DB_CLIENT: "pg"
DB_HOST: "mintel-me-db"
DB_PORT: "5432"
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.mintel.me}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
LOGGER_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/ping" ]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- 'traefik.http.routers.mintel-me-cms.rule=${TRAEFIK_DIRECTUS_RULE:-Host("${DIRECTUS_HOST:-cms.mintel.localhost}")}'
- "traefik.http.routers.mintel-me-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.mintel-me-cms.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.mintel-me-cms.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.mintel-me-cms.service=mintel-me-cms-svc"
- "traefik.http.routers.mintel-me-cms.middlewares=mintel-me-forward"
- "traefik.http.services.mintel-me-cms-svc.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
- "caddy=${DIRECTUS_HOST:-cms.mintel.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
mintel-me-db:
postgres-db:
image: postgres:15-alpine
restart: always
networks:
@@ -132,9 +87,9 @@ services:
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
POSTGRES_DB: ${postgres_DB_NAME:-directus}
POSTGRES_USER: ${postgres_DB_USER:-directus}
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-directus}
volumes:
- directus-db-data:/var/lib/postgresql/data

View File

@@ -4,7 +4,7 @@
"type": "module",
"packageManager": "pnpm@10.18.3",
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo \"\n🚀 Development Environment Starting...\n\n📱 App: http://mintel.localhost\n🗄 CMS: http://cms.mintel.localhost/admin\n🖼 Imgproxy: http://img.mintel.localhost\n🚦 Caddy Proxy: http://localhost:80\n\" && lsof -ti:3000 | xargs kill -9 2>/dev/null || true && rm -rf apps/web/.next apps/web/.contentlayer/.cache 2>/dev/null || true && docker rm -f mintel-me-gatekeeper 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down --remove-orphans && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up -d app directus directus-db gatekeeper imgproxy && pnpm -r dev",
"dev": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mintel.localhost\\n🖼 Imgproxy: http://img.mintel.localhost\\n🚦 Caddy Proxy: http://localhost:80\\n\" && lsof -ti:3000 | xargs kill -9 2>/dev/null || true && rm -rf apps/web/.next apps/web/.contentlayer/.cache 2>/dev/null || true && docker rm -f mintel-me-gatekeeper 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down --remove-orphans && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up -d app postgres-db gatekeeper imgproxy && DATABASE_URI=\"postgres://directus:directus@localhost:5432/directus\" PAYLOAD_SECRET=\"dev-secret\" pnpm -r dev",
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/.contentlayer apps/web/node_modules && pnpm install && pnpm dev",
"dev:stop": "lsof -ti:3000,3001,3002 | xargs kill -9 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
"dev:local": "pnpm -r dev",
@@ -48,7 +48,6 @@
}
},
"dependencies": {
"@directus/sdk": "21.0.0",
"@eslint/compat": "^2.0.2",
"@mintel/acquisition": "link:../at-mintel/packages/acquisition-library",
"tsx": "^4.21.0"

4536
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +0,0 @@
#!/bin/bash
# Configuration
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " testing, staging, production"
exit 1
fi
# Project Configuration
PRJ_ID="mintel-me"
REMOTE_DIR="/home/deploy/sites/mintel.me"
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
# DB Details
DB_USER="directus"
DB_NAME="directus"
echo "🔍 Detecting local database..."
# Check root or apps/web for docker-compose context
if [ -f "docker-compose.yml" ]; then
COMPOSE_CMD="docker compose"
elif [ -f "../../docker-compose.yml" ]; then
COMPOSE_CMD="docker compose -f ../../docker-compose.yml"
else
echo "❌ docker-compose.yml not found."
exit 1
fi
LOCAL_DB_CONTAINER=$($COMPOSE_CMD ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Is it running?"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
echo "📦 Dumping local database..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
echo "📤 Uploading dump to remote server..."
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
echo "📁 Syncing uploads (Local -> $ENV)..."
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "🔄 Restarting remote Directus..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV Data -> LOCAL..."
echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
echo "🧹 Wiping local database schema..."
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "⚡ Restoring database locally..."
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
echo "📁 Syncing uploads ($ENV -> Local)..."
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull to Local complete!"
fi