Integrate Prisma for content; enhance SEO, i18n, and deployment workflows
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -1,2 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
export { default } from "../../legal-notice/page";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
|
||||
return {
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,23 @@
|
||||
export { default } from "../_ui/HomePage";
|
||||
import type { Metadata } from "next";
|
||||
import HomePage from "../_ui/HomePage";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: "" });
|
||||
return {
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}`),
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
export { default } from "../../privacy-policy/page";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
|
||||
return {
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||
return {
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const languages = getLanguageAlternates({ pathWithoutLocale: "projects" });
|
||||
return {
|
||||
alternates: {
|
||||
canonical: toAbsoluteUrl(`/${locale}/projects`),
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectsPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
import { GET } from '@/app/api/fetchAllProjects/route';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Wir mocken node-fetch direkt
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
posts: [
|
||||
{
|
||||
id: '67ac8dfa709c60000117d312',
|
||||
title: 'Just Doing Some Testing',
|
||||
meta_description: 'Hello bla bla bla bla',
|
||||
slug: 'just-doing-some-testing',
|
||||
updated_at: '2025-02-13T14:25:38.000+00:00',
|
||||
},
|
||||
{
|
||||
id: '67aaffc3709c60000117d2d9',
|
||||
title: 'Blockchain Based Voting System',
|
||||
meta_description:
|
||||
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||
slug: 'blockchain-based-voting-system',
|
||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
limit: 'all',
|
||||
next: null,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
prev: null,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
),
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: jest.fn(async () => [
|
||||
{
|
||||
id: 1,
|
||||
slug: 'just-doing-some-testing',
|
||||
title: 'Just Doing Some Testing',
|
||||
updatedAt: new Date('2025-02-13T14:25:38.000Z'),
|
||||
metaDescription: 'Hello bla bla bla bla',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
slug: 'blockchain-based-voting-system',
|
||||
title: 'Blockchain Based Voting System',
|
||||
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
|
||||
metaDescription:
|
||||
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('next/server', () => ({
|
||||
@@ -47,12 +31,8 @@ jest.mock('next/server', () => ({
|
||||
}));
|
||||
|
||||
describe('GET /api/fetchAllProjects', () => {
|
||||
beforeAll(() => {
|
||||
process.env.GHOST_API_URL = 'http://localhost:2368';
|
||||
process.env.GHOST_API_KEY = 'some-key';
|
||||
});
|
||||
|
||||
it('should return a list of projects (partial match)', async () => {
|
||||
const { GET } = await import('@/app/api/fetchAllProjects/route');
|
||||
await GET();
|
||||
|
||||
// Den tatsächlichen Argumentwert extrahieren
|
||||
@@ -61,11 +41,11 @@ describe('GET /api/fetchAllProjects', () => {
|
||||
expect(responseArg).toMatchObject({
|
||||
posts: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '67ac8dfa709c60000117d312',
|
||||
id: '1',
|
||||
title: 'Just Doing Some Testing',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '67aaffc3709c60000117d2d9',
|
||||
id: '2',
|
||||
title: 'Blockchain Based Voting System',
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { GET } from '@/app/api/fetchProject/route';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Mock node-fetch so the route uses it as a reliable fallback
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
posts: [
|
||||
{
|
||||
id: '67aaffc3709c60000117d2d9',
|
||||
title: 'Blockchain Based Voting System',
|
||||
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||
slug: 'blockchain-based-voting-system',
|
||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
),
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findUnique: jest.fn(async ({ where }: { where: { slug: string } }) => {
|
||||
if (where.slug !== 'blockchain-based-voting-system') return null;
|
||||
return {
|
||||
id: 2,
|
||||
title: 'Blockchain Based Voting System',
|
||||
metaDescription:
|
||||
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||
slug: 'blockchain-based-voting-system',
|
||||
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
|
||||
description: null,
|
||||
content: null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('next/server', () => ({
|
||||
@@ -29,12 +26,8 @@ jest.mock('next/server', () => ({
|
||||
},
|
||||
}));
|
||||
describe('GET /api/fetchProject', () => {
|
||||
beforeAll(() => {
|
||||
process.env.GHOST_API_URL = 'http://localhost:2368';
|
||||
process.env.GHOST_API_KEY = 'some-key';
|
||||
});
|
||||
|
||||
it('should fetch a project by slug', async () => {
|
||||
const { GET } = await import('@/app/api/fetchProject/route');
|
||||
const mockRequest = {
|
||||
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
|
||||
} as unknown as NextRequest;
|
||||
@@ -44,11 +37,11 @@ describe('GET /api/fetchProject', () => {
|
||||
expect(NextResponse.json).toHaveBeenCalledWith({
|
||||
posts: [
|
||||
{
|
||||
id: '67aaffc3709c60000117d2d9',
|
||||
id: '2',
|
||||
title: 'Blockchain Based Voting System',
|
||||
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||
slug: 'blockchain-based-voting-system',
|
||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
||||
updated_at: '2025-02-13T16:54:42.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -34,77 +34,38 @@ jest.mock("next/server", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "@/app/api/sitemap/route";
|
||||
|
||||
// Mock node-fetch so we don't perform real network requests in tests
|
||||
jest.mock("node-fetch", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
posts: [
|
||||
{
|
||||
id: "67ac8dfa709c60000117d312",
|
||||
title: "Just Doing Some Testing",
|
||||
meta_description: "Hello bla bla bla bla",
|
||||
slug: "just-doing-some-testing",
|
||||
updated_at: "2025-02-13T14:25:38.000+00:00",
|
||||
},
|
||||
{
|
||||
id: "67aaffc3709c60000117d2d9",
|
||||
title: "Blockchain Based Voting System",
|
||||
meta_description:
|
||||
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
|
||||
slug: "blockchain-based-voting-system",
|
||||
updated_at: "2025-02-13T16:54:42.000+00:00",
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
limit: "all",
|
||||
next: null,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
prev: null,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
jest.mock("@/lib/sitemap", () => ({
|
||||
getSitemapEntries: jest.fn(async () => [
|
||||
{
|
||||
url: "https://dki.one/en",
|
||||
lastModified: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
url: "https://dki.one/de",
|
||||
lastModified: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
url: "https://dki.one/en/projects/blockchain-based-voting-system",
|
||||
lastModified: "2025-02-13T16:54:42.000Z",
|
||||
},
|
||||
{
|
||||
url: "https://dki.one/de/projects/blockchain-based-voting-system",
|
||||
lastModified: "2025-02-13T16:54:42.000Z",
|
||||
},
|
||||
]),
|
||||
generateSitemapXml: jest.fn(
|
||||
() =>
|
||||
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
|
||||
),
|
||||
}));
|
||||
|
||||
describe("GET /api/sitemap", () => {
|
||||
beforeAll(() => {
|
||||
process.env.GHOST_API_URL = "http://localhost:2368";
|
||||
process.env.GHOST_API_KEY = "test-api-key";
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
||||
|
||||
// Provide mock posts via env so route can use them without fetching
|
||||
process.env.GHOST_MOCK_POSTS = JSON.stringify({
|
||||
posts: [
|
||||
{
|
||||
id: "67ac8dfa709c60000117d312",
|
||||
title: "Just Doing Some Testing",
|
||||
meta_description: "Hello bla bla bla bla",
|
||||
slug: "just-doing-some-testing",
|
||||
updated_at: "2025-02-13T14:25:38.000+00:00",
|
||||
},
|
||||
{
|
||||
id: "67aaffc3709c60000117d2d9",
|
||||
title: "Blockchain Based Voting System",
|
||||
meta_description:
|
||||
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
|
||||
slug: "blockchain-based-voting-system",
|
||||
updated_at: "2025-02-13T16:54:42.000+00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a sitemap", async () => {
|
||||
const { GET } = await import("@/app/api/sitemap/route");
|
||||
const response = await GET();
|
||||
|
||||
// Get the body text from the NextResponse
|
||||
@@ -113,15 +74,7 @@ describe("GET /api/sitemap", () => {
|
||||
expect(body).toContain(
|
||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
);
|
||||
expect(body).toContain("<loc>https://dki.one/</loc>");
|
||||
expect(body).toContain("<loc>https://dki.one/legal-notice</loc>");
|
||||
expect(body).toContain("<loc>https://dki.one/privacy-policy</loc>");
|
||||
expect(body).toContain(
|
||||
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
|
||||
);
|
||||
expect(body).toContain(
|
||||
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
|
||||
);
|
||||
expect(body).toContain("<loc>https://dki.one/en</loc>");
|
||||
// Note: Headers are not available in test environment
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { GET } from "@/app/sitemap.xml/route";
|
||||
|
||||
jest.mock("next/server", () => ({
|
||||
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
|
||||
@@ -11,71 +10,32 @@ jest.mock("next/server", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Sitemap XML used by node-fetch mock
|
||||
const sitemapXml = `
|
||||
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dki.one/</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dki.one/legal-notice</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dki.one/privacy-policy</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dki.one/projects/just-doing-some-testing</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dki.one/projects/blockchain-based-voting-system</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
// Mock node-fetch for sitemap endpoint (hoisted by Jest)
|
||||
jest.mock("node-fetch", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn((_url: string) =>
|
||||
Promise.resolve({ ok: true, text: () => Promise.resolve(sitemapXml) }),
|
||||
jest.mock("@/lib/sitemap", () => ({
|
||||
getSitemapEntries: jest.fn(async () => [
|
||||
{
|
||||
url: "https://dki.one/en",
|
||||
lastModified: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
generateSitemapXml: jest.fn(
|
||||
() =>
|
||||
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Sitemap Component", () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
||||
|
||||
// Provide sitemap XML directly so route uses it without fetching
|
||||
process.env.GHOST_MOCK_SITEMAP = sitemapXml;
|
||||
|
||||
// Mock global.fetch too, to avoid any network calls
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/api/sitemap")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(sitemapXml),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unknown URL: ${url}`));
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the sitemap XML", async () => {
|
||||
const { GET } = await import("@/app/sitemap.xml/route");
|
||||
const response = await GET();
|
||||
|
||||
expect(response.body).toContain(
|
||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
);
|
||||
expect(response.body).toContain("<loc>https://dki.one/</loc>");
|
||||
expect(response.body).toContain("<loc>https://dki.one/legal-notice</loc>");
|
||||
expect(response.body).toContain(
|
||||
"<loc>https://dki.one/privacy-policy</loc>",
|
||||
);
|
||||
expect(response.body).toContain(
|
||||
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
|
||||
);
|
||||
expect(response.body).toContain(
|
||||
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
|
||||
);
|
||||
expect(response.body).toContain("<loc>https://dki.one/en</loc>");
|
||||
// Note: Headers are not available in test environment
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import NodeCache from "node-cache";
|
||||
|
||||
// Use a dynamic import for node-fetch so tests that mock it (via jest.mock) are respected
|
||||
async function getFetch() {
|
||||
try {
|
||||
const mod = await import("node-fetch");
|
||||
// support both CJS and ESM interop
|
||||
return (mod as { default: unknown }).default ?? mod;
|
||||
} catch (_err) {
|
||||
return globalThis.fetch;
|
||||
}
|
||||
}
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs"; // Force Node runtime
|
||||
|
||||
const GHOST_API_URL = process.env.GHOST_API_URL;
|
||||
const GHOST_API_KEY = process.env.GHOST_API_KEY;
|
||||
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
|
||||
|
||||
type GhostPost = {
|
||||
type LegacyPost = {
|
||||
slug: string;
|
||||
id: string;
|
||||
title: string;
|
||||
feature_image: string;
|
||||
visibility: string;
|
||||
published_at: string;
|
||||
meta_description: string | null;
|
||||
updated_at: string;
|
||||
html: string;
|
||||
reading_time: number;
|
||||
meta_description: string;
|
||||
};
|
||||
|
||||
type GhostPostsResponse = {
|
||||
posts: Array<GhostPost>;
|
||||
type LegacyPostsResponse = {
|
||||
posts: Array<LegacyPost>;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const cacheKey = "ghostPosts";
|
||||
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey);
|
||||
const cacheKey = "projects:legacyPosts";
|
||||
const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
|
||||
|
||||
if (cachedPosts) {
|
||||
return NextResponse.json(cachedPosts);
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchFn = await getFetch();
|
||||
const response = await (fetchFn as unknown as typeof fetch)(
|
||||
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
|
||||
);
|
||||
const posts: GhostPostsResponse =
|
||||
(await response.json()) as GhostPostsResponse;
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
updatedAt: true,
|
||||
metaDescription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!posts || !posts.posts) {
|
||||
console.error("Invalid posts data");
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
const payload: LegacyPostsResponse = {
|
||||
posts: projects.map((p) => ({
|
||||
id: String(p.id),
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
meta_description: p.metaDescription ?? null,
|
||||
updated_at: (p.updatedAt ?? new Date()).toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
cache.set(cacheKey, posts); // Daten im Cache speichern
|
||||
|
||||
return NextResponse.json(posts);
|
||||
cache.set(cacheKey, payload);
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch posts from Ghost:", error);
|
||||
console.error("Failed to fetch projects:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch projects" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs"; // Force Node runtime
|
||||
|
||||
const GHOST_API_URL = process.env.GHOST_API_URL;
|
||||
const GHOST_API_KEY = process.env.GHOST_API_KEY;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get("slug");
|
||||
@@ -14,59 +12,37 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Debug: show whether fetch is present/mocked
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
updatedAt: true,
|
||||
metaDescription: true,
|
||||
description: true,
|
||||
content: true,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
console.log(
|
||||
"DEBUG fetch in fetchProject:",
|
||||
typeof (globalThis as any).fetch,
|
||||
"globalIsMock:",
|
||||
!!(globalThis as any).fetch?._isMockFunction,
|
||||
);
|
||||
|
||||
// Try global fetch first (as tests often mock it). If it fails or returns undefined,
|
||||
// fall back to dynamically importing node-fetch.
|
||||
let response: any;
|
||||
|
||||
if (typeof (globalThis as any).fetch === "function") {
|
||||
try {
|
||||
response = await (globalThis as any).fetch(
|
||||
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
|
||||
);
|
||||
} catch (_e) {
|
||||
response = undefined;
|
||||
}
|
||||
if (!project) {
|
||||
return NextResponse.json({ posts: [] }, { status: 200 });
|
||||
}
|
||||
|
||||
if (!response || typeof response.ok === "undefined") {
|
||||
try {
|
||||
const mod = await import("node-fetch");
|
||||
const nodeFetch = (mod as any).default ?? mod;
|
||||
response = await (nodeFetch as any)(
|
||||
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
|
||||
);
|
||||
} catch (_err) {
|
||||
response = undefined;
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// Debug: inspect the response returned from the fetch
|
||||
|
||||
// Debug: inspect the response returned from the fetch
|
||||
|
||||
console.log("DEBUG fetch response:", response);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch post: ${response?.statusText ?? "no response"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const post = await response.json();
|
||||
return NextResponse.json(post);
|
||||
// Legacy shape (Ghost-like) for compatibility with older frontend/tests.
|
||||
return NextResponse.json({
|
||||
posts: [
|
||||
{
|
||||
id: String(project.id),
|
||||
title: project.title,
|
||||
meta_description: project.metaDescription ?? project.description ?? "",
|
||||
slug: project.slug,
|
||||
updated_at: (project.updatedAt ?? new Date()).toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch post from Ghost:", error);
|
||||
console.error("Failed to fetch project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch project" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,37 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma, projectService } from "@/lib/prisma";
|
||||
import { requireSessionAuth } from "@/lib/auth";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
type ImportSiteSettings = {
|
||||
defaultLocale?: unknown;
|
||||
locales?: unknown;
|
||||
theme?: unknown;
|
||||
};
|
||||
|
||||
type ImportContentPageTranslation = {
|
||||
locale?: unknown;
|
||||
title?: unknown;
|
||||
slug?: unknown;
|
||||
content?: unknown;
|
||||
metaDescription?: unknown;
|
||||
keywords?: unknown;
|
||||
};
|
||||
|
||||
type ImportContentPage = {
|
||||
key?: unknown;
|
||||
status?: unknown;
|
||||
translations?: unknown;
|
||||
};
|
||||
|
||||
type ImportProject = {
|
||||
id?: unknown;
|
||||
slug?: unknown;
|
||||
defaultLocale?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
content?: unknown;
|
||||
tags?: unknown;
|
||||
category?: unknown;
|
||||
featured?: unknown;
|
||||
github?: unknown;
|
||||
live?: unknown;
|
||||
published?: unknown;
|
||||
imageUrl?: unknown;
|
||||
difficulty?: unknown;
|
||||
timeToComplete?: unknown;
|
||||
technologies?: unknown;
|
||||
challenges?: unknown;
|
||||
lessonsLearned?: unknown;
|
||||
futureImprovements?: unknown;
|
||||
demoVideo?: unknown;
|
||||
screenshots?: unknown;
|
||||
colorScheme?: unknown;
|
||||
accessibility?: unknown;
|
||||
performance?: unknown;
|
||||
analytics?: unknown;
|
||||
};
|
||||
|
||||
type ImportProjectTranslation = {
|
||||
projectId?: unknown;
|
||||
locale?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
content?: unknown;
|
||||
metaDescription?: unknown;
|
||||
keywords?: unknown;
|
||||
ogImage?: unknown;
|
||||
schema?: unknown;
|
||||
};
|
||||
|
||||
type ImportPayload = {
|
||||
projects?: unknown;
|
||||
siteSettings?: unknown;
|
||||
contentPages?: unknown;
|
||||
projectTranslations?: unknown;
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] | null {
|
||||
if (!Array.isArray(v)) return null;
|
||||
const allStrings = v.filter((x) => typeof x === "string") as string[];
|
||||
return allStrings.length === v.length ? allStrings : null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const isAdminRequest = request.headers.get("x-admin-request") === "true";
|
||||
if (!isAdminRequest) {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await request.json();
|
||||
const body = (await request.json()) as ImportPayload;
|
||||
|
||||
// Validate import data structure
|
||||
if (!body.projects || !Array.isArray(body.projects)) {
|
||||
if (!Array.isArray(body.projects)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid import data format' },
|
||||
{ status: 400 }
|
||||
{ error: "Invalid import data format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as string[]
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Import SiteSettings (optional)
|
||||
if (body.siteSettings && typeof body.siteSettings === 'object') {
|
||||
if (body.siteSettings && typeof body.siteSettings === "object") {
|
||||
try {
|
||||
const ss = body.siteSettings as ImportSiteSettings;
|
||||
const defaultLocale = asString(ss.defaultLocale);
|
||||
const locales = asStringArray(ss.locales);
|
||||
const theme = ss.theme as Prisma.InputJsonValue | undefined;
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: 1 },
|
||||
create: { id: 1, ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
update: { ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
create: {
|
||||
id: 1,
|
||||
...(defaultLocale ? { defaultLocale } : {}),
|
||||
...(locales ? { locales } : {}),
|
||||
...(theme ? { theme } : {}),
|
||||
},
|
||||
update: {
|
||||
...(defaultLocale ? { defaultLocale } : {}),
|
||||
...(locales ? { locales } : {}),
|
||||
...(theme ? { theme } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// non-blocking
|
||||
@@ -42,39 +137,47 @@ export async function POST(request: NextRequest) {
|
||||
if (Array.isArray(body.contentPages)) {
|
||||
for (const page of body.contentPages) {
|
||||
try {
|
||||
if (!page?.key) continue;
|
||||
const key = asString((page as ImportContentPage)?.key);
|
||||
if (!key) continue;
|
||||
const statusRaw = asString((page as ImportContentPage)?.status);
|
||||
const status = statusRaw === "DRAFT" || statusRaw === "PUBLISHED" ? statusRaw : "PUBLISHED";
|
||||
const upserted = await prisma.contentPage.upsert({
|
||||
where: { key: page.key },
|
||||
create: { key: page.key, status: page.status || 'PUBLISHED' },
|
||||
update: { status: page.status || 'PUBLISHED' },
|
||||
where: { key },
|
||||
create: { key, status },
|
||||
update: { status },
|
||||
});
|
||||
|
||||
if (Array.isArray(page.translations)) {
|
||||
for (const tr of page.translations) {
|
||||
if (!tr?.locale || !tr?.content) continue;
|
||||
const translations = (page as ImportContentPage)?.translations;
|
||||
if (Array.isArray(translations)) {
|
||||
for (const tr of translations as ImportContentPageTranslation[]) {
|
||||
const locale = asString(tr?.locale);
|
||||
if (!locale || typeof tr?.content === "undefined" || tr?.content === null) continue;
|
||||
await prisma.contentPageTranslation.upsert({
|
||||
where: { pageId_locale: { pageId: upserted.id, locale: tr.locale } },
|
||||
where: { pageId_locale: { pageId: upserted.id, locale } },
|
||||
create: {
|
||||
pageId: upserted.id,
|
||||
locale: tr.locale,
|
||||
title: tr.title || null,
|
||||
slug: tr.slug || null,
|
||||
content: tr.content,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
} as any,
|
||||
locale,
|
||||
title: asString(tr.title),
|
||||
slug: asString(tr.slug),
|
||||
content: tr.content as Prisma.InputJsonValue,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
},
|
||||
update: {
|
||||
title: tr.title || null,
|
||||
slug: tr.slug || null,
|
||||
content: tr.content,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
} as any,
|
||||
title: asString(tr.title),
|
||||
slug: asString(tr.slug),
|
||||
content: tr.content as Prisma.InputJsonValue,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const key = asString((page as ImportContentPage)?.key) ?? "unknown";
|
||||
results.errors.push(
|
||||
`Failed to import content page "${key}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,102 +186,124 @@ export async function POST(request: NextRequest) {
|
||||
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
|
||||
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
|
||||
const existingTitles = new Set(existingProjects.map(p => p.title));
|
||||
const existingSlugs = new Set(existingProjects.map(p => (p as unknown as { slug?: string }).slug).filter(Boolean));
|
||||
const existingSlugs = new Set(
|
||||
existingProjects
|
||||
.map((p) => (p as unknown as { slug?: string }).slug)
|
||||
.filter((s): s is string => typeof s === "string" && s.length > 0),
|
||||
);
|
||||
|
||||
// Process each project
|
||||
for (const projectData of body.projects) {
|
||||
for (const projectData of body.projects as ImportProject[]) {
|
||||
try {
|
||||
// Check if project already exists (by title)
|
||||
const exists = existingTitles.has(projectData.title);
|
||||
const title = asString(projectData.title);
|
||||
if (!title) continue;
|
||||
const exists = existingTitles.has(title);
|
||||
|
||||
if (exists) {
|
||||
results.skipped++;
|
||||
results.errors.push(`Project "${projectData.title}" already exists`);
|
||||
results.errors.push(`Project "${title}" already exists`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create new project
|
||||
const created = await projectService.createProject({
|
||||
slug: projectData.slug,
|
||||
defaultLocale: projectData.defaultLocale || 'en',
|
||||
title: projectData.title,
|
||||
description: projectData.description,
|
||||
content: projectData.content,
|
||||
tags: projectData.tags || [],
|
||||
category: projectData.category,
|
||||
featured: projectData.featured || false,
|
||||
github: projectData.github,
|
||||
live: projectData.live,
|
||||
slug: asString(projectData.slug) ?? undefined,
|
||||
defaultLocale: asString(projectData.defaultLocale) ?? "en",
|
||||
title,
|
||||
description: asString(projectData.description) ?? "",
|
||||
content: projectData.content as Prisma.InputJsonValue | undefined,
|
||||
tags: (asStringArray(projectData.tags) ?? []) as string[],
|
||||
category: asString(projectData.category) ?? "General",
|
||||
featured: projectData.featured === true,
|
||||
github: asString(projectData.github) ?? undefined,
|
||||
live: asString(projectData.live) ?? undefined,
|
||||
published: projectData.published !== false, // Default to true
|
||||
imageUrl: projectData.imageUrl,
|
||||
difficulty: projectData.difficulty || 'Intermediate',
|
||||
timeToComplete: projectData.timeToComplete,
|
||||
technologies: projectData.technologies || [],
|
||||
challenges: projectData.challenges || [],
|
||||
lessonsLearned: projectData.lessonsLearned || [],
|
||||
futureImprovements: projectData.futureImprovements || [],
|
||||
demoVideo: projectData.demoVideo,
|
||||
screenshots: projectData.screenshots || [],
|
||||
colorScheme: projectData.colorScheme || 'Dark',
|
||||
imageUrl: asString(projectData.imageUrl) ?? undefined,
|
||||
difficulty: asString(projectData.difficulty) ?? "Intermediate",
|
||||
timeToComplete: asString(projectData.timeToComplete) ?? undefined,
|
||||
technologies: (asStringArray(projectData.technologies) ?? []) as string[],
|
||||
challenges: (asStringArray(projectData.challenges) ?? []) as string[],
|
||||
lessonsLearned: (asStringArray(projectData.lessonsLearned) ?? []) as string[],
|
||||
futureImprovements: (asStringArray(projectData.futureImprovements) ?? []) as string[],
|
||||
demoVideo: asString(projectData.demoVideo) ?? undefined,
|
||||
screenshots: (asStringArray(projectData.screenshots) ?? []) as string[],
|
||||
colorScheme: asString(projectData.colorScheme) ?? "Dark",
|
||||
accessibility: projectData.accessibility !== false, // Default to true
|
||||
performance: projectData.performance || {
|
||||
performance: (projectData.performance as Record<string, unknown> | null) || {
|
||||
lighthouse: 0,
|
||||
bundleSize: '0KB',
|
||||
loadTime: '0s'
|
||||
bundleSize: "0KB",
|
||||
loadTime: "0s",
|
||||
},
|
||||
analytics: projectData.analytics || {
|
||||
analytics: (projectData.analytics as Record<string, unknown> | null) || {
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0
|
||||
}
|
||||
shares: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Import translations (optional, from export v2)
|
||||
if (Array.isArray(body.projectTranslations)) {
|
||||
for (const tr of body.projectTranslations) {
|
||||
if (!tr?.projectId || !tr?.locale) continue;
|
||||
for (const tr of body.projectTranslations as ImportProjectTranslation[]) {
|
||||
const projectId = typeof tr?.projectId === "number" ? tr.projectId : null;
|
||||
const locale = asString(tr?.locale);
|
||||
if (!projectId || !locale) continue;
|
||||
// Map translation to created project by original slug/title when possible.
|
||||
// We match by slug if available in exported project list; otherwise by title.
|
||||
const exportedProject = body.projects.find((p: any) => p.id === tr.projectId);
|
||||
const exportedSlug = exportedProject?.slug;
|
||||
const exportedProject = (body.projects as ImportProject[]).find(
|
||||
(p) => typeof p.id === "number" && p.id === projectId,
|
||||
);
|
||||
const exportedSlug = asString(exportedProject?.slug);
|
||||
const matches =
|
||||
(exportedSlug && (created as any).slug === exportedSlug) ||
|
||||
(!!exportedProject?.title && (created as any).title === exportedProject.title);
|
||||
(exportedSlug && (created as unknown as { slug?: string }).slug === exportedSlug) ||
|
||||
(!!asString(exportedProject?.title) &&
|
||||
(created as unknown as { title?: string }).title === asString(exportedProject?.title));
|
||||
if (!matches) continue;
|
||||
|
||||
if (!tr.title || !tr.description) continue;
|
||||
const trTitle = asString(tr.title);
|
||||
const trDescription = asString(tr.description);
|
||||
if (!trTitle || !trDescription) continue;
|
||||
await prisma.projectTranslation.upsert({
|
||||
where: { projectId_locale: { projectId: (created as any).id, locale: tr.locale } },
|
||||
where: {
|
||||
projectId_locale: {
|
||||
projectId: (created as unknown as { id: number }).id,
|
||||
locale,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId: (created as any).id,
|
||||
locale: tr.locale,
|
||||
title: tr.title,
|
||||
description: tr.description,
|
||||
content: tr.content || null,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
ogImage: tr.ogImage || null,
|
||||
schema: tr.schema || null,
|
||||
} as any,
|
||||
projectId: (created as unknown as { id: number }).id,
|
||||
locale,
|
||||
title: trTitle,
|
||||
description: trDescription,
|
||||
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
ogImage: asString(tr.ogImage),
|
||||
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||
},
|
||||
update: {
|
||||
title: tr.title,
|
||||
description: tr.description,
|
||||
content: tr.content || null,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
ogImage: tr.ogImage || null,
|
||||
schema: tr.schema || null,
|
||||
} as any,
|
||||
title: trTitle,
|
||||
description: trDescription,
|
||||
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
ogImage: asString(tr.ogImage),
|
||||
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.imported++;
|
||||
existingTitles.add(projectData.title);
|
||||
if (projectData.slug) existingSlugs.add(projectData.slug);
|
||||
existingTitles.add(title);
|
||||
const slug = asString(projectData.slug);
|
||||
if (slug) existingSlugs.add(slug);
|
||||
} catch (error) {
|
||||
results.skipped++;
|
||||
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const title = asString(projectData.title) ?? "unknown";
|
||||
results.errors.push(
|
||||
`Failed to import "${title}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +313,10 @@ export async function POST(request: NextRequest) {
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
console.error("Import error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to import projects' },
|
||||
{ status: 500 }
|
||||
{ error: "Failed to import projects" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
updated_at?: string; // Optional timestamp for last modification
|
||||
}
|
||||
|
||||
interface ProjectsData {
|
||||
posts: Project[];
|
||||
}
|
||||
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs"; // Force Node runtime
|
||||
|
||||
// Read Ghost API config at runtime, tests may set env vars in beforeAll
|
||||
|
||||
// Funktion, um die XML für die Sitemap zu generieren
|
||||
function generateXml(sitemapRoutes: { url: string; lastModified: string }[]) {
|
||||
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||
const urlsetOpen =
|
||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
|
||||
const urlsetClose = "</urlset>";
|
||||
|
||||
const urlEntries = sitemapRoutes
|
||||
.map(
|
||||
(route) => `
|
||||
<url>
|
||||
<loc>${route.url}</loc>
|
||||
<lastmod>${route.lastModified}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
|
||||
}
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
|
||||
// Statische Routen
|
||||
const staticRoutes = [
|
||||
{
|
||||
url: `${baseUrl}/`,
|
||||
lastModified: new Date().toISOString(),
|
||||
priority: 1,
|
||||
changeFreq: "weekly",
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/legal-notice`,
|
||||
lastModified: new Date().toISOString(),
|
||||
priority: 0.5,
|
||||
changeFreq: "yearly",
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/privacy-policy`,
|
||||
lastModified: new Date().toISOString(),
|
||||
priority: 0.5,
|
||||
changeFreq: "yearly",
|
||||
},
|
||||
];
|
||||
|
||||
// In test environment we can short-circuit and use a mocked posts payload
|
||||
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_POSTS) {
|
||||
const mockData = JSON.parse(process.env.GHOST_MOCK_POSTS);
|
||||
const projects = (mockData as ProjectsData).posts || [];
|
||||
|
||||
const sitemapRoutes = projects.map((project) => {
|
||||
const lastModified = project.updated_at || new Date().toISOString();
|
||||
return {
|
||||
url: `${baseUrl}/projects/${project.slug}`,
|
||||
lastModified,
|
||||
priority: 0.8,
|
||||
changeFreq: "monthly",
|
||||
};
|
||||
});
|
||||
|
||||
const allRoutes = [...staticRoutes, ...sitemapRoutes];
|
||||
const xml = generateXml(allRoutes);
|
||||
|
||||
// For tests return a plain object so tests can inspect `.body` easily
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return new NextResponse(xml, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await getSitemapEntries();
|
||||
const xml = generateSitemapXml(entries);
|
||||
return new NextResponse(xml, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Debug: show whether fetch is present/mocked
|
||||
|
||||
// Try global fetch first (tests may mock global.fetch)
|
||||
let response: Response | undefined;
|
||||
|
||||
try {
|
||||
if (typeof globalThis.fetch === "function") {
|
||||
response = await globalThis.fetch(
|
||||
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
|
||||
);
|
||||
// Debug: inspect the result
|
||||
|
||||
console.log("DEBUG sitemap global fetch returned:", response);
|
||||
}
|
||||
} catch (_e) {
|
||||
response = undefined;
|
||||
}
|
||||
|
||||
if (!response || typeof response.ok === "undefined" || !response.ok) {
|
||||
try {
|
||||
const mod = await import("node-fetch");
|
||||
const nodeFetch = mod.default ?? mod;
|
||||
response = await (nodeFetch as unknown as typeof fetch)(
|
||||
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("Failed to fetch posts from Ghost:", err);
|
||||
return new NextResponse(generateXml(staticRoutes), {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
console.error(
|
||||
`Failed to fetch posts: ${response?.statusText ?? "no response"}`,
|
||||
);
|
||||
return new NextResponse(generateXml(staticRoutes), {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
|
||||
const projectsData = (await response.json()) as ProjectsData;
|
||||
|
||||
const projects = projectsData.posts;
|
||||
|
||||
// Dynamische Projekt-Routen generieren
|
||||
const sitemapRoutes = projects.map((project) => {
|
||||
const lastModified = project.updated_at || new Date().toISOString();
|
||||
return {
|
||||
url: `${baseUrl}/projects/${project.slug}`,
|
||||
lastModified,
|
||||
priority: 0.8,
|
||||
changeFreq: "monthly",
|
||||
};
|
||||
});
|
||||
|
||||
const allRoutes = [...staticRoutes, ...sitemapRoutes];
|
||||
|
||||
// Rückgabe der Sitemap im XML-Format
|
||||
return new NextResponse(generateXml(allRoutes), {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch posts from Ghost:", error);
|
||||
// Rückgabe der statischen Routen, falls Fehler auftritt
|
||||
return new NextResponse(generateXml(staticRoutes), {
|
||||
console.error("Failed to generate sitemap:", error);
|
||||
// Fail closed: return minimal sitemap
|
||||
const xml = generateSitemapXml([]);
|
||||
return new NextResponse(xml, {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ export default function ConsentBanner() {
|
||||
const { consent, setConsent } = useConsent();
|
||||
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
||||
|
||||
const shouldShow = useMemo(() => consent === null, [consent]);
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const locale = useMemo(() => {
|
||||
if (typeof document === "undefined") return "en";
|
||||
const match = document.cookie
|
||||
@@ -20,6 +17,9 @@ export default function ConsentBanner() {
|
||||
return decodeURIComponent(match.split("=").slice(1).join("=")) || "en";
|
||||
}, []);
|
||||
|
||||
const shouldShow = consent === null;
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const s = locale === "de"
|
||||
? {
|
||||
title: "Datenschutz-Einstellungen",
|
||||
|
||||
@@ -26,21 +26,21 @@ const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return (
|
||||
<div>
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return (
|
||||
<div>
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
25
app/robots.txt/route.ts
Normal file
25
app/robots.txt/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBaseUrl } from "@/lib/seo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const base = getBaseUrl();
|
||||
const body = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"Disallow: /api/",
|
||||
"Disallow: /manage",
|
||||
"Disallow: /editor",
|
||||
`Sitemap: ${base}/sitemap.xml`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,67 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
const apiUrl = `${baseUrl}/api/sitemap`; // Verwende die vollständige URL zur API
|
||||
|
||||
// In test runs, allow returning a mocked sitemap explicitly
|
||||
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_SITEMAP) {
|
||||
// For tests return a simple object so tests can inspect `.body`
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
return {
|
||||
body: process.env.GHOST_MOCK_SITEMAP,
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
} as any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
return new NextResponse(process.env.GHOST_MOCK_SITEMAP, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Holt die Sitemap-Daten von der API
|
||||
// Try global fetch first, then fall back to node-fetch
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
let res: any;
|
||||
try {
|
||||
if (typeof (globalThis as any).fetch === "function") {
|
||||
res = await (globalThis as any).fetch(apiUrl);
|
||||
}
|
||||
} catch (_e) {
|
||||
res = undefined;
|
||||
}
|
||||
|
||||
if (!res || typeof res.ok === "undefined" || !res.ok) {
|
||||
try {
|
||||
const mod = await import("node-fetch");
|
||||
const nodeFetch = (mod as any).default ?? mod;
|
||||
res = await (nodeFetch as any)(apiUrl);
|
||||
} catch (err) {
|
||||
console.error("Error fetching sitemap:", err);
|
||||
return new NextResponse("Error fetching sitemap", { status: 500 });
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
if (!res || !res.ok) {
|
||||
console.error(
|
||||
`Failed to fetch sitemap: ${res?.statusText ?? "no response"}`,
|
||||
);
|
||||
return new NextResponse("Failed to fetch sitemap", { status: 500 });
|
||||
}
|
||||
|
||||
const xml = await res.text();
|
||||
|
||||
// Gibt die XML mit dem richtigen Content-Type zurück
|
||||
const entries = await getSitemapEntries();
|
||||
const xml = generateSitemapXml(entries);
|
||||
return new NextResponse(xml, {
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching sitemap:", error);
|
||||
return new NextResponse("Error fetching sitemap", { status: 500 });
|
||||
console.error("Error generating sitemap.xml:", error);
|
||||
return new NextResponse(generateSitemapXml([]), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user