diff --git a/.dockerignore b/.dockerignore index 9d7cfe8..b409662 100644 --- a/.dockerignore +++ b/.dockerignore @@ -57,6 +57,7 @@ docker-compose*.yml # Scripts (keep only essential ones) scripts !scripts/init-db.sql +!scripts/start-with-migrate.js # Misc .cache diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index d31ebf9..c7d01e1 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -7,11 +7,11 @@ on: env: NODE_VERSION: '20' DOCKER_IMAGE: portfolio-app - IMAGE_TAG: staging + IMAGE_TAG: dev jobs: deploy-dev: - runs-on: ubuntu-latest + runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label steps: - name: Checkout code uses: actions/checkout@v3 @@ -50,31 +50,210 @@ jobs: run: | echo "🚀 Starting zero-downtime dev deployment..." - COMPOSE_FILE="docker-compose.staging.yml" - CONTAINER_NAME="portfolio-app-staging" - HEALTH_PORT="3002" + CONTAINER_NAME="portfolio-app-dev" + HEALTH_PORT="3001" + IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}" - # Backup current container ID if running - OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "") + # Check for existing container (running or stopped) + EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") + + # Start DB and Redis if not running + echo "🗄️ Starting database and Redis..." + COMPOSE_FILE="docker-compose.dev.minimal.yml" + + # Stop and remove existing containers to ensure clean start with correct architecture + echo "🧹 Cleaning up existing containers..." + docker stop portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true + docker rm portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true + + # Remove old images to force re-pull with correct architecture + echo "🔄 Removing old images to force re-pull..." + docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true + + # Pull images with correct architecture (Docker will auto-detect) + echo "📥 Pulling images for current architecture..." + docker compose -f $COMPOSE_FILE pull postgres redis + + # Start containers + echo "📦 Starting PostgreSQL and Redis containers..." + docker compose -f $COMPOSE_FILE up -d postgres redis + + # Wait for DB to be ready + echo "⏳ Waiting for database to be ready..." + for i in {1..30}; do + if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then + echo "✅ Database is ready!" + break + fi + echo "⏳ Waiting for database... ($i/30)" + sleep 1 + done + + # Export environment variables + export NODE_ENV=production + export LOG_LEVEL=${LOG_LEVEL:-debug} + export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} + export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public" + export REDIS_URL="redis://portfolio_redis_dev:6379" + export MY_EMAIL=${MY_EMAIL} + export MY_INFO_EMAIL=${MY_INFO_EMAIL} + export MY_PASSWORD=${MY_PASSWORD} + export MY_INFO_PASSWORD=${MY_INFO_PASSWORD} + export ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} + export ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} + export N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} + export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} + export PORT=${HEALTH_PORT} + + # Stop and remove existing container if it exists (running or stopped) + if [ ! -z "$EXISTING_CONTAINER" ]; then + echo "🛑 Stopping and removing existing container..." + docker stop $EXISTING_CONTAINER 2>/dev/null || true + docker rm $EXISTING_CONTAINER 2>/dev/null || true + echo "✅ Old container removed" + # Wait for Docker to release the port + echo "⏳ Waiting for Docker to release port ${HEALTH_PORT}..." + sleep 3 + fi + + # Check if port is still in use by Docker containers (check all containers, not just running) + PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") + if [ ! -z "$PORT_CONTAINER" ]; then + echo "⚠️ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER" + echo "🛑 Stopping and removing container using port..." + docker stop $PORT_CONTAINER 2>/dev/null || true + docker rm $PORT_CONTAINER 2>/dev/null || true + sleep 3 + fi + + # Also check for any containers with the same name that might be using the port + SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "") + if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then + echo "⚠️ Found another container with same name: $SAME_NAME_CONTAINER" + docker stop $SAME_NAME_CONTAINER 2>/dev/null || true + docker rm $SAME_NAME_CONTAINER 2>/dev/null || true + sleep 2 + fi + + # Also check if port is in use by another process (non-Docker) + PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "") + if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then + echo "⚠️ Port ${HEALTH_PORT} is in use by process" + echo "Attempting to free the port..." + # Try to find and kill the process + if command -v lsof >/dev/null 2>&1; then + PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") + if [ ! -z "$PID" ]; then + kill -9 $PID 2>/dev/null || true + sleep 2 + fi + fi + fi + + # Final check: verify port is free and wait if needed + echo "🔍 Verifying port ${HEALTH_PORT} is free..." + MAX_WAIT=10 + WAIT_COUNT=0 + while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") + if [ -z "$PORT_CHECK" ]; then + # Also check with lsof/ss if available + if command -v lsof >/dev/null 2>&1; then + PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") + elif command -v ss >/dev/null 2>&1; then + PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "") + fi + fi + if [ -z "$PORT_CHECK" ]; then + echo "✅ Port ${HEALTH_PORT} is free!" + break + fi + WAIT_COUNT=$((WAIT_COUNT + 1)) + echo "⏳ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)" + sleep 1 + done + + # If port is still in use, try alternative port + if [ $WAIT_COUNT -ge $MAX_WAIT ]; then + echo "⚠️ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..." + HEALTH_PORT="3002" + echo "🔄 Using alternative port: ${HEALTH_PORT}" + # Quick check if alternative port is also in use + ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") + if [ ! -z "$ALT_PORT_CHECK" ]; then + echo "❌ Alternative port ${HEALTH_PORT} is also in use!" + echo "Attempting to free alternative port..." + ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") + if [ ! -z "$ALT_CONTAINER" ]; then + docker stop $ALT_CONTAINER 2>/dev/null || true + docker rm $ALT_CONTAINER 2>/dev/null || true + sleep 2 + fi + fi + fi + + # Ensure networks exist + echo "🌐 Checking for networks..." + if ! docker network inspect proxy >/dev/null 2>&1; then + echo "⚠️ Proxy network not found, creating it..." + docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed" + else + echo "✅ Proxy network exists" + fi + + if ! docker network inspect portfolio_dev >/dev/null 2>&1; then + echo "⚠️ Portfolio dev network not found, creating it..." + docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed" + else + echo "✅ Portfolio dev network exists" + fi + + # Connect proxy network to portfolio_dev network if needed + # (This allows the app to access both proxy and DB/Redis) # Start new container with updated image echo "🆕 Starting new dev container..." - docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging + docker run -d \ + --name $CONTAINER_NAME \ + --restart unless-stopped \ + --network portfolio_dev \ + -p ${HEALTH_PORT}:3000 \ + -e NODE_ENV=production \ + -e LOG_LEVEL=${LOG_LEVEL:-debug} \ + -e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \ + -e DATABASE_URL=${DATABASE_URL} \ + -e REDIS_URL=${REDIS_URL} \ + -e MY_EMAIL=${MY_EMAIL} \ + -e MY_INFO_EMAIL=${MY_INFO_EMAIL} \ + -e MY_PASSWORD=${MY_PASSWORD} \ + -e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \ + -e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \ + -e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \ + -e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \ + -e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \ + $IMAGE_NAME + + # Connect container to proxy network as well (for external access) + echo "🔗 Connecting container to proxy network..." + docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network" # Wait for new container to be healthy echo "⏳ Waiting for new container to be healthy..." + HEALTH_CHECK_PASSED=false for i in {1..60}; do NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME) if [ ! -z "$NEW_CONTAINER" ]; then - # Check health status + # Check Docker health status HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") if [ "$HEALTH" == "healthy" ]; then echo "✅ New container is healthy!" + HEALTH_CHECK_PASSED=true break fi # Also check HTTP health endpoint if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then echo "✅ New container is responding!" + HEALTH_CHECK_PASSED=true break fi fi @@ -83,9 +262,9 @@ jobs: done # Verify new container is working - if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then + if [ "$HEALTH_CHECK_PASSED" != "true" ]; then echo "⚠️ New dev container health check failed, but continuing (non-blocking)..." - docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging + docker logs $CONTAINER_NAME --tail=50 fi # Remove old container if it exists and is different @@ -100,14 +279,17 @@ jobs: echo "✅ Dev 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_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} + DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public + REDIS_URL: redis://portfolio_redis_dev:6379 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 || '' }} @@ -115,7 +297,7 @@ jobs: run: | echo "🔍 Running dev health checks..." for i in {1..20}; do - if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then + if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then echo "✅ Dev is fully operational!" exit 0 fi @@ -123,7 +305,7 @@ jobs: sleep 3 done echo "⚠️ Dev health check failed, but continuing (non-blocking)..." - docker compose -f docker-compose.staging.yml logs --tail=50 + docker logs portfolio-app-dev --tail=50 - name: Cleanup run: | diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml index 4312c17..822943a 100644 --- a/.gitea/workflows/production-deploy.yml +++ b/.gitea/workflows/production-deploy.yml @@ -11,7 +11,7 @@ env: jobs: deploy-production: - runs-on: ubuntu-latest + runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label steps: - name: Checkout code uses: actions/checkout@v3 @@ -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/.gitignore b/.gitignore index b557940..9a0b3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# Sentry +.sentryclirc +sentry.properties + # vercel .vercel diff --git a/DEPLOYMENT_SETUP.md b/DEPLOYMENT_SETUP.md deleted file mode 100644 index 20636a2..0000000 --- a/DEPLOYMENT_SETUP.md +++ /dev/null @@ -1,200 +0,0 @@ -# 🚀 Deployment Setup Guide - -## Overview - -This project uses a **dual-branch deployment strategy** with zero-downtime deployments: - -- **Production Branch** (`production`) → Serves `https://dk0.dev` on port 3000 -- **Dev Branch** (`dev`) → Serves `https://dev.dk0.dev` on port 3002 - -Both environments are completely isolated with separate: -- Docker containers -- Databases (PostgreSQL) -- Redis instances -- Networks -- Volumes - -## Branch Strategy - -### Production Branch -- **Branch**: `production` -- **Domain**: `https://dk0.dev` -- **Port**: `3000` -- **Container**: `portfolio-app` -- **Database**: `portfolio_db` (port 5432) -- **Redis**: `portfolio-redis` (port 6379) -- **Image Tag**: `portfolio-app:production` / `portfolio-app:latest` - -### Dev Branch -- **Branch**: `dev` -- **Domain**: `https://dev.dk0.dev` -- **Port**: `3002` -- **Container**: `portfolio-app-staging` -- **Database**: `portfolio_staging_db` (port 5434) -- **Redis**: `portfolio-redis-staging` (port 6381) -- **Image Tag**: `portfolio-app:staging` - -## Automatic Deployment - -### How It Works - -1. **Push to `production` branch**: - - Triggers `.gitea/workflows/production-deploy.yml` - - Runs tests, builds, and deploys to production - - Zero-downtime deployment (starts new container, waits for health, removes old) - -2. **Push to `dev` branch**: - - Triggers `.gitea/workflows/dev-deploy.yml` - - Runs tests, builds, and deploys to dev/staging - - Zero-downtime deployment - -### Zero-Downtime Process - -1. Build new Docker image -2. Start new container with updated image -3. Wait for new container to be healthy (health checks) -4. Verify HTTP endpoints respond correctly -5. Remove old container (if different) -6. Cleanup old images - -## Manual Deployment - -### Production -```bash -# Build and deploy production -docker build -t portfolio-app:latest . -docker compose -f docker-compose.production.yml up -d --build -``` - -### Dev/Staging -```bash -# Build and deploy dev -docker build -t portfolio-app:staging . -docker compose -f docker-compose.staging.yml up -d --build -``` - -## Environment Variables - -### Required Gitea Variables -- `NEXT_PUBLIC_BASE_URL` - Base URL for the application -- `MY_EMAIL` - Email address for contact -- `MY_INFO_EMAIL` - Info email address -- `LOG_LEVEL` - Logging level (info/debug) - -### Required Gitea Secrets -- `MY_PASSWORD` - Email password -- `MY_INFO_PASSWORD` - Info email password -- `ADMIN_BASIC_AUTH` - Admin basic auth credentials -- `N8N_SECRET_TOKEN` - Optional: n8n webhook secret - -### Optional Variables -- `N8N_WEBHOOK_URL` - n8n webhook URL for automation - -## Health Checks - -Both environments have health check endpoints: -- Production: `http://localhost:3000/api/health` -- Dev: `http://localhost:3002/api/health` - -## Monitoring - -### Check Container Status -```bash -# Production -docker compose -f docker-compose.production.yml ps - -# Dev -docker compose -f docker-compose.staging.yml ps -``` - -### View Logs -```bash -# Production -docker logs portfolio-app --tail=100 -f - -# Dev -docker logs portfolio-app-staging --tail=100 -f -``` - -### Health Check -```bash -# Production -curl http://localhost:3000/api/health - -# Dev -curl http://localhost:3002/api/health -``` - -## Troubleshooting - -### Container Won't Start -1. Check logs: `docker logs ` -2. Verify environment variables are set -3. Check database/redis connectivity -4. Verify ports aren't already in use - -### Deployment Fails -1. Check Gitea Actions logs -2. Verify all required secrets/variables are set -3. Check if old containers are blocking ports -4. Verify Docker image builds successfully - -### Zero-Downtime Issues -- Old container might still be running - check with `docker ps` -- Health checks might be failing - check container logs -- Port conflicts - verify ports 3000 and 3002 are available - -## Rollback - -If a deployment fails or causes issues: - -```bash -# Production rollback -docker compose -f docker-compose.production.yml down -docker tag portfolio-app:previous portfolio-app:latest -docker compose -f docker-compose.production.yml up -d - -# Dev rollback -docker compose -f docker-compose.staging.yml down -docker tag portfolio-app:staging-previous portfolio-app:staging -docker compose -f docker-compose.staging.yml up -d -``` - -## Best Practices - -1. **Always test on dev branch first** before pushing to production -2. **Monitor health checks** after deployment -3. **Keep old images** for quick rollback (last 3 versions) -4. **Use feature flags** for new features -5. **Document breaking changes** before deploying -6. **Run tests locally** before pushing - -## Network Configuration - -- **Production Network**: `portfolio_net` + `proxy` (external) -- **Dev Network**: `portfolio_staging_net` -- **Isolation**: Complete separation ensures no interference - -## Database Management - -### Production Database -- **Container**: `portfolio-postgres` -- **Port**: `5432` (internal only) -- **Database**: `portfolio_db` -- **User**: `portfolio_user` - -### Dev Database -- **Container**: `portfolio-postgres-staging` -- **Port**: `5434` (external), `5432` (internal) -- **Database**: `portfolio_staging_db` -- **User**: `portfolio_user` - -## Redis Configuration - -### Production Redis -- **Container**: `portfolio-redis` -- **Port**: `6379` (internal only) - -### Dev Redis -- **Container**: `portfolio-redis-staging` -- **Port**: `6381` (external), `6379` (internal) diff --git a/DEV-SETUP.md b/DEV-SETUP.md deleted file mode 100644 index 8cb5710..0000000 --- a/DEV-SETUP.md +++ /dev/null @@ -1,239 +0,0 @@ -# 🚀 Development Environment Setup - -This document explains how to set up and use the development environment for the portfolio project. - -## ✨ Features - -- **Automatic Database Setup**: PostgreSQL and Redis start automatically -- **Hot Reload**: Next.js development server with hot reload -- **Database Integration**: Real database integration for email management -- **Modern Admin Dashboard**: Completely redesigned admin interface -- **Minimal Setup**: Only essential services for fast development - -## 🛠️ Quick Start - -### Prerequisites - -- Node.js 18+ -- Docker & Docker Compose -- npm or yarn - -### 1. Install Dependencies - -```bash -npm install -``` - -### 2. Start Development Environment - -#### Option A: Full Development Environment (with Docker) -```bash -npm run dev -``` - -This single command will: -- Start PostgreSQL database -- Start Redis cache -- Start Next.js development server -- Set up all environment variables - -#### Option B: Simple Development Mode (without Docker) -```bash -npm run dev:simple -``` - -This starts only the Next.js development server without Docker services. Use this if you don't have Docker installed or want a faster startup. - -### 3. Access Services - -- **Portfolio**: http://localhost:3000 -- **Admin Dashboard**: http://localhost:3000/manage -- **PostgreSQL**: localhost:5432 -- **Redis**: localhost:6379 - -## 📧 Email Testing - -The development environment supports email functionality: - -1. Send emails through the contact form or admin panel -2. Emails are sent directly (configure SMTP in production) -3. Check console logs for email debugging - -## 🗄️ Database - -### Development Database - -- **Host**: localhost:5432 -- **Database**: portfolio_dev -- **User**: portfolio_user -- **Password**: portfolio_dev_pass - -### Database Commands - -```bash -# Generate Prisma client -npm run db:generate - -# Push schema changes -npm run db:push - -# Seed database with sample data -npm run db:seed - -# Open Prisma Studio -npm run db:studio - -# Reset database -npm run db:reset -``` - -## 🎨 Admin Dashboard - -The new admin dashboard includes: - -- **Overview**: Statistics and recent activity -- **Projects**: Manage portfolio projects -- **Emails**: Handle contact form submissions with beautiful templates -- **Analytics**: View performance metrics -- **Settings**: Import/export functionality - -### Email Templates - -Three beautiful email templates are available: - -1. **Welcome Template** (Green): Friendly greeting with portfolio links -2. **Project Template** (Purple): Professional project discussion response -3. **Quick Template** (Orange): Fast acknowledgment response - -## 🔧 Environment Variables - -Create a `.env.local` file: - -```env -# Development Database -DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public" - -# Redis -REDIS_URL="redis://localhost:6379" - -# Email (for production) -MY_EMAIL=contact@dk0.dev -MY_PASSWORD=your-email-password - -# Application -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -NODE_ENV=development -``` - -## 🛑 Stopping the Environment - -Use Ctrl+C to stop all services, or: - -```bash -# Stop Docker services only -npm run docker:dev:down -``` - -## 🐳 Docker Commands - -```bash -# Start only database services -npm run docker:dev - -# Stop database services -npm run docker:dev:down - -# View logs -docker compose -f docker-compose.dev.minimal.yml logs -f -``` - -## 📁 Project Structure - -``` -├── docker-compose.dev.minimal.yml # Minimal development services -├── scripts/ -│ ├── dev-minimal.js # Main development script -│ ├── dev-simple.js # Simple development script -│ ├── setup-database.js # Database setup script -│ └── init-db.sql # Database initialization -├── app/ -│ ├── admin/ # Admin dashboard -│ ├── api/ -│ │ ├── contacts/ # Contact management API -│ │ └── email/ # Email sending API -│ └── components/ -│ ├── ModernAdminDashboard.tsx -│ ├── EmailManager.tsx -│ └── EmailResponder.tsx -└── prisma/ - └── schema.prisma # Database schema -``` - -## 🚨 Troubleshooting - -### Docker Compose Not Found - -If you get the error `spawn docker compose ENOENT`: - -```bash -# Try the simple dev mode instead -npm run dev:simple - -# Or install Docker Desktop -# Download from: https://www.docker.com/products/docker-desktop -``` - -### Port Conflicts - -If ports are already in use: - -```bash -# Check what's using the ports -lsof -i :3000 -lsof -i :5432 -lsof -i :6379 - -# Kill processes if needed -kill -9 -``` - -### Database Connection Issues - -```bash -# Restart database services -npm run docker:dev:down -npm run docker:dev - -# Check database status -docker compose -f docker-compose.dev.minimal.yml ps -``` - -### Email Not Working - -1. Verify environment variables -2. Check browser console for errors -3. Ensure SMTP is configured for production - -## 🎯 Production Deployment - -For production deployment, use: - -```bash -npm run build -npm run start -``` - -The production environment uses the production Docker Compose configuration. - -## 📝 Notes - -- The development environment automatically creates sample data -- Database changes are persisted in Docker volumes -- Hot reload works for all components and API routes -- Minimal setup for fast development startup - -## 🔗 Links - -- **Portfolio**: https://dk0.dev -- **Admin**: https://dk0.dev/manage -- **GitHub**: https://github.com/denniskonkol/portfolio diff --git a/DIRECTUS_CHECKLIST.md b/DIRECTUS_CHECKLIST.md new file mode 100644 index 0000000..dd97ca7 --- /dev/null +++ b/DIRECTUS_CHECKLIST.md @@ -0,0 +1,269 @@ +# Directus CMS – Eingabe-Checkliste + +## Collections und Struktur + +Du hast zwei Collections in Directus: +1. **messages** – kurze UI-Texte (Keys mit Werten) +2. **content_pages** – längere Abschnitte (Slug mit Rich Text) + +--- + +## Collection: messages + +Alle folgenden Einträge in Directus anlegen. Format: +| key | locale | value | + +### Navigation & Header +``` +nav.home | en | Home +nav.home | de | Startseite +nav.about | en | About +nav.about | de | Über mich +nav.projects | en | Projects +nav.projects | de | Projekte +nav.contact | en | Contact +nav.contact | de | Kontakt +``` + +### Footer +``` +footer.role | en | Software Engineer +footer.role | de | Software Engineer +footer.madeIn | en | Made in Germany +footer.madeIn | de | Made in Germany +footer.legalNotice | en | Legal notice +footer.legalNotice | de | Impressum +footer.privacyPolicy | en | Privacy policy +footer.privacyPolicy | de | Datenschutz +footer.privacySettings| en | Privacy settings +footer.privacySettings| de | Datenschutz-Einstellungen +footer.privacySettingsTitle | en | Show privacy settings banner again +footer.privacySettingsTitle | de | Datenschutz-Banner wieder anzeigen +footer.builtWith | en | Built with +footer.builtWith | de | Built with +``` + +### Home – Hero +``` +home.hero.features.f1 | en | Next.js & Flutter +home.hero.features.f1 | de | Next.js & Flutter +home.hero.features.f2 | en | Docker Swarm & CI/CD +home.hero.features.f2 | de | Docker Swarm & CI/CD +home.hero.features.f3 | en | Self-Hosted Infrastructure +home.hero.features.f3 | de | Self-Hosted Infrastruktur +``` + +### Home – About +``` +home.about.title | en | About Me +home.about.title | de | Über mich +home.about.techStackTitle | en | My Tech Stack +home.about.techStackTitle | de | Mein Tech Stack +home.about.hobbiesTitle | en | When I'm Not Coding +home.about.hobbiesTitle | de | Wenn ich nicht code +home.about.currentlyReading.title | en | Currently Reading +home.about.currentlyReading.title | de | Aktuell am Lesen +home.about.currentlyReading.progress | en | Progress +home.about.currentlyReading.progress | de | Fortschritt +``` + +### Home – Projects (List) +``` +home.projects.title | en | Selected Works +home.projects.title | de | Ausgewählte Projekte +home.projects.subtitle | en | A collection of projects I've worked on... +home.projects.subtitle | de | Eine Auswahl an Projekten, an denen ich gearbeitet habe... +home.projects.featured | en | Featured +home.projects.featured | de | Hervorgehoben +home.projects.viewAll | en | View All Projects +home.projects.viewAll | de | Alle Projekte ansehen +``` + +### Home – Contact +``` +home.contact.title | en | Contact Me +home.contact.title | de | Kontakt +home.contact.subtitle | en | Interested in working together... +home.contact.subtitle | de | Du willst zusammenarbeiten... +home.contact.getInTouch | en | Get In Touch +home.contact.getInTouch | de | Melde dich +home.contact.getInTouchBody | en | I'm always available to discuss... +home.contact.getInTouchBody | de | Ich bin immer offen für neue Chancen... +home.contact.info.email | en | Email +home.contact.info.email | de | E-Mail +home.contact.info.location | en | Location +home.contact.info.location | de | Ort +home.contact.info.locationValue | en | Osnabrück, Germany +home.contact.info.locationValue | de | Osnabrück, Deutschland +``` + +### Common +``` +common.backToHome | en | Back to Home +common.backToHome | de | Zurück zur Startseite +common.backToProjects | en | Back to Projects +common.backToProjects | de | Zurück zu den Projekten +common.viewAllProjects | en | View All Projects +common.viewAllProjects | de | Alle Projekte ansehen +common.loading | en | Loading... +common.loading | de | Lädt... +``` + +### Projects – List +``` +projects.list.title | en | My Projects +projects.list.title | de | Meine Projekte +projects.list.intro | en | Explore my portfolio... +projects.list.intro | de | Stöbere durch mein Portfolio... +projects.list.searchPlaceholder | en | Search projects... +projects.list.searchPlaceholder | de | Projekte durchsuchen... +projects.list.all | en | All +projects.list.all | de | Alle +projects.list.noResults | en | No projects found... +projects.list.noResults | de | Keine Projekte passen... +projects.list.clearFilters | en | Clear filters +projects.list.clearFilters | de | Filter zurücksetzen +``` + +### Projects – Detail +``` +projects.detail.links | en | Project Links +projects.detail.links | de | Projektlinks +projects.detail.liveDemo | en | Live Demo +projects.detail.liveDemo | de | Live-Demo +projects.detail.liveNotAvailable | en | Live demo not available +projects.detail.liveNotAvailable | de | Keine Live-Demo verfügbar +projects.detail.viewSource | en | View Source +projects.detail.viewSource | de | Quellcode ansehen +projects.detail.techStack | en | Tech Stack +projects.detail.techStack | de | Tech-Stack +``` + +### Consent & Privacy +``` +consent.title | en | Privacy settings +consent.title | de | Datenschutz-Einstellungen +consent.description | en | We use optional services... +consent.description | de | Wir nutzen optionale Dienste... +consent.essential | en | Essential +consent.essential | de | Essentiell +consent.analytics | en | Analytics +consent.analytics | de | Analytics +consent.chat | en | Chatbot +consent.chat | de | Chatbot +consent.alwaysOn | en | Always on +consent.alwaysOn | de | Immer aktiv +consent.acceptAll | en | Accept all +consent.acceptAll | de | Alles akzeptieren +consent.acceptSelected | en | Accept selected +consent.acceptSelected | de | Auswahl akzeptieren +consent.rejectAll | en | Reject all +consent.rejectAll | de | Alles ablehnen +consent.hide | en | Hide +consent.hide | de | Ausblenden +``` + +--- + +## Collection: content_pages + +Diese sind für **längere Inhalte**. Nutze den Ric-Text-Editor in Directus oder Markdown. + +### Home – Hero (langere Beschreibung) +- **slug**: home-hero +- **locale**: en / de +- **title** (optional): Hero Section Description +- **content**: Längerer Text/Rich Text (ersetzen die kurze beschreibung) + +Beispiel EN: +> "I'm a passionate software engineer and self-hoster from Osnabrück, Germany. I build full-stack web applications with Next.js, create mobile solutions with Flutter, and love exploring DevOps. I run my own infrastructure and automate deployments with CI/CD." + +Beispiel DE: +> "Ich bin ein leidenschaftlicher Softwareentwickler und Self-Hoster aus Osnabrück. Ich entwickle Full-Stack Web-Apps mit Next.js, mobile Apps mit Flutter und bin begeistert von DevOps. Ich betreibe meine eigene Infrastruktur und automatisiere Deployments." + +### Home – About (längere Inhalte) +- **slug**: home-about +- **locale**: en / de +- **content**: Längerer Fließtext über mich + +### Home – Projects Intro +- **slug**: home-projects +- **locale**: en / de +- **content**: Intro-Text vor der Projekt-Liste + +### Home – Contact Intro +- **slug**: home-contact +- **locale**: en / de +- **content**: Intro vor dem Kontakt-Formular + +--- + +## Wie du es in Directus eingeben kannst: + +### Schritt 1: messages Collection +1. Gehe in Directus → **messages**. +2. Klick "Create New" (oder "+"). +3. Füll aus: + - **key**: z. B. "nav.home" + - **locale**: Dropdown → "en" oder "de" + - **value**: Der Text (z. B. "Home") +4. Speichern. Wiederholen für alle Keys oben. + +### Schritt 2: content_pages Collection +1. Gehe in Directus → **content_pages**. +2. Klick "Create New". +3. Füll aus: + - **slug**: z. B. "home-hero" + - **locale**: "en" oder "de" + - **title** (optional): "Hero Section" oder leer + - **content**: Markdown/Rich Text eingeben +4. Speichern. Wiederholen für andere Seiten. + +--- + +## Im Code: Texte nutzen + +### Kurze Keys (aus messages): +```tsx +import { getLocalizedMessage } from '@/lib/i18n-loader'; + +const text = await getLocalizedMessage('nav.home', locale); +// text = "Home" (oder fallback aus JSON) +``` + +### Längere Inhalte (aus content_pages): +```tsx +import { getLocalizedContent } from '@/lib/i18n-loader'; + +const page = await getLocalizedContent('home-hero', locale); +// page.content = "Längerer Fließtext..." +``` + +--- + +## Quick-Test: + +1. Lege in Directus **einen** Key in messages an: + - key: "test" + - locale: "en" + - value: "Hello from Directus" + +2. Im Code: + ```tsx + const text = await getLocalizedMessage('test', 'en'); + console.log(text); // sollte "Hello from Directus" loggen + ``` + +3. Wenn das funktioniert: Alle anderen Keys eintragen! + +--- + +## Hinweise: + +- **Keys** sollten mit `.` strukturiert sein (z. B. `nav.home`, `home.about.title`). +- **Locale** ist immer "en" oder "de" (enum). +- **Fallback**: Wenn ein Key in Directus fehlt, nutzt der Code die `messages/*.json` Dateien. +- **Caching**: Texte werden 5 Minuten gecacht. Um Cache zu leeren: `clearI18nCache()` im Code oder Server restart. +- **Rich Text**: Im `content_pages` Feld kannst du Markdown oder den Rich-Text-Editor nutzen. + +Viel Spaß! 🚀 diff --git a/DIRECTUS_MIGRATION.md b/DIRECTUS_MIGRATION.md new file mode 100644 index 0000000..c2810e3 --- /dev/null +++ b/DIRECTUS_MIGRATION.md @@ -0,0 +1,146 @@ +# Directus Integration - Migration Guide + +## 🎯 Overview + +This portfolio now has a **hybrid i18n system**: +- ✅ **JSON Files** (Primary) → All translations work from `messages/*.json` files +- ✅ **Directus CMS** (Optional) → Can override translations dynamically without rebuilds + +**Important**: Directus is **optional**. The app works perfectly fine without it using JSON fallbacks. + +## 📁 New File Structure + +### Core Infrastructure +- `lib/directus.ts` - REST Client for Directus (uses `de-DE`, `en-US` locale codes) +- `lib/i18n-loader.ts` - Loads texts with Fallback Chain +- `lib/translations-loader.ts` - Batch loader for all sections (cleaned up to match actual usage) +- `types/translations.ts` - TypeScript types for all translation objects (fixed to match components) + +### Components +All component wrappers properly load and pass translations to client components. + +## 🔄 How It Works + +### Without Directus (Default) +``` +Component → useTranslations("nav") → JSON File (messages/en.json) +``` + +### With Directus (Optional) +``` +Server Component → getNavTranslations(locale) + → Try Directus API (de-DE/en-US) + → If not found: JSON File (de/en) + → Props to Client Component +``` + +## 🗄️ Directus Setup (Optional) + +Only set this up if you want to edit translations through a CMS without rebuilding the app. + +### 1. Environment Variables + +Add to `.env.local`: +```bash +DIRECTUS_URL=https://cms.example.com +DIRECTUS_STATIC_TOKEN=your_token_here +``` + +**If these are not set**, the system will skip Directus and use JSON files only. + +### 2. Collection: `messages` + +Create a `messages` collection in Directus with these fields: +- `key` (String, required) - e.g., "nav.home" +- `translations` (Translations) - Directus native translations feature +- Configure languages: `en-US` and `de-DE` + +**Note**: Keys use dot notation (`nav.home`) but locales use dashes (`en-US`, `de-DE`). + +### 3. Permissions + +Grant **Public** role read access to `messages` collection. + +## 📝 Translation Keys + +See `docs/LOCALE_SYSTEM.md` for the complete list of translation keys and their structure. + +All keys are organized hierarchically: +- `nav.*` - Navigation items +- `home.hero.*` - Hero section +- `home.about.*` - About section +- `home.projects.*` - Projects section +- `home.contact.*` - Contact form and info +- `footer.*` - Footer content +- `consent.*` - Privacy consent banner + +## 🎨 Rich Text Content + +For longer content that needs formatting (bold, italic, lists), use the `content_pages` collection: + +### Collection: `content_pages` (Optional) + +Fields: +- `slug` (String, unique) - e.g., "home-hero" +- `locale` (String) - `en` or `de` +- `title` (String) +- `content` (Rich Text or Long Text) + +Examples: +- `home-hero` - Hero section description +- `home-about` - About section content +- `home-contact` - Contact intro text + +Components fetch these via `/api/content/page` and render using `RichTextClient`. + +## 🔍 Fallback Chain + +For every translation key, the system searches in this order: + +1. **Directus** (if configured) in requested locale (e.g., `de-DE`) +2. **Directus** in English fallback (e.g., `en-US`) +3. **JSON file** in requested locale (e.g., `messages/de.json`) +4. **JSON file** in English (e.g., `messages/en.json`) +5. **Key itself** as last resort (e.g., returns `"nav.home"`) + +## ✅ What Was Fixed + +Previous issues that have been resolved: + +1. ✅ **Type mismatches** - All translation types now match actual component usage +2. ✅ **Unused fields** - Removed translation keys that were never used (like `hero.greeting`, `hero.name`) +3. ✅ **Wrong structure** - Fixed `AboutTranslations` structure (removed fake `interests` nesting) +4. ✅ **Missing keys** - Aligned loaders with JSON files and actual component requirements +5. ✅ **Confusing comments** - Removed misleading comments in `translations-loader.ts` + +## 🎯 Best Practices + +1. **Always maintain JSON files** - Even if using Directus, keep JSON files as fallback +2. **Use types** - TypeScript types ensure correct usage +3. **Test without Directus** - App should work perfectly without CMS configured +4. **Rich text for formatting** - Use `content_pages` for content that needs bold/italic/lists +5. **JSON for UI labels** - Use JSON/messages for short UI strings like buttons and labels + +## 🐛 Troubleshooting + +### Directus not configured +**This is normal!** The app works fine. All translations come from JSON files. + +### Want to use Directus? +1. Set up `DIRECTUS_URL` and `DIRECTUS_STATIC_TOKEN` +2. Create `messages` collection +3. Add your translations +4. They will override JSON values + +### Translation not showing? +Check in this order: +1. Does key exist in `messages/en.json`? +2. Is the key spelled correctly? +3. Is component using correct namespace? + +## 📚 Further Reading + +- **Complete locale documentation**: `docs/LOCALE_SYSTEM.md` +- **Directus setup checklist**: `DIRECTUS_CHECKLIST.md` +- **Operations guide**: `docs/OPERATIONS.md` + diff --git a/Dockerfile b/Dockerfile index 2d1f28e..2487baa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,6 +82,12 @@ 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 + +# Create scripts directory and copy start script AFTER standalone to ensure it's not overwritten +RUN mkdir -p scripts && chown nextjs:nodejs scripts +COPY --from=builder --chown=nextjs:nodejs /app/scripts/start-with-migrate.js ./scripts/start-with-migrate.js # 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 +103,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 deleted file mode 100644 index ff25bcd..0000000 --- a/GITEA_VARIABLES_SETUP.md +++ /dev/null @@ -1,185 +0,0 @@ -# 🔧 Gitea Variables & Secrets Setup Guide - -## Übersicht - -In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt) für dein Repository setzen. Diese werden in den CI/CD Workflows verwendet. - -## 📍 Wo findest du die Einstellungen? - -1. Gehe zu deinem Repository auf Gitea -2. Klicke auf **Settings** (Einstellungen) -3. Klicke auf **Variables** oder **Secrets** im linken Menü - -## 🔑 Variablen für Production Branch - -Für den `production` Branch brauchst du: - -### Variables (öffentlich sichtbar): -- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` -- `MY_EMAIL` = `contact@dk0.dev` (oder deine Email) -- `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email) -- `LOG_LEVEL` = `info` -- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional) - -### Secrets (verschlüsselt): -- `MY_PASSWORD` = Dein Email-Passwort -- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort -- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort` -- `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: - -### Variables: -- `NEXT_PUBLIC_BASE_URL` = `https://dev.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) -- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional) - -### 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) -- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional) - -## ✅ Lösung: Automatische Branch-Erkennung - -**Gitea unterstützt keine branch-spezifischen Variablen, aber die Workflows erkennen automatisch den Branch!** - -### Wie es funktioniert: - -Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch die richtigen Defaults: - -**Production Workflow** (`.gitea/workflows/production-deploy.yml`): -- Triggert nur auf `production` Branch -- Verwendet: `NEXT_PUBLIC_BASE_URL` (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` - -**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`) - -### ⚠️ 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 - -## ✅ Empfohlene Konfiguration - -### ⭐ Einfachste Lösung: NICHTS setzen! - -Die Workflows haben bereits die richtigen Defaults: -- **Production Branch** → automatisch `https://dk0.dev` -- **Dev Branch** → automatisch `https://dev.dk0.dev` - -Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch! - -### Wenn du Variablen setzen willst: - -**Nur diese Variablen setzen (für beide Branches):** -- `MY_EMAIL` = `contact@dk0.dev` -- `MY_INFO_EMAIL` = `info@dk0.dev` -- `LOG_LEVEL` = `info` (wird für Production verwendet, Dev überschreibt mit `debug`) - -**Secrets (für beide Branches):** -- `MY_PASSWORD` = Dein Email-Passwort -- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort -- `ADMIN_BASIC_AUTH` = `admin:dein_passwort` -- `N8N_SECRET_TOKEN` = Dein n8n Token (optional) - -**⚠️ NICHT setzen:** -- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet! - -## 📝 Schritt-für-Schritt Anleitung - -### 1. Gehe zu Repository Settings -``` -https://git.dk0.dev/denshooter/portfolio/settings -``` - -### 2. Klicke auf "Variables" oder "Secrets" - -### 3. Für Variables (öffentlich): -- Klicke auf **"New Variable"** -- **Name:** `NEXT_PUBLIC_BASE_URL` -- **Value:** `https://dk0.dev` (für Production) -- **Protect:** ✅ (optional, schützt vor Änderungen) -- Klicke **"Add Variable"** - -### 4. Für Secrets (verschlüsselt): -- Klicke auf **"New Secret"** -- **Name:** `MY_PASSWORD` -- **Value:** Dein Passwort -- Klicke **"Add Secret"** - -## 🔄 Aktuelle Workflow-Logik - -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' }} - -# Dev Workflow (triggert nur auf dev branch) -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }} -``` - -**Das bedeutet:** -- Jeder Workflow hat seinen **eigenen Default** -- Wenn `NEXT_PUBLIC_BASE_URL` 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! - -## 🎯 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? diff --git a/NGINX_PROXY_MANAGER_SETUP.md b/NGINX_PROXY_MANAGER_SETUP.md deleted file mode 100644 index 1424a1f..0000000 --- a/NGINX_PROXY_MANAGER_SETUP.md +++ /dev/null @@ -1,198 +0,0 @@ -# 🔧 Nginx Proxy Manager Setup Guide - -## Übersicht - -Dieses Projekt nutzt **Nginx Proxy Manager** als Reverse Proxy. Die Container sind im `proxy` Netzwerk, damit Nginx Proxy Manager auf sie zugreifen kann. - -## 🐳 Docker Netzwerk-Konfiguration - -Die Container sind bereits im `proxy` Netzwerk konfiguriert: - -**Production:** -```yaml -networks: - - portfolio_net - - proxy # ✅ Bereits konfiguriert -``` - -**Staging:** -```yaml -networks: - - portfolio_staging_net - - proxy # ✅ Bereits konfiguriert -``` - -## 📋 Nginx Proxy Manager Konfiguration - -### Production (dk0.dev) - -1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host - -2. **Details Tab:** - - **Domain Names:** `dk0.dev`, `www.dk0.dev` - - **Scheme:** `http` - - **Forward Hostname/IP:** `portfolio-app` (Container-Name) - - **Forward Port:** `3000` - - **Cache Assets:** ✅ (optional) - - **Block Common Exploits:** ✅ - - **Websockets Support:** ✅ (für Chat/Activity) - -3. **SSL Tab:** - - **SSL Certificate:** Request a new SSL Certificate - - **Force SSL:** ✅ - - **HTTP/2 Support:** ✅ - - **HSTS Enabled:** ✅ - -4. **Advanced Tab:** - ``` - # Custom Nginx Configuration - # Fix for 421 Misdirected Request - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - # Fix HTTP/2 connection reuse issues - proxy_http_version 1.1; - proxy_set_header Connection ""; - - # Timeouts - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - ``` - -### Staging (dev.dk0.dev) - -1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host - -2. **Details Tab:** - - **Domain Names:** `dev.dk0.dev` - - **Scheme:** `http` - - **Forward Hostname/IP:** `portfolio-app-staging` (Container-Name) - - **Forward Port:** `3000` (interner Port im Container) - - **Cache Assets:** ❌ (für Dev besser deaktiviert) - - **Block Common Exploits:** ✅ - - **Websockets Support:** ✅ - -3. **SSL Tab:** - - **SSL Certificate:** Request a new SSL Certificate - - **Force SSL:** ✅ - - **HTTP/2 Support:** ✅ - - **HSTS Enabled:** ✅ - -4. **Advanced Tab:** - ``` - # Custom Nginx Configuration - # Fix for 421 Misdirected Request - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - # Fix HTTP/2 connection reuse issues - proxy_http_version 1.1; - proxy_set_header Connection ""; - - # Timeouts - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - ``` - -## 🔍 421 Misdirected Request - Lösung - -Der **421 Misdirected Request** Fehler tritt auf, wenn: - -1. **HTTP/2 Connection Reuse:** Nginx Proxy Manager versucht, eine HTTP/2-Verbindung wiederzuverwenden, aber der Host-Header stimmt nicht überein -2. **Host-Header nicht richtig weitergegeben:** Der Container erhält den falschen Host-Header - -### Lösung 1: Advanced Tab Konfiguration (Wichtig!) - -Füge diese Zeilen im **Advanced Tab** von Nginx Proxy Manager hinzu: - -```nginx -proxy_http_version 1.1; -proxy_set_header Connection ""; -proxy_set_header Host $host; -proxy_set_header X-Forwarded-Host $host; -``` - -### Lösung 2: Container-Namen verwenden - -Stelle sicher, dass du den **Container-Namen** (nicht IP) verwendest: -- Production: `portfolio-app` -- Staging: `portfolio-app-staging` - -### Lösung 3: Netzwerk prüfen - -Stelle sicher, dass beide Container im `proxy` Netzwerk sind: - -```bash -# Prüfen -docker network inspect proxy - -# Sollte enthalten: -# - portfolio-app -# - portfolio-app-staging -``` - -## ✅ Checkliste - -- [ ] Container sind im `proxy` Netzwerk -- [ ] Nginx Proxy Manager nutzt Container-Namen (nicht IP) -- [ ] Advanced Tab Konfiguration ist gesetzt -- [ ] `proxy_http_version 1.1` ist gesetzt -- [ ] `proxy_set_header Host $host` ist gesetzt -- [ ] SSL-Zertifikat ist konfiguriert -- [ ] Websockets Support ist aktiviert - -## 🐛 Troubleshooting - -### 421 Fehler weiterhin vorhanden? - -1. **Prüfe Container-Namen:** - ```bash - docker ps --format "table {{.Names}}\t{{.Status}}" - ``` - -2. **Prüfe Netzwerk:** - ```bash - docker network inspect proxy | grep -A 5 portfolio - ``` - -3. **Prüfe Nginx Proxy Manager Logs:** - - Gehe zu Nginx Proxy Manager → System Logs - - Suche nach "421" oder "misdirected" - -4. **Teste direkt:** - ```bash - # Vom Host aus - curl -H "Host: dk0.dev" http://portfolio-app:3000 - - # Sollte funktionieren - ``` - -5. **Deaktiviere HTTP/2 temporär:** - - In Nginx Proxy Manager → SSL Tab - - **HTTP/2 Support:** ❌ - - Teste ob es funktioniert - -## 📝 Wichtige Hinweise - -- **Container-Namen sind wichtig:** Nutze `portfolio-app` nicht `localhost` oder IP -- **Port:** Immer Port `3000` (interner Container-Port), nicht `3000:3000` -- **Netzwerk:** Beide Container müssen im `proxy` Netzwerk sein -- **HTTP/2:** Kann Probleme verursachen, wenn Advanced Config fehlt - -## 🔄 Nach Deployment - -Nach jedem Deployment: -1. Prüfe ob Container läuft: `docker ps | grep portfolio` -2. Prüfe ob Container im proxy-Netzwerk ist -3. Teste die URL im Browser -4. Prüfe Nginx Proxy Manager Logs bei Problemen diff --git a/README.md b/README.md index 17babc9..3e975bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +# Quick links + +- **Ops / setup / deployment / testing**: `docs/OPERATIONS.md` +- **Locale System & Translations**: `docs/LOCALE_SYSTEM.md` + # Dennis Konkol Portfolio - Modern Dark Theme Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Admin-Dashboard. @@ -48,8 +53,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/TESTING_GUIDE.md b/TESTING_GUIDE.md deleted file mode 100644 index 1df1443..0000000 --- a/TESTING_GUIDE.md +++ /dev/null @@ -1,284 +0,0 @@ -# 🧪 Automated Testing Guide - -This guide explains how to run automated tests for critical paths, hydration, emails, and more. - -## 📋 Test Types - -### 1. Unit Tests (Jest) -Tests individual components and functions in isolation. - -```bash -npm run test # Run all unit tests -npm run test:watch # Watch mode -npm run test:coverage # With coverage report -``` - -### 2. E2E Tests (Playwright) -Tests complete user flows in a real browser. - -```bash -npm run test:e2e # Run all E2E tests -npm run test:e2e:ui # Run with UI mode (visual) -npm run test:e2e:headed # Run with visible browser -npm run test:e2e:debug # Debug mode -``` - -### 3. Critical Path Tests -Tests the most important user flows. - -```bash -npm run test:critical # Run critical path tests only -``` - -### 4. Hydration Tests -Ensures React hydration works without errors. - -```bash -npm run test:hydration # Run hydration tests only -``` - -### 5. Email Tests -Tests email API endpoints. - -```bash -npm run test:email # Run email tests only -``` - -### 6. Performance Tests -Checks page load times and performance. - -```bash -npm run test:performance # Run performance tests -``` - -### 7. Accessibility Tests -Basic accessibility checks. - -```bash -npm run test:accessibility # Run accessibility tests -``` - -## 🚀 Running All Tests - -### Quick Test (Recommended) -```bash -npm run test:all -``` - -This runs: -- ✅ TypeScript check -- ✅ ESLint -- ✅ Build -- ✅ Unit tests -- ✅ Critical paths -- ✅ Hydration tests -- ✅ Email tests -- ✅ Performance tests -- ✅ Accessibility tests - -### Individual Test Suites -```bash -# Unit tests only -npm run test - -# E2E tests only -npm run test:e2e - -# Both -npm run test && npm run test:e2e -``` - -## 📝 What Gets Tested - -### Critical Paths -- ✅ Home page loads correctly -- ✅ Projects page displays projects -- ✅ Individual project pages work -- ✅ Admin dashboard is accessible -- ✅ API health endpoint -- ✅ API projects endpoint - -### Hydration -- ✅ No hydration errors in console -- ✅ No duplicate React key warnings -- ✅ Client-side navigation works -- ✅ Server and client HTML match -- ✅ Interactive elements work after hydration - -### Email -- ✅ Email API accepts requests -- ✅ Required field validation -- ✅ Email format validation -- ✅ Rate limiting (if implemented) -- ✅ Email respond endpoint - -### Performance -- ✅ Page load times (< 5s) -- ✅ No large layout shifts -- ✅ Images are optimized -- ✅ API response times (< 1s) - -### Accessibility -- ✅ Proper heading structure -- ✅ Images have alt text -- ✅ Links have descriptive text -- ✅ Forms have labels - -## 🎯 Pre-Push Testing - -Before pushing to main, run: - -```bash -# Full test suite -npm run test:all - -# Or manually: -npm run build -npm run lint -npx tsc --noEmit -npm run test -npm run test:critical -npm run test:hydration -``` - -## 🔧 Configuration - -### Playwright Config -Located in `playwright.config.ts` - -- **Base URL**: `http://localhost:3000` (or set `PLAYWRIGHT_TEST_BASE_URL`) -- **Browsers**: Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari -- **Retries**: 2 retries in CI, 0 locally -- **Screenshots**: On failure -- **Videos**: On failure - -### Jest Config -Located in `jest.config.ts` - -- **Environment**: jsdom -- **Coverage**: v8 provider -- **Setup**: `jest.setup.ts` - -## 🐛 Debugging Tests - -### Playwright Debug Mode -```bash -npm run test:e2e:debug -``` - -This opens Playwright Inspector where you can: -- Step through tests -- Inspect elements -- View console logs -- See network requests - -### UI Mode (Visual) -```bash -npm run test:e2e:ui -``` - -Shows a visual interface to: -- See all tests -- Run specific tests -- Watch tests execute -- View results - -### Headed Mode -```bash -npm run test:e2e:headed -``` - -Runs tests with visible browser (useful for debugging). - -## 📊 Test Reports - -### Playwright HTML Report -After running E2E tests: -```bash -npx playwright show-report -``` - -Shows: -- Test results -- Screenshots on failure -- Videos on failure -- Timeline of test execution - -### Jest Coverage Report -```bash -npm run test:coverage -``` - -Generates coverage report in `coverage/` directory. - -## 🚨 Common Issues - -### Tests Fail Locally But Pass in CI -- Check environment variables -- Ensure database is set up -- Check for port conflicts - -### Hydration Errors -- Check for server/client mismatches -- Ensure no conditional rendering based on `window` -- Check for date/time differences - -### Email Tests Fail -- Email service might not be configured -- Check environment variables -- Tests are designed to handle missing email service - -### Performance Tests Fail -- Network might be slow -- Adjust thresholds in test file -- Check for heavy resources loading - -## 📝 Writing New Tests - -### E2E Test Example -```typescript -import { test, expect } from '@playwright/test'; - -test('My new feature works', async ({ page }) => { - await page.goto('/my-page'); - await expect(page.locator('h1')).toContainText('Expected Text'); -}); -``` - -### Unit Test Example -```typescript -import { render, screen } from '@testing-library/react'; -import MyComponent from './MyComponent'; - -test('renders correctly', () => { - render(); - expect(screen.getByText('Hello')).toBeInTheDocument(); -}); -``` - -## 🎯 CI/CD Integration - -### GitHub Actions Example -```yaml -- name: Run tests - run: | - npm install - npm run test:all -``` - -### Pre-Push Hook -Add to `.git/hooks/pre-push`: -```bash -#!/bin/bash -npm run test:all -``` - -## 📚 Resources - -- [Playwright Docs](https://playwright.dev) -- [Jest Docs](https://jestjs.io) -- [Testing Library](https://testing-library.com) - ---- - -**Remember**: Tests should be fast, reliable, and easy to understand! 🚀 diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..ec21198 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,37 @@ +import { NextIntlClientProvider } from "next-intl"; +import { setRequestLocale } from "next-intl/server"; +import React from "react"; +import ConsentBanner from "../components/ConsentBanner"; +import { getLocalizedMessage } from "@/lib/i18n-loader"; + +async function loadEnhancedMessages(locale: string) { + // Lade basis JSON Messages + const baseMessages = (await import(`../../messages/${locale}.json`)).default; + + // Erweitere mit Directus (wenn verfügbar) + // Für jetzt: return base messages, Directus wird per Server Component geladen + return baseMessages; +} + +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 loadEnhancedMessages(locale); + + 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..6e59d5e --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import HomePageServer from "../_ui/HomePageServer"; +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 async function Page({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + 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..9311494 --- /dev/null +++ b/app/[locale]/projects/[slug]/page.tsx @@ -0,0 +1,65 @@ +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: { + select: { title: true, description: true, content: true, locale: true }, + }, + }, + }); + + if (!project) return notFound(); + + const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description)); + const trDefault = project.translations?.find( + (t) => t.locale === project.defaultLocale && (t?.title || t?.description), + ); + const tr = trPreferred ?? trDefault; + const { translations: _translations, ...rest } = project; + const localizedContent = (() => { + if (typeof tr?.content === "string") return tr.content; + if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) { + const markdown = (tr.content as Record).markdown; + if (typeof markdown === "string") return markdown; + } + return project.content; + })(); + const localized = { + ...rest, + title: tr?.title ?? project.title, + description: tr?.description ?? project.description, + content: localizedContent, + }; + + return ; +} + diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx new file mode 100644 index 0000000..f194b43 --- /dev/null +++ b/app/[locale]/projects/page.tsx @@ -0,0 +1,56 @@ +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: { + select: { title: true, description: true, locale: true }, + }, + }, + }); + + const localized = projects.map((p) => { + const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description)); + const trDefault = p.translations?.find( + (t) => t.locale === p.defaultLocale && (t?.title || t?.description), + ); + const tr = trPreferred ?? trDefault; + 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/ActivityFeed.test.tsx b/app/__tests__/components/ActivityFeed.test.tsx new file mode 100644 index 0000000..6ee4d13 --- /dev/null +++ b/app/__tests__/components/ActivityFeed.test.tsx @@ -0,0 +1,150 @@ +import '@testing-library/jest-dom'; + +/** + * Unit tests for ActivityFeed NaN handling + * + * This test suite validates that the ActivityFeed component correctly handles + * NaN and numeric values in gaming and custom activity data to prevent + * "Received NaN for the children attribute" React errors. + */ +describe('ActivityFeed NaN Handling', () => { + describe('Gaming activity rendering logic', () => { + // Helper function to simulate getSafeGamingText behavior + const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => { + if (typeof details === 'string' && details.trim().length > 0) return details; + if (typeof state === 'string' && state.trim().length > 0) return state; + if (typeof details === 'number' && !isNaN(details)) return String(details); + if (typeof state === 'number' && !isNaN(state)) return String(state); + return fallback; + }; + + it('should safely handle NaN in gaming.details', () => { + const result = getSafeGamingText(NaN, 'Playing', 'Playing...'); + expect(result).toBe('Playing'); // Should fall through NaN to state + expect(result).not.toBe(NaN); + expect(typeof result).toBe('string'); + }); + + it('should safely handle NaN in both gaming.details and gaming.state', () => { + const result = getSafeGamingText(NaN, NaN, 'Playing...'); + expect(result).toBe('Playing...'); // Should use fallback + expect(typeof result).toBe('string'); + }); + + it('should prioritize string details over numeric state', () => { + const result = getSafeGamingText('Details text', 42, 'Playing...'); + expect(result).toBe('Details text'); // String details takes precedence + expect(typeof result).toBe('string'); + }); + + it('should prioritize string state over numeric details', () => { + const result = getSafeGamingText(42, 'State text', 'Playing...'); + expect(result).toBe('State text'); // String state takes precedence over numeric details + expect(typeof result).toBe('string'); + }); + + it('should convert valid numeric details to string', () => { + const result = getSafeGamingText(42, undefined, 'Playing...'); + expect(result).toBe('42'); + expect(typeof result).toBe('string'); + }); + + it('should handle empty strings correctly', () => { + const result1 = getSafeGamingText('', 'Playing', 'Playing...'); + expect(result1).toBe('Playing'); // Empty string should fall through to state + + const result2 = getSafeGamingText(' ', 'Playing', 'Playing...'); + expect(result2).toBe('Playing'); // Whitespace-only should fall through to state + }); + + it('should convert gaming.name to string safely', () => { + const validName = String('Test Game' || ''); + expect(validName).toBe('Test Game'); + expect(typeof validName).toBe('string'); + + // In the actual code, we use String(data.gaming.name || '') + // If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy + const nanName = String(NaN || ''); + expect(nanName).toBe(''); // NaN is falsy, so it falls back to '' + expect(typeof nanName).toBe('string'); + }); + }); + + describe('Custom activities progress handling', () => { + it('should only render progress bar when progress is a valid number', () => { + const validProgress = 75; + const shouldRender = validProgress !== undefined && + typeof validProgress === 'number' && + !isNaN(validProgress); + expect(shouldRender).toBe(true); + }); + + it('should not render progress bar when progress is NaN', () => { + const invalidProgress = NaN; + const shouldRender = invalidProgress !== undefined && + typeof invalidProgress === 'number' && + !isNaN(invalidProgress); + expect(shouldRender).toBe(false); + }); + + it('should not render progress bar when progress is undefined', () => { + const undefinedProgress = undefined; + const shouldRender = undefinedProgress !== undefined && + typeof undefinedProgress === 'number' && + !isNaN(undefinedProgress); + expect(shouldRender).toBe(false); + }); + }); + + describe('Custom activities dynamic field rendering', () => { + it('should safely convert valid numeric values to string', () => { + const value = 42; + const shouldRender = typeof value === 'string' || + (typeof value === 'number' && !isNaN(value)); + + expect(shouldRender).toBe(true); + + if (shouldRender) { + const stringValue = String(value); + expect(stringValue).toBe('42'); + expect(typeof stringValue).toBe('string'); + } + }); + + it('should not render NaN values', () => { + const value = NaN; + const shouldRender = typeof value === 'string' || + (typeof value === 'number' && !isNaN(value)); + + expect(shouldRender).toBe(false); + }); + + it('should render valid string values', () => { + const value = 'Test String'; + const shouldRender = typeof value === 'string' || + (typeof value === 'number' && !isNaN(value)); + + expect(shouldRender).toBe(true); + + if (shouldRender) { + const stringValue = String(value); + expect(stringValue).toBe('Test String'); + expect(typeof stringValue).toBe('string'); + } + }); + + it('should render zero as a valid numeric value', () => { + const value = 0; + const shouldRender = typeof value === 'string' || + (typeof value === 'number' && !isNaN(value)); + + expect(shouldRender).toBe(true); + + if (shouldRender) { + const stringValue = String(value); + expect(stringValue).toBe('0'); + expect(typeof stringValue).toBe('string'); + } + }); + }); +}); 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} @@ -33,11 +42,40 @@ export default function RootLayout({ } export const metadata: Metadata = { - title: "Dennis Konkol | Portfolio", + metadataBase: new URL(getBaseUrl()), + title: { + default: "Dennis Konkol | Portfolio", + template: "%s | Dennis Konkol", + }, description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.", - keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"], + keywords: [ + "Dennis Konkol", + "Software Engineer", + "Portfolio", + "Student", + "Web Development", + "Full Stack Developer", + "Osnabrück", + "Germany", + "React", + "Next.js", + "TypeScript", + ], authors: [{ name: "Dennis Konkol", url: "https://dk0.dev" }], + creator: "Dennis Konkol", + publisher: "Dennis Konkol", + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, openGraph: { title: "Dennis Konkol | Portfolio", description: @@ -52,6 +90,7 @@ export const metadata: Metadata = { alt: "Dennis Konkol Portfolio", }, ], + locale: "en_US", type: "website", }, twitter: { @@ -59,5 +98,12 @@ export const metadata: Metadata = { title: "Dennis Konkol | Portfolio", description: "Student & Software Engineer based in Osnabrück, Germany.", images: ["https://dk0.dev/api/og"], + creator: "@denshooter", + }, + verification: { + google: process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION, + }, + alternates: { + canonical: "https://dk0.dev", }, }; 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

+
+ + )}