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:
@@ -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'"
|
||||
|
||||
|
||||
25
apps/web/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
apps/web/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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;
|
||||
75
apps/web/app/(payload)/admin/importMap.js
Normal file
75
apps/web/app/(payload)/admin/importMap.js
Normal 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,
|
||||
};
|
||||
16
apps/web/app/(payload)/api/[...slug]/route.ts
Normal file
16
apps/web/app/(payload)/api/[...slug]/route.ts
Normal 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);
|
||||
20
apps/web/app/(payload)/layout.tsx
Normal file
20
apps/web/app/(payload)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
436
apps/web/payload-types.ts
Normal 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 {}
|
||||
}
|
||||
36
apps/web/payload.config.ts
Normal file
36
apps/web/payload.config.ts
Normal 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: [],
|
||||
});
|
||||
72
apps/web/scripts/migrate-posts.ts
Normal file
72
apps/web/scripts/migrate-posts.ts
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
4536
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user