diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index d31ebf9..640885c 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -1,16 +1,16 @@ -name: Dev Deployment (Zero Downtime) +name: Testing Deployment (Zero Downtime) on: push: - branches: [ dev ] + branches: [ testing ] env: NODE_VERSION: '20' DOCKER_IMAGE: portfolio-app - IMAGE_TAG: staging + IMAGE_TAG: testing jobs: - deploy-dev: + deploy-testing: runs-on: ubuntu-latest steps: - name: Checkout code @@ -38,7 +38,7 @@ jobs: - name: Build Docker image run: | - echo "🏗️ Building dev Docker image with BuildKit cache..." + echo "🏗️ Building testing Docker image with BuildKit cache..." DOCKER_BUILDKIT=1 docker build \ --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ --cache-from ${{ env.DOCKER_IMAGE }}:latest \ @@ -46,12 +46,12 @@ jobs: . echo "✅ Docker image built successfully" - - name: Zero-Downtime Dev Deployment + - name: Zero-Downtime Testing Deployment run: | - echo "🚀 Starting zero-downtime dev deployment..." + echo "🚀 Starting zero-downtime testing deployment..." - COMPOSE_FILE="docker-compose.staging.yml" - CONTAINER_NAME="portfolio-app-staging" + COMPOSE_FILE="docker-compose.testing.yml" + CONTAINER_NAME="portfolio-app-testing" HEALTH_PORT="3002" # Backup current container ID if running @@ -59,7 +59,7 @@ jobs: # Start new container with updated image echo "🆕 Starting new dev container..." - docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging + docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-testing # Wait for new container to be healthy echo "⏳ Waiting for new container to be healthy..." @@ -84,8 +84,8 @@ jobs: # Verify new container is working if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then - echo "⚠️ New dev container health check failed, but continuing (non-blocking)..." - docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging + echo "⚠️ New testing container health check failed, but continuing (non-blocking)..." + docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-testing fi # Remove old container if it exists and is different @@ -98,32 +98,33 @@ jobs: fi fi - echo "✅ Dev deployment completed!" + echo "✅ Testing deployment completed!" env: - NODE_ENV: staging + NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }} MY_EMAIL: ${{ vars.MY_EMAIL }} MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }} MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} - - name: Dev Health Check + - name: Testing Health Check run: | - echo "🔍 Running dev health checks..." + echo "🔍 Running testing health checks..." for i in {1..20}; do if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then - echo "✅ Dev is fully operational!" + echo "✅ Testing is fully operational!" exit 0 fi - echo "⏳ Waiting for dev... ($i/20)" + echo "⏳ Waiting for testing... ($i/20)" sleep 3 done - echo "⚠️ Dev health check failed, but continuing (non-blocking)..." - docker compose -f docker-compose.staging.yml logs --tail=50 + echo "⚠️ Testing health check failed, but continuing (non-blocking)..." + docker compose -f docker-compose.testing.yml logs --tail=50 - name: Cleanup run: | diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml index 4312c17..a2eb0ba 100644 --- a/.gitea/workflows/production-deploy.yml +++ b/.gitea/workflows/production-deploy.yml @@ -69,6 +69,7 @@ jobs: export MY_PASSWORD="${{ secrets.MY_PASSWORD }}" export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" + export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" # Start new container with updated image (docker-compose will handle this) echo "🆕 Starting new production container..." @@ -196,12 +197,13 @@ jobs: env: NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} MY_EMAIL: ${{ vars.MY_EMAIL }} MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }} MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} diff --git a/Dockerfile b/Dockerfile index 2d1f28e..820f9de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN adduser --system --uid 1001 nextjs # Copy the built application COPY --from=builder /app/public ./public +COPY --from=builder /app/scripts ./scripts # Set the correct permission for prerender cache RUN mkdir .next @@ -82,6 +83,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Copy Prisma files COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # Note: Environment variables should be passed via docker-compose or runtime environment # DO NOT copy .env files into the image for security reasons @@ -97,4 +100,4 @@ ENV HOSTNAME="0.0.0.0" HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:3000/api/health || exit 1 -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "scripts/start-with-migrate.js"] \ No newline at end of file diff --git a/GITEA_VARIABLES_SETUP.md b/GITEA_VARIABLES_SETUP.md index ff25bcd..1c12127 100644 --- a/GITEA_VARIABLES_SETUP.md +++ b/GITEA_VARIABLES_SETUP.md @@ -10,12 +10,12 @@ In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt) 2. Klicke auf **Settings** (Einstellungen) 3. Klicke auf **Variables** oder **Secrets** im linken Menü -## 🔑 Variablen für Production Branch +## 🔑 Variablen für Production Branch (`production` → `dk0.dev`) Für den `production` Branch brauchst du: ### Variables (öffentlich sichtbar): -- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` +- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` - `MY_EMAIL` = `contact@dk0.dev` (oder deine Email) - `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email) - `LOG_LEVEL` = `info` @@ -25,14 +25,15 @@ Für den `production` Branch brauchst du: - `MY_PASSWORD` = Dein Email-Passwort - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort - `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort` +- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen) - `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional) ## 🧪 Variablen für Dev Branch -Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten: +Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten: ### Variables: -- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!** +- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ⚠️ **WICHTIG: Andere URL!** - `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein) - `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein) - `LOG_LEVEL` = `debug` (für Dev mehr Logging) @@ -41,7 +42,8 @@ Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen W ### Secrets: - `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein) - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein) -- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein) +- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein) +- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen; kann gleich sein) - `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional) ## ✅ Lösung: Automatische Branch-Erkennung @@ -54,38 +56,27 @@ Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch **Production Workflow** (`.gitea/workflows/production-deploy.yml`): - Triggert nur auf `production` Branch -- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev` +- Verwendet: `NEXT_PUBLIC_BASE_URL_PRODUCTION` (wenn gesetzt) oder Default: `https://dk0.dev` -**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`): -- Triggert nur auf `dev` Branch -- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev` +**Testing Workflow** (`.gitea/workflows/dev-deploy.yml`): +- Triggert nur auf `testing` Branch +- Verwendet: `NEXT_PUBLIC_BASE_URL_TESTING` (wenn gesetzt) oder Default: `https://testing.dk0.dev` **Das bedeutet:** -- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea -- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`) -- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`) +- Du setzt **zwei** Variablen in Gitea (empfohlen, weil Gitea nicht branch-spezifisch scoped): + - `NEXT_PUBLIC_BASE_URL_PRODUCTION` + - `NEXT_PUBLIC_BASE_URL_TESTING` ### ⚠️ WICHTIG: - -Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben: - -**Option 1: Variable NICHT setzen (Empfohlen)** -- Production verwendet automatisch: `https://dk0.dev` -- Dev verwendet automatisch: `https://dev.dk0.dev` -- ✅ Funktioniert perfekt ohne Konfiguration! - -**Option 2: Variable setzen** -- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt -- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev) -- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet +Gitea kann Variablen/Secrets nicht pro Branch trennen. Darum nutzen wir **separate Variablennamen** für Production und Testing. ## ✅ Empfohlene Konfiguration -### ⭐ Einfachste Lösung: NICHTS setzen! +### ⭐ Einfachste Lösung: nur URLs setzen (optional) Die Workflows haben bereits die richtigen Defaults: - **Production Branch** → automatisch `https://dk0.dev` -- **Dev Branch** → automatisch `https://dev.dk0.dev` +- **Testing Branch** → automatisch `https://testing.dk0.dev` Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch! @@ -100,10 +91,12 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch! - `MY_PASSWORD` = Dein Email-Passwort - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort - `ADMIN_BASIC_AUTH` = `admin:dein_passwort` +- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen) - `N8N_SECRET_TOKEN` = Dein n8n Token (optional) -**⚠️ NICHT setzen:** -- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet! +**Optional setzen:** +- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` +- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ## 📝 Schritt-für-Schritt Anleitung @@ -116,11 +109,17 @@ https://git.dk0.dev/denshooter/portfolio/settings ### 3. Für Variables (öffentlich): - Klicke auf **"New Variable"** -- **Name:** `NEXT_PUBLIC_BASE_URL` -- **Value:** `https://dk0.dev` (für Production) +- **Name:** `NEXT_PUBLIC_BASE_URL_PRODUCTION` +- **Value:** `https://dk0.dev` - **Protect:** ✅ (optional, schützt vor Änderungen) - Klicke **"Add Variable"** +- Klicke auf **"New Variable"** +- **Name:** `NEXT_PUBLIC_BASE_URL_TESTING` +- **Value:** `https://testing.dk0.dev` +- **Protect:** ✅ (optional) +- Klicke **"Add Variable"** + ### 4. Für Secrets (verschlüsselt): - Klicke auf **"New Secret"** - **Name:** `MY_PASSWORD` @@ -133,53 +132,22 @@ Die Workflows verwenden diese einfache Logik: ```yaml # Production Workflow (triggert nur auf production branch) -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }} +NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} -# Dev Workflow (triggert nur auf dev branch) -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }} +# Testing Workflow (triggert nur auf testing branch) +NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }} ``` **Das bedeutet:** - Jeder Workflow hat seinen **eigenen Default** -- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet +- Wenn die jeweilige Variable (`NEXT_PUBLIC_BASE_URL_PRODUCTION` / `NEXT_PUBLIC_BASE_URL_TESTING`) in Gitea gesetzt ist, wird diese verwendet - Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default **⭐ Beste Lösung:** -- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen -- Dann verwendet Production automatisch `https://dk0.dev` -- Und Dev verwendet automatisch `https://dev.dk0.dev` -- ✅ Perfekt getrennt, ohne Konfiguration! +- Setze `NEXT_PUBLIC_BASE_URL_PRODUCTION` und `NEXT_PUBLIC_BASE_URL_TESTING` (oder lass beide weg und nutze Defaults). ## 🎯 Best Practice -1. **Production:** Setze alle Variablen explizit in Gitea -2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen) -3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code! - -## 🔍 Prüfen ob Variablen gesetzt sind - -In den Workflow-Logs siehst du: -``` -📝 Using Gitea Variables and Secrets: - - NEXT_PUBLIC_BASE_URL: https://dk0.dev -``` - -Wenn eine Variable fehlt, wird der Default verwendet. - -## ⚙️ Alternative: Environment-spezifische Variablen - -Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen: - -```yaml -# Production -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} - -# Dev -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} -``` - -Dann könntest du setzen: -- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` -- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev` - -Soll ich die Workflows entsprechend anpassen? +1. **Production (`production`)**: `docker-compose.production.yml` → Port 3000 → NPM Host `dk0.dev` +2. **Testing (`testing`)**: `docker-compose.testing.yml` → Port 3002 → NPM Host `testing.dk0.dev` +3. **Secrets**: immer als Gitea Secrets, nie im Code diff --git a/README.md b/README.md index 17babc9..2e9a1cd 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,10 @@ npm run start # Production Server ## 📖 Dokumentation - [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung -- [Deployment Guide](DEPLOYMENT.md) - Production Deployment +- [Deployment Setup](DEPLOYMENT_SETUP.md) - Production Deployment - [Analytics](ANALYTICS.md) - Analytics und Performance +- [CMS Guide](docs/CMS_GUIDE.md) - Inhalte/Sprachen pflegen (Rich Text) +- [Testing & Deployment](docs/TESTING_AND_DEPLOYMENT.md) - Branches → Container → Domains ## 🔗 Links diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..b9e024d --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,27 @@ +import { NextIntlClientProvider } from "next-intl"; +import { setRequestLocale } from "next-intl/server"; +import React from "react"; +import ConsentBanner from "../components/ConsentBanner"; + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + // Ensure next-intl actually uses the route segment locale for this request. + setRequestLocale(locale); + // Load messages explicitly by route locale to avoid falling back to the wrong + // language when request-level locale detection is unavailable/misconfigured. + const messages = (await import(`../../messages/${locale}.json`)).default; + + return ( + + {children} + + + ); +} + diff --git a/app/[locale]/legal-notice/page.tsx b/app/[locale]/legal-notice/page.tsx new file mode 100644 index 0000000..4ab1de6 --- /dev/null +++ b/app/[locale]/legal-notice/page.tsx @@ -0,0 +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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/legal-notice`), + languages, + }, + }; +} + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..4068978 --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,23 @@ +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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}`), + languages, + }, + }; +} + +export default function Page() { + return ; +} + diff --git a/app/[locale]/privacy-policy/page.tsx b/app/[locale]/privacy-policy/page.tsx new file mode 100644 index 0000000..1f5b0cd --- /dev/null +++ b/app/[locale]/privacy-policy/page.tsx @@ -0,0 +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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/privacy-policy`), + languages, + }, + }; +} + diff --git a/app/[locale]/projects/[slug]/page.tsx b/app/[locale]/projects/[slug]/page.tsx new file mode 100644 index 0000000..b5571e5 --- /dev/null +++ b/app/[locale]/projects/[slug]/page.tsx @@ -0,0 +1,53 @@ +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 { + 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, +}: { + params: Promise<{ locale: string; slug: string }>; +}) { + const { locale, slug } = await params; + + const project = await prisma.project.findFirst({ + where: { slug, published: true }, + include: { + translations: { + where: { locale }, + select: { title: true, description: true }, + }, + }, + }); + + if (!project) return notFound(); + + const tr = project.translations?.[0]; + const { translations: _translations, ...rest } = project; + const localized = { + ...rest, + title: tr?.title ?? project.title, + description: tr?.description ?? project.description, + }; + + return ; +} + diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx new file mode 100644 index 0000000..0dffa8d --- /dev/null +++ b/app/[locale]/projects/page.tsx @@ -0,0 +1,53 @@ +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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "projects" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/projects`), + languages, + }, + }; +} + +export default async function ProjectsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + const projects = await prisma.project.findMany({ + where: { published: true }, + orderBy: { createdAt: "desc" }, + include: { + translations: { + where: { locale }, + select: { title: true, description: true }, + }, + }, + }); + + const localized = projects.map((p) => { + const tr = p.translations?.[0]; + const { translations: _translations, ...rest } = p; + return { + ...rest, + title: tr?.title ?? p.title, + description: tr?.description ?? p.description, + }; + }); + + return ; +} + diff --git a/app/__tests__/api/fetchAllProjects.test.tsx b/app/__tests__/api/fetchAllProjects.test.tsx index 1ffba9f..3375c14 100644 --- a/app/__tests__/api/fetchAllProjects.test.tsx +++ b/app/__tests__/api/fetchAllProjects.test.tsx @@ -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', }), ]), diff --git a/app/__tests__/api/fetchProject.test.tsx b/app/__tests__/api/fetchProject.test.tsx index 85e443c..c53a5c9 100644 --- a/app/__tests__/api/fetchProject.test.tsx +++ b/app/__tests__/api/fetchProject.test.tsx @@ -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', }, ], }); diff --git a/app/__tests__/api/sitemap.test.tsx b/app/__tests__/api/sitemap.test.tsx index 0a17e68..91e1e9e 100644 --- a/app/__tests__/api/sitemap.test.tsx +++ b/app/__tests__/api/sitemap.test.tsx @@ -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( + () => + 'https://dki.one/en', ), })); 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( '', ); - expect(body).toContain("https://dki.one/"); - expect(body).toContain("https://dki.one/legal-notice"); - expect(body).toContain("https://dki.one/privacy-policy"); - expect(body).toContain( - "https://dki.one/projects/just-doing-some-testing", - ); - expect(body).toContain( - "https://dki.one/projects/blockchain-based-voting-system", - ); + expect(body).toContain("https://dki.one/en"); // Note: Headers are not available in test environment }); }); diff --git a/app/__tests__/components/Header.test.tsx b/app/__tests__/components/Header.test.tsx index e9c1108..8c8edd9 100644 --- a/app/__tests__/components/Header.test.tsx +++ b/app/__tests__/components/Header.test.tsx @@ -21,7 +21,7 @@ describe('Header', () => { it('renders the mobile header', () => { render(
); // Check for mobile menu button (hamburger icon) - const menuButton = screen.getByRole('button'); + const menuButton = screen.getByLabelText('Open menu'); expect(menuButton).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/app/__tests__/sitemap.xml/page.test.tsx b/app/__tests__/sitemap.xml/page.test.tsx index 7511683..e884fe0 100644 --- a/app/__tests__/sitemap.xml/page.test.tsx +++ b/app/__tests__/sitemap.xml/page.test.tsx @@ -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 = ` - - - https://dki.one/ - - - https://dki.one/legal-notice - - - https://dki.one/privacy-policy - - - https://dki.one/projects/just-doing-some-testing - - - https://dki.one/projects/blockchain-based-voting-system - - -`; - -// 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( + () => + 'https://dki.one/en', ), })); 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( '', ); - expect(response.body).toContain("https://dki.one/"); - expect(response.body).toContain("https://dki.one/legal-notice"); - expect(response.body).toContain( - "https://dki.one/privacy-policy", - ); - expect(response.body).toContain( - "https://dki.one/projects/just-doing-some-testing", - ); - expect(response.body).toContain( - "https://dki.one/projects/blockchain-based-voting-system", - ); + expect(response.body).toContain("https://dki.one/en"); // Note: Headers are not available in test environment }); }); diff --git a/app/_ui/ActivityFeedClient.tsx b/app/_ui/ActivityFeedClient.tsx new file mode 100644 index 0000000..525fca9 --- /dev/null +++ b/app/_ui/ActivityFeedClient.tsx @@ -0,0 +1,31 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +type ActivityFeedComponent = React.ComponentType>; + +export default function ActivityFeedClient() { + const [Comp, setComp] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const mod = await import("../components/ActivityFeed"); + const C = (mod as unknown as { default?: ActivityFeedComponent }).default; + if (!cancelled && typeof C === "function") { + setComp(() => C); + } + } catch { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (!Comp) return null; + return ; +} + diff --git a/app/_ui/HomePage.tsx b/app/_ui/HomePage.tsx new file mode 100644 index 0000000..26dc333 --- /dev/null +++ b/app/_ui/HomePage.tsx @@ -0,0 +1,115 @@ +import Header from "../components/Header"; +import Hero from "../components/Hero"; +import About from "../components/About"; +import Projects from "../components/Projects"; +import Contact from "../components/Contact"; +import Footer from "../components/Footer"; +import Script from "next/script"; +import ActivityFeedClient from "./ActivityFeedClient"; + +export default function HomePage() { + return ( +
+ - Dennis Konkol's Portfolio {children} diff --git a/app/legal-notice/page.tsx b/app/legal-notice/page.tsx index 782b249..877dce4 100644 --- a/app/legal-notice/page.tsx +++ b/app/legal-notice/page.tsx @@ -6,8 +6,40 @@ import { ArrowLeft } from 'lucide-react'; import Header from "../components/Header"; import Footer from "../components/Footer"; import Link from "next/link"; +import { useLocale, useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import type { JSONContent } from "@tiptap/react"; +import RichTextClient from "../components/RichTextClient"; export default function LegalNotice() { + const locale = useLocale(); + const t = useTranslations("common"); + const [cmsDoc, setCmsDoc] = useState(null); + const [cmsTitle, setCmsTitle] = useState(null); + + useEffect(() => { + (async () => { + try { + const res = await fetch( + `/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`, + ); + const data = await res.json(); + // Only use CMS content if it exists for the active locale. + if (data?.content?.content && data?.content?.locale === locale) { + setCmsDoc(data.content.content as JSONContent); + setCmsTitle((data.content.title as string | null) ?? null); + } else { + setCmsDoc(null); + setCmsTitle(null); + } + } catch { + // ignore; fallback to static content + setCmsDoc(null); + setCmsTitle(null); + } + })(); + }, [locale]); + return (
@@ -19,15 +51,15 @@ export default function LegalNotice() { className="mb-8" > - Back to Home + {t("backToHome")}

- Impressum + {cmsTitle || "Impressum"}

@@ -37,47 +69,68 @@ export default function LegalNotice() { transition={{ duration: 0.8, delay: 0.2 }} className="glass-card p-8 rounded-2xl space-y-6" > -
-

- Verantwortlicher für die Inhalte dieser Website -

-
-

Name: Dennis Konkol

-

Adresse: Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland

-

E-Mail: info@dk0.dev

-

Website: dk0.dev

-
-
+ {cmsDoc ? ( + + ) : ( + <> +
+

Verantwortlicher für die Inhalte dieser Website

+
+

+ Name: Dennis Konkol +

+

+ Adresse: Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland +

+

+ E-Mail:{" "} + + info@dk0.dev + +

+

+ Website:{" "} + + dk0.dev + +

+
+
-
-

Haftung für Links

-

- Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser Websites - und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der Betreiber oder - Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum Zeitpunkt der Verlinkung - auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen. -

-
+
+

Haftung für Links

+

+ Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser + Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der + Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum + Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde + ich derartige Links umgehend entfernen. +

+
-
-

Urheberrecht

-

- Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter Urheberrechtsschutz. - Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist verboten. -

-
+
+

Urheberrecht

+

+ Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter + Urheberrechtsschutz. Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist + verboten. +

+
-
-

Gewährleistung

-

- Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine - Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser Website. -

-
+
+

Gewährleistung

+

+ Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine + Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser + Website. +

+
-
-

Letzte Aktualisierung: 12.02.2025

-
+
+

Letzte Aktualisierung: 12.02.2025

+
+ + )}