40 Commits

Author SHA1 Message Date
denshooter
98f1a07b08 style: enhance glassmorphism for projects and chat widget with improved transparency and readability 2026-01-10 01:07:49 +01:00
denshooter
792f0c8aae style: modernize chat widget with glassmorphism and improve mobile layout 2026-01-10 01:05:08 +01:00
denshooter
eaaee17bca style: update chat widget to use warm organic modern color palette 2026-01-10 01:02:58 +01:00
denshooter
ae37294b06 full upgrade 2026-01-10 00:52:08 +01:00
denshooter
b487f4ba75 feat: Add production troubleshooting tools and remove eye icon from ActivityFeed
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m1s
- Add diagnose-production.sh script for comprehensive production diagnostics
- Add fix-production.sh script for automatic production issue resolution
- Add PRODUCTION_TROUBLESHOOTING.md documentation with step-by-step guides
- Remove eye icon from ActivityFeed header (keep only X button for minimize)
- Improve error handling and network connectivity checks
2026-01-09 20:20:08 +01:00
denshooter
37178ce421 fix: Improve production health check to use Docker health status
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 10m54s
- Use Docker health check status instead of host-based curl
- Test from inside container instead of from host
- Better error messages and debugging
- More reliable health check that doesn't depend on port mapping
2026-01-09 20:05:31 +01:00
denshooter
e5233138ab fix: Improve production deployment health check
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m13s
- Use docker compose ps to get correct container ID (avoids staging container)
- Verify container is from production compose file before health check
- Accept deployment if Docker health check reports healthy (even if HTTP test fails)
- Better error messages and debugging output
- Fix container ID selection to avoid matching staging containers
2026-01-09 19:53:48 +01:00
denshooter
c989f15cab fix: Add n8n environment variables to production deployment
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 10m24s
- Add N8N_WEBHOOK_URL, N8N_SECRET_TOKEN, N8N_API_KEY to docker-compose.production.yml
- Export environment variables in workflow before docker-compose up
- Improve error logging in chat API for better debugging
- Add better error handling in ChatWidget component
- Create setup guide for n8n chat configuration
2026-01-09 19:40:00 +01:00
denshooter
bd73a77ae3 fix: Reduce component flashing on page load and scroll
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Remove mounted state checks that return null (Hero, About, Projects)
- Reduce animation delays and durations for faster initial render
- Change viewport margins from -100px to -50px for earlier trigger
- Reduce initial animation distances (y: 40 -> 20, y: 30 -> 20)
- Use requestAnimationFrame for Header mount to prevent flash
- Always render components instead of returning null to prevent layout shift
- Optimize Framer Motion transitions for smoother scrolling
2026-01-09 19:36:06 +01:00
denshooter
f63a745221 fix: Improve ChatWidget text visibility and ActivityFeed loading state
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Fix ChatWidget tooltip text being cut off (add z-index and shadow)
- Fix ChatWidget header text overflow with truncate classes
- Add loading state for ActivityFeed so it's visible on production while fetching
- Ensure ActivityFeed shows even when data is loading
2026-01-09 19:32:56 +01:00
denshooter
4e48f55737 docs: Add guide for adding 404 project to production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-01-09 19:30:57 +01:00
denshooter
fadeb9b6b9 feat: Add Kernel Panic 404 page as project and link in footer
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Add 404 link in footer
- Add Kernel Panic 404 as featured project in seed data
- Project includes interactive terminal, Easter eggs, and retro effects
2026-01-09 19:28:45 +01:00
denshooter
947f72ecca feat: Add interactive kernel panic 404 page
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Terminal-style 404 page with boot sequence
- Interactive command line with file system
- Easter eggs: hawkins/011, fsociety, 42, rm -rf /
- CRT monitor effects and visual glitches
- Audio synthesis for key presses and effects
- Full terminal emulator with commands: ls, cd, cat, grep, etc.
2026-01-09 19:26:08 +01:00
denshooter
ab110fd009 fix: Improve health check to use container-internal testing
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Prioritize Docker health status (most reliable)
- Test HTTP endpoint from inside container using docker exec
- Fallback to host-based HTTP test if available
- Better debugging output showing both internal and external tests
- Final verification uses Docker health status as authoritative
2026-01-09 19:18:43 +01:00
denshooter
511c37f104 fix: Install curl in production image and improve health check
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 9m26s
2026-01-09 19:06:58 +01:00
denshooter
3771949ba8 fix: Install curl in production image and improve health check
- Install curl in runner stage for health checks
- Increase health check timeout to 90 attempts (3 minutes)
- Improve health check logic to prioritize HTTP endpoint
- Add better debugging output during health check wait
- Show container status and logs during health check failures
2026-01-09 19:06:07 +01:00
denshooter
1e950823e1 Merge dev into production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m19s
- Add activity tracking toggle
- Remove Discord status display
- Fix HTML entity decoding in chat
- Improve n8n chat response parsing
- Add n8n status text guide documentation
2026-01-09 18:46:48 +01:00
denshooter
c5b607a253 fix: Improve n8n chat response parsing
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m6s
- Add comprehensive parsing for various n8n response formats
- Check multiple field names (reply, message, response, text, content, answer, output, result)
- Handle array responses and nested structures (data, json, items)
- Add recursive search for string values in complex objects
- Improve logging to show full n8n response structure
- Only use fallback if truly no response found
2026-01-09 18:11:03 +01:00
denshooter
42a586d183 fix: Properly decode HTML entities in chat messages
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Fix ' not being decoded to apostrophe
- Decode HTML entities when loading messages from localStorage
- Improve server-side HTML entity decoding to handle all variations
- Replace hardcoded ' in static text with regular apostrophes
- Add support for more HTML entity variations (rsquo, lsquo, etc.)
2026-01-09 18:07:43 +01:00
denshooter
9c24fdf5bd feat: Remove Discord status display from activity feed
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m6s
- Remove status footer section that displayed Discord status
- Status information (online/offline/dnd/away) is no longer shown
- Activity feed now only shows coding, gaming, and music activities
2026-01-09 17:42:05 +01:00
denshooter
d09802ab19 remove: Remove staging banner component
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
2026-01-09 17:36:44 +01:00
denshooter
fc71bc740a docs: Add guide for changing status text in n8n
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
2026-01-09 17:34:18 +01:00
denshooter
242a808590 feat: Add activity tracking toggle and customize status text
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add toggle button to enable/disable activity tracking
- Store tracking preference in localStorage
- Change 'Do Not Disturb' to 'Nicht stören' (German)
- Add better status text translations (online, offline, away)
- Show disabled state when tracking is off
- Stop fetching activity data when tracking is disabled
2026-01-09 17:26:05 +01:00
denshooter
60e69eb37b fix: Remove Traefik labels and add Nginx Proxy Manager support
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Remove Traefik-specific labels (user uses Nginx Proxy Manager)
- Add proper host header handling in middleware for 421 fix
- Create NGINX_PROXY_MANAGER_SETUP.md with complete setup guide
- Fix 421 Misdirected Request by ensuring proper proxy headers
2026-01-09 17:06:08 +01:00
denshooter
d8001fc2c4 fix: Move staging banner to top-left to avoid overlap with activity monitor
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Position banner at top-left instead of bottom-right
- Make banner more compact to reduce visual clutter
- Avoids overlap with ActivityFeed (bottom-right) and ChatWidget (bottom-left)
- Smaller, cleaner design that doesn't interfere with content
2026-01-09 16:04:13 +01:00
denshooter
e8248a6ee1 fix: Ensure staging banner is positioned bottom-right, not top-right
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Add explicit inline styles to override any CSS conflicts
- Set top: auto and left: auto to ensure bottom-right positioning
- Fix banner appearing in wrong location
2026-01-09 15:36:23 +01:00
denshooter
d40fdf6d22 fix: Simplify Gitea variables and improve staging banner design
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Remove branch-specific variable names (not needed)
- Each workflow uses its own default based on branch
- Users only need to set general variables, not branch-specific ones
- Redesign staging banner as floating box in bottom-right corner
- Better UX: doesn't block content, dismissible, modern design
2026-01-09 15:14:23 +01:00
denshooter
9486116fd8 feat: Add branch-specific Gitea variables support
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Support NEXT_PUBLIC_BASE_URL_PRODUCTION and NEXT_PUBLIC_BASE_URL_DEV
- Support LOG_LEVEL_PRODUCTION and LOG_LEVEL_DEV
- Fallback to general variables if branch-specific not set
- Add comprehensive GITEA_VARIABLES_SETUP.md guide
- Allows independent configuration for production and dev branches
2026-01-09 15:01:29 +01:00
denshooter
0d44ebee17 feat: Add staging banner to dev/test environment
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add StagingBanner component that displays on dev/staging/test domains
- Shows warning that site is not production-ready
- Automatically detects staging environment via hostname or env vars
- Dismissible banner with smooth animations
- Only shows on dev.dk0.dev or other test domains
2026-01-09 14:54:45 +01:00
denshooter
4184e2fcf0 fix: Decode HTML entities in chat responses and improve n8n error handling
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add HTML entity decoding for chat responses (fixes ' display issue)
- Add timeout handling for n8n webhook requests (30s chat, 10s status)
- Improve error logging with detailed error information
- Add N8N_SECRET_TOKEN support for authentication
- Better fallback handling when n8n is unavailable
- Fix server-side HTML entity decoding for chat and status endpoints
2026-01-09 14:52:26 +01:00
denshooter
271703556d fix: Add proxy network to staging container
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add external proxy network to portfolio-staging service
- Ensures staging container can communicate with reverse proxy
- Matches production configuration
2026-01-09 14:47:37 +01:00
denshooter
fd49095710 feat: Optimize builds, add rollback script, and improve security
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m33s
Build Optimizations:
- Enable Docker BuildKit cache for faster builds (7min → 3-4min)
- Add .dockerignore to reduce build context
- Optimize Dockerfile with better layer caching
- Run linting and tests in parallel
- Skip blocking checks for dev deployments

Rollback Functionality:
- Add rollback.sh script to restore previous versions
- Supports both production and dev environments
- Automatic health checks after rollback

Security Improvements:
- Add authentication to n8n/generate-image endpoint
- Add rate limiting to all n8n endpoints (10-30 req/min)
- Create email obfuscation utilities
- Add ObfuscatedEmail React component
- Document security best practices

Files:
- .dockerignore - Faster builds
- scripts/rollback.sh - Rollback functionality
- lib/email-obfuscate.ts - Email obfuscation utilities
- components/ObfuscatedEmail.tsx - React component
- SECURITY_IMPROVEMENTS.md - Security documentation
2026-01-09 14:30:14 +01:00
denshooter
8c223db2a8 feat: Setup zero-downtime deployments for production and dev branches
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Created separate workflows for production and dev deployments
- Production branch → dk0.dev (port 3000)
- Dev branch → dev.dk0.dev (port 3002)
- Zero-downtime deployment pattern (start new, wait for health, remove old)
- Complete isolation between environments (separate containers, databases, networks)
- Cleaned up unused code and files:
  - Removed unused GhostEditor and ResizableGhostEditor components
  - Removed old/unused workflows and markdown files
  - Fixed docker-compose references
- Upgraded dependencies to latest compatible versions
- Fixed TypeScript errors in editor page
- Updated staging to use dev.dk0.dev domain
2026-01-09 14:21:03 +01:00
denshooter
5dcc6ae0a6 fix: Remove newline from quote string literal
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 10m32s
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Successful in 18m39s
Staging Deployment / staging (push) Successful in 16m35s
2026-01-09 12:57:08 +01:00
denshooter
21f0ebaa98 feat: Replace quotes with comprehensive collection of programming quotes 2026-01-09 12:56:54 +01:00
denshooter
db0bf2b0c6 Update staging configuration to avoid port conflicts and enhance deployment scripts
- Changed staging app port from 3001 to 3002 in docker-compose.staging.yml
- Updated PostgreSQL port from 5433 to 5434 and Redis port from 6380 to 6381
- Modified STAGING_SETUP.md to reflect new port configurations
- Adjusted CI/CD workflows to accommodate new staging ports and improve deployment messages
- Added N8N environment variables to staging configuration for better integration
2026-01-09 12:56:53 +01:00
denshooter
de0f3f1e66 fix: Update Dockerfile to correctly copy Next.js 15 standalone output structure
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 8m11s
2026-01-09 03:03:33 +01:00
denshooter
393e8c01cd feat: Enhance Dockerfile with verification for standalone output and update n8n status route to handle missing webhook URL
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 7m56s
2026-01-09 02:36:21 +01:00
denshooter
0e578dd833 feat: Add Dev Branch Testing Guide and CI/CD Pipeline for Staging Deployment
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 9m0s
2026-01-09 02:02:08 +01:00
denshooter
5cbe95dc24 Merge branch 'dev_n8n' into dev 2026-01-09 00:24:21 +01:00
63 changed files with 9053 additions and 3581 deletions

64
.dockerignore Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
# Next.js
.next
out
build
dist
# Testing
coverage
.nyc_output
test-results
playwright-report
# Environment files
.env
.env.local
.env*.local
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Documentation
*.md
docs
!README.md
# Logs
logs
*.log
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# CI/CD
.gitea
.github
# Scripts (keep only essential ones)
scripts
!scripts/init-db.sql
# Misc
.cache
.temp
tmp

View File

@@ -2,7 +2,7 @@ name: CI/CD Pipeline (Using Gitea Variables & Secrets)
on:
push:
branches: [ production ]
branches: [ dev, main, production ]
env:
NODE_VERSION: '20'
@@ -94,10 +94,23 @@ jobs:
- name: Deploy using Gitea Variables and Secrets
run: |
echo "🚀 Deploying using Gitea Variables and Secrets..."
# Determine if this is staging or production
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
echo "🚀 Deploying Production using Gitea Variables and Secrets..."
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "📝 Using Gitea Variables and Secrets:"
echo " - NODE_ENV: ${NODE_ENV}"
echo " - NODE_ENV: ${DEPLOY_ENV}"
echo " - LOG_LEVEL: ${LOG_LEVEL}"
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
echo " - MY_EMAIL: ${MY_EMAIL}"
@@ -105,31 +118,32 @@ jobs:
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-}"
# Stop old containers
echo "🛑 Stopping old containers..."
docker compose down || true
# Stop old containers (only for the environment being deployed)
echo "🛑 Stopping old ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE down || true
# Clean up orphaned containers
echo "🧹 Cleaning up orphaned containers..."
docker compose down --remove-orphans || true
echo "🧹 Cleaning up orphaned ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE down --remove-orphans || true
# Start new containers
echo "🚀 Starting new containers..."
docker compose up -d
echo "🚀 Starting new ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE up -d --force-recreate
# Wait a moment for containers to start
echo "⏳ Waiting for containers to start..."
sleep 10
echo "⏳ Waiting for ${DEPLOY_ENV} containers to start..."
sleep 15
# Check container logs for debugging
echo "📋 Container logs (first 20 lines):"
docker compose logs --tail=20
echo "📋 ${DEPLOY_ENV} container logs (first 30 lines):"
docker compose -f $COMPOSE_FILE logs --tail=30
echo "✅ Deployment completed!"
echo "✅ ${DEPLOY_ENV} deployment completed!"
env:
NODE_ENV: ${{ vars.NODE_ENV }}
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
NODE_ENV: ${{ vars.NODE_ENV || 'production' }}
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
@@ -138,65 +152,98 @@ jobs:
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Wait for containers to be ready
run: |
echo "⏳ Waiting for containers to be ready..."
sleep 45
# Determine environment
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "⏳ Waiting for ${DEPLOY_ENV} containers to be ready..."
sleep 30
# Check if all containers are running
echo "📊 Checking container status..."
docker compose ps
echo "📊 Checking ${DEPLOY_ENV} container status..."
docker compose -f $COMPOSE_FILE ps
# Wait for application container to be healthy
echo "🏥 Waiting for application container to be healthy..."
for i in {1..60}; do
if docker exec portfolio-app curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Application container is healthy!"
echo "🏥 Waiting for ${DEPLOY_ENV} application container to be healthy..."
for i in {1..40}; do
if curl -f http://localhost:${HEALTH_PORT}/api/health > /dev/null 2>&1; then
echo "✅ ${DEPLOY_ENV} application container is healthy!"
break
fi
echo "⏳ Waiting for application container... ($i/60)"
sleep 5
echo "⏳ Waiting for ${DEPLOY_ENV} application container... ($i/40)"
sleep 3
done
# Additional wait for main page to be accessible
echo "🌐 Waiting for main page to be accessible..."
for i in {1..30}; do
if curl -f http://localhost:3000/ > /dev/null 2>&1; then
echo "✅ Main page is accessible!"
echo "🌐 Waiting for ${DEPLOY_ENV} main page to be accessible..."
for i in {1..20}; do
if curl -f http://localhost:${HEALTH_PORT}/ > /dev/null 2>&1; then
echo "✅ ${DEPLOY_ENV} main page is accessible!"
break
fi
echo "⏳ Waiting for main page... ($i/30)"
sleep 3
echo "⏳ Waiting for ${DEPLOY_ENV} main page... ($i/20)"
sleep 2
done
- name: Health check
run: |
echo "🔍 Running comprehensive health checks..."
# Determine environment
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "🔍 Running comprehensive ${DEPLOY_ENV} health checks..."
# Check container status
echo "📊 Container status:"
docker compose ps
echo "📊 ${DEPLOY_ENV} container status:"
docker compose -f $COMPOSE_FILE ps
# Check application container
echo "🏥 Checking application container..."
if docker exec portfolio-app curl -f http://localhost:3000/api/health; then
echo "✅ Application health check passed!"
echo "🏥 Checking ${DEPLOY_ENV} application container..."
if curl -f http://localhost:${HEALTH_PORT}/api/health; then
echo "✅ ${DEPLOY_ENV} application health check passed!"
else
echo "❌ Application health check failed!"
docker logs portfolio-app --tail=50
exit 1
echo "⚠️ ${DEPLOY_ENV} application health check failed, but continuing..."
docker compose -f $COMPOSE_FILE logs --tail=50
# Don't exit 1 for staging, only for production
if [ "$DEPLOY_ENV" == "production" ]; then
exit 1
fi
fi
# Check main page
if curl -f http://localhost:3000/ > /dev/null; then
echo "✅ Main page is accessible!"
if curl -f http://localhost:${HEALTH_PORT}/ > /dev/null; then
echo "✅ ${DEPLOY_ENV} main page is accessible!"
else
echo "❌ Main page is not accessible!"
exit 1
echo "⚠️ ${DEPLOY_ENV} main page check failed, but continuing..."
if [ "$DEPLOY_ENV" == "production" ]; then
exit 1
fi
fi
echo "✅ All health checks passed! Deployment successful!"
echo "✅ ${DEPLOY_ENV} health checks completed!"
- name: Cleanup old images
run: |

View File

@@ -1,232 +0,0 @@
name: CI/CD Pipeline (Woodpecker)
when:
event: push
branch: production
steps:
build:
image: node:20-alpine
commands:
- echo "🚀 Starting CI/CD Pipeline"
- echo "📋 Step 1: Installing dependencies..."
- npm ci --prefer-offline --no-audit
- echo "🔍 Step 2: Running linting..."
- npm run lint
- echo "🧪 Step 3: Running tests..."
- npm run test
- echo "🏗️ Step 4: Building application..."
- npm run build
- echo "🔒 Step 5: Running security scan..."
- npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
volumes:
- node_modules:/app/node_modules
docker-build:
image: docker:latest
commands:
- echo "🐳 Building Docker image..."
- docker build -t portfolio-app:latest .
- docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
volumes:
- /var/run/docker.sock:/var/run/docker.sock
deploy:
image: docker:latest
commands:
- echo "🚀 Deploying application..."
# Verify secrets and variables
- echo "🔍 Verifying secrets and variables..."
- |
if [ -z "$NEXT_PUBLIC_BASE_URL" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
exit 1
fi
if [ -z "$MY_EMAIL" ]; then
echo "❌ MY_EMAIL variable is missing!"
exit 1
fi
if [ -z "$MY_INFO_EMAIL" ]; then
echo "❌ MY_INFO_EMAIL variable is missing!"
exit 1
fi
if [ -z "$MY_PASSWORD" ]; then
echo "❌ MY_PASSWORD secret is missing!"
exit 1
fi
if [ -z "$MY_INFO_PASSWORD" ]; then
echo "❌ MY_INFO_PASSWORD secret is missing!"
exit 1
fi
if [ -z "$ADMIN_BASIC_AUTH" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
exit 1
fi
echo "✅ All required secrets and variables are present"
# Check if current container is running
- |
if docker ps -q -f name=portfolio-app | grep -q .; then
echo "📊 Current container is running, proceeding with zero-downtime update"
CURRENT_CONTAINER_RUNNING=true
else
echo "📊 No current container running, doing fresh deployment"
CURRENT_CONTAINER_RUNNING=false
fi
# Ensure database and redis are running
- echo "🔧 Ensuring database and redis are running..."
- docker compose up -d postgres redis
- sleep 10
# Deploy with zero downtime
- |
if [ "$CURRENT_CONTAINER_RUNNING" = "true" ]; then
echo "🔄 Performing rolling update..."
# Generate unique container name
TIMESTAMP=$(date +%s)
TEMP_CONTAINER_NAME="portfolio-app-temp-$TIMESTAMP"
echo "🔧 Using temporary container name: $TEMP_CONTAINER_NAME"
# Clean up any existing temporary containers
echo "🧹 Cleaning up any existing temporary containers..."
docker rm -f portfolio-app-new portfolio-app-temp-* portfolio-app-backup || true
# Find and remove any containers with portfolio-app in the name (except the main one)
EXISTING_CONTAINERS=$(docker ps -a --format "table {{.Names}}" | grep "portfolio-app" | grep -v "^portfolio-app$" || true)
if [ -n "$EXISTING_CONTAINERS" ]; then
echo "🗑️ Removing existing portfolio-app containers:"
echo "$EXISTING_CONTAINERS"
echo "$EXISTING_CONTAINERS" | xargs -r docker rm -f || true
fi
# Also clean up any stopped containers
docker container prune -f || true
# Start new container with unique temporary name
docker run -d \
--name $TEMP_CONTAINER_NAME \
--restart unless-stopped \
--network portfolio_net \
-e NODE_ENV=$NODE_ENV \
-e LOG_LEVEL=$LOG_LEVEL \
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
-e REDIS_URL=redis://redis:6379 \
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
-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" \
portfolio-app:latest
# Wait for new container to be ready
echo "⏳ Waiting for new container to be ready..."
sleep 15
# Health check new container
for i in {1..20}; do
if docker exec $TEMP_CONTAINER_NAME curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ New container is healthy!"
break
fi
echo "⏳ Health check attempt $i/20..."
sleep 3
done
# Stop old container
echo "🛑 Stopping old container..."
docker stop portfolio-app || true
docker rm portfolio-app || true
# Rename new container
docker rename $TEMP_CONTAINER_NAME portfolio-app
# Update port mapping
docker stop portfolio-app
docker rm portfolio-app
# Start with correct port
docker run -d \
--name portfolio-app \
--restart unless-stopped \
--network portfolio_net \
-p 3000:3000 \
-e NODE_ENV=$NODE_ENV \
-e LOG_LEVEL=$LOG_LEVEL \
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
-e REDIS_URL=redis://redis:6379 \
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
-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" \
portfolio-app:latest
echo "✅ Rolling update completed!"
else
echo "🆕 Fresh deployment..."
docker compose up -d
fi
# Wait for container to be ready
- echo "⏳ Waiting for container to be ready..."
- sleep 15
# Health check
- |
echo "🏥 Performing health check..."
for i in {1..40}; do
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Application is healthy!"
break
fi
echo "⏳ Health check attempt $i/40..."
sleep 3
done
# Final verification
- echo "🔍 Final health verification..."
- docker ps --filter "name=portfolio-app" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
- |
if curl -f http://localhost:3000/api/health; then
echo "✅ Health endpoint accessible"
else
echo "❌ Health endpoint not accessible"
exit 1
fi
- |
if curl -f http://localhost:3000/ > /dev/null; then
echo "✅ Main page is accessible"
else
echo "❌ Main page is not accessible"
exit 1
fi
- echo "✅ Deployment successful!"
# Cleanup
- docker image prune -f
- docker system prune -f
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- NODE_ENV
- LOG_LEVEL
- NEXT_PUBLIC_BASE_URL
- NEXT_PUBLIC_UMAMI_URL
- NEXT_PUBLIC_UMAMI_WEBSITE_ID
- MY_EMAIL
- MY_INFO_EMAIL
- MY_PASSWORD
- MY_INFO_PASSWORD
- ADMIN_BASIC_AUTH
volumes:
node_modules:

View File

@@ -1,123 +0,0 @@
name: Debug Secrets
on:
workflow_dispatch:
push:
branches: [ main ]
jobs:
debug-secrets:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Debug Environment Variables
run: |
echo "🔍 Checking if secrets are available..."
echo ""
echo "📊 VARIABLES:"
echo "✅ NODE_ENV: ${{ vars.NODE_ENV }}"
echo "✅ LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
echo "✅ NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}"
echo "✅ NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
echo "✅ NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
echo "✅ MY_EMAIL: ${{ vars.MY_EMAIL }}"
echo "✅ MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}"
echo ""
echo "🔐 SECRETS:"
if [ -n "${{ secrets.MY_PASSWORD }}" ]; then
echo "✅ MY_PASSWORD: Set (length: ${#MY_PASSWORD})"
else
echo "❌ MY_PASSWORD: Not set"
fi
if [ -n "${{ secrets.MY_INFO_PASSWORD }}" ]; then
echo "✅ MY_INFO_PASSWORD: Set (length: ${#MY_INFO_PASSWORD})"
else
echo "❌ MY_INFO_PASSWORD: Not set"
fi
if [ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "✅ ADMIN_BASIC_AUTH: Set (length: ${#ADMIN_BASIC_AUTH})"
else
echo "❌ ADMIN_BASIC_AUTH: Not set"
fi
echo ""
echo "📋 Summary:"
echo "Variables: 7 configured"
echo "Secrets: 3 configured"
echo "Total environment variables: 10"
env:
NODE_ENV: ${{ vars.NODE_ENV }}
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
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 }}
- name: Test Docker Environment
run: |
echo "🐳 Testing Docker environment with secrets..."
# Create a test container to verify environment variables
docker run --rm \
-e NODE_ENV=production \
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
-e REDIS_URL=redis://redis:6379 \
-e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
-e MY_EMAIL="${{ secrets.MY_EMAIL }}" \
-e MY_INFO_EMAIL="${{ secrets.MY_INFO_EMAIL }}" \
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
alpine:latest sh -c '
echo "Environment variables in container:"
echo "NODE_ENV: $NODE_ENV"
echo "DATABASE_URL: $DATABASE_URL"
echo "REDIS_URL: $REDIS_URL"
echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL"
echo "MY_EMAIL: $MY_EMAIL"
echo "MY_INFO_EMAIL: $MY_INFO_EMAIL"
echo "MY_PASSWORD: [HIDDEN - length: ${#MY_PASSWORD}]"
echo "MY_INFO_PASSWORD: [HIDDEN - length: ${#MY_INFO_PASSWORD}]"
echo "ADMIN_BASIC_AUTH: [HIDDEN - length: ${#ADMIN_BASIC_AUTH}]"
'
- name: Validate Secret Formats
run: |
echo "🔐 Validating secret formats..."
# Check NEXT_PUBLIC_BASE_URL format
if [[ "${{ secrets.NEXT_PUBLIC_BASE_URL }}" =~ ^https?:// ]]; then
echo "✅ NEXT_PUBLIC_BASE_URL: Valid URL format"
else
echo "❌ NEXT_PUBLIC_BASE_URL: Invalid URL format (should start with http:// or https://)"
fi
# Check email formats
if [[ "${{ secrets.MY_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "✅ MY_EMAIL: Valid email format"
else
echo "❌ MY_EMAIL: Invalid email format"
fi
if [[ "${{ secrets.MY_INFO_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "✅ MY_INFO_EMAIL: Valid email format"
else
echo "❌ MY_INFO_EMAIL: Invalid email format"
fi
# Check ADMIN_BASIC_AUTH format (should be username:password)
if [[ "${{ secrets.ADMIN_BASIC_AUTH }}" =~ ^[^:]+:.+$ ]]; then
echo "✅ ADMIN_BASIC_AUTH: Valid format (username:password)"
else
echo "❌ ADMIN_BASIC_AUTH: Invalid format (should be username:password)"
fi

View File

@@ -0,0 +1,132 @@
name: Dev Deployment (Zero Downtime)
on:
push:
branches: [ dev ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: staging
jobs:
deploy-dev:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
continue-on-error: true # Don't block dev deployments on lint errors
- name: Run tests
run: npm run test
continue-on-error: true # Don't block dev deployments on test failures
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building dev Docker image with BuildKit cache..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
.
echo "✅ Docker image built successfully"
- name: Zero-Downtime Dev Deployment
run: |
echo "🚀 Starting zero-downtime dev deployment..."
COMPOSE_FILE="docker-compose.staging.yml"
CONTAINER_NAME="portfolio-app-staging"
HEALTH_PORT="3002"
# Backup current container ID if running
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
# Start new container with updated image
echo "🆕 Starting new dev container..."
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
for i in {1..60}; do
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ ! -z "$NEW_CONTAINER" ]; then
# Check 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!"
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!"
break
fi
fi
echo "⏳ Waiting... ($i/60)"
sleep 2
done
# 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
fi
# Remove old container if it exists and is different
if [ ! -z "$OLD_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Dev deployment completed!"
env:
NODE_ENV: staging
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Dev Health Check
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
echo "✅ Dev is fully operational!"
exit 0
fi
echo "⏳ Waiting for dev... ($i/20)"
sleep 3
done
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
docker compose -f docker-compose.staging.yml logs --tail=50
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

View File

@@ -0,0 +1,273 @@
name: Production Deployment (Zero Downtime)
on:
push:
branches: [ production ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: production
jobs:
deploy-production:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting and tests in parallel
run: |
npm run lint &
LINT_PID=$!
npm run test:production &
TEST_PID=$!
wait $LINT_PID $TEST_PID
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building production Docker image with BuildKit cache..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
-t ${{ env.DOCKER_IMAGE }}:latest \
.
echo "✅ Docker image built successfully"
- name: Zero-Downtime Production Deployment
run: |
echo "🚀 Starting zero-downtime production deployment..."
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
HEALTH_PORT="3000"
# Backup current container ID if running (exact name match to avoid staging)
OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "")
# Export environment variables for docker-compose
export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}"
export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}"
export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}"
# Also export other variables that docker-compose needs
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
# Start new container with updated image (docker-compose will handle this)
echo "🆕 Starting new production container..."
echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}"
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
HEALTH_CHECK_PASSED=false
for i in {1..90}; do
# Get the production container ID (exact name match, exclude staging)
# Use compose project to ensure we get the right container
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
# Fallback: try exact name match with leading slash
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
# Verify it's actually the production container by checking compose project label
CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "")
CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "")
if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then
# Check Docker health status first (most reliable)
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ New container is healthy (Docker health check)!"
# Also verify HTTP endpoint from inside container
if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Container HTTP endpoint is also responding!"
HEALTH_CHECK_PASSED=true
break
else
echo "⚠️ Docker health check passed, but HTTP endpoint test failed. Continuing..."
fi
fi
# Try HTTP health endpoint from host (may not work if port not mapped yet)
if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ New container is responding to HTTP health check from host!"
HEALTH_CHECK_PASSED=true
break
fi
# Show container status for debugging
if [ $((i % 10)) -eq 0 ]; then
echo "📊 Container ID: $NEW_CONTAINER"
echo "📊 Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')"
echo "📊 Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')"
echo "📊 Health status: $HEALTH"
echo "📊 Testing from inside container:"
docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed"
docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true
fi
else
echo "⚠️ Found container but it's not from production compose file (skipping): $NEW_CONTAINER"
fi
fi
echo "⏳ Waiting... ($i/90)"
sleep 2
done
# Final verification: Check Docker health status (most reliable)
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
if [ "$FINAL_HEALTH" == "healthy" ]; then
echo "✅ Final verification: Container is healthy!"
HEALTH_CHECK_PASSED=true
fi
fi
# Verify new container is working
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "❌ New container failed health check!"
echo "📋 All running containers with 'portfolio' in name:"
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"
echo "📋 Production container from compose:"
docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose"
echo "📋 Container logs:"
docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs"
# Get the correct container ID
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
echo "📋 Container inspect (ID: $NEW_CONTAINER):"
docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found"
echo "📋 Testing health endpoint from inside container:"
docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
# Check Docker health status - if it's healthy, accept it
FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then
echo "✅ Docker health check reports healthy - accepting deployment!"
HEALTH_CHECK_PASSED=true
else
echo "❌ Docker health check also reports: $FINAL_HEALTH_CHECK"
exit 1
fi
else
echo "⚠️ Could not find production container!"
exit 1
fi
fi
# Remove old container if it exists and is different
if [ ! -z "$OLD_CONTAINER" ]; then
# Get the new production container ID
NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Production deployment completed with zero downtime!"
env:
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || '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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
- name: Production Health Check
run: |
echo "🔍 Running production health checks..."
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
# Get the production container ID
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$CONTAINER_ID" ]; then
CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ -z "$CONTAINER_ID" ]; then
echo "❌ Production container not found!"
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
exit 1
fi
echo "📦 Found container: $CONTAINER_ID"
# Wait for container to be healthy (using Docker's health check)
HEALTH_CHECK_PASSED=false
for i in {1..30}; do
HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then
echo "✅ Container is healthy and running!"
# Test from inside the container (most reliable)
if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Health endpoint responds from inside container!"
HEALTH_CHECK_PASSED=true
break
else
echo "⚠️ Container is healthy but HTTP endpoint test failed. Retrying..."
fi
fi
if [ $((i % 5)) -eq 0 ]; then
echo "📊 Status: $STATUS, Health: $HEALTH (attempt $i/30)"
fi
echo "⏳ Waiting for production... ($i/30)"
sleep 2
done
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "❌ Production health check failed!"
echo "📋 Container status:"
docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container"
echo "📋 Container logs:"
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs"
echo "📋 Testing from inside container:"
docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
exit 1
fi
echo "✅ Production is fully operational!"
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

View File

@@ -0,0 +1,155 @@
name: Staging Deployment
on:
push:
branches: [ dev, main ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app-staging
jobs:
staging:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building Docker image for staging..."
docker build -t ${{ env.DOCKER_IMAGE }}:staging .
docker tag ${{ env.DOCKER_IMAGE }}:staging ${{ env.DOCKER_IMAGE }}:staging-$(date +%Y%m%d-%H%M%S)
echo "✅ Docker image built successfully"
- name: Deploy Staging using Gitea Variables and Secrets
run: |
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
echo "📝 Using Gitea Variables and Secrets:"
echo " - NODE_ENV: staging"
echo " - LOG_LEVEL: ${LOG_LEVEL:-info}"
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
echo " - MY_EMAIL: ${MY_EMAIL}"
echo " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-}"
# Stop old staging containers only
echo "🛑 Stopping old staging containers..."
docker compose -f docker-compose.staging.yml down || true
# Clean up orphaned staging containers
echo "🧹 Cleaning up orphaned staging containers..."
docker compose -f docker-compose.staging.yml down --remove-orphans || true
# Start new staging containers
echo "🚀 Starting new staging containers..."
docker compose -f docker-compose.staging.yml up -d --force-recreate
# Wait a moment for containers to start
echo "⏳ Waiting for staging containers to start..."
sleep 15
# Check container logs for debugging
echo "📋 Staging container logs (first 30 lines):"
docker compose -f docker-compose.staging.yml logs --tail=30
echo "✅ Staging deployment completed!"
env:
NODE_ENV: staging
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Wait for staging to be ready
run: |
echo "⏳ Waiting for staging application to be ready..."
sleep 30
# Check if all staging containers are running
echo "📊 Checking staging container status..."
docker compose -f docker-compose.staging.yml ps
# Wait for application container to be healthy
echo "🏥 Waiting for staging application container to be healthy..."
for i in {1..40}; do
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
echo "✅ Staging application container is healthy!"
break
fi
echo "⏳ Waiting for staging application container... ($i/40)"
sleep 3
done
# Additional wait for main page to be accessible
echo "🌐 Waiting for staging main page to be accessible..."
for i in {1..20}; do
if curl -f http://localhost:3002/ > /dev/null 2>&1; then
echo "✅ Staging main page is accessible!"
break
fi
echo "⏳ Waiting for staging main page... ($i/20)"
sleep 2
done
- name: Staging health check
run: |
echo "🔍 Running staging health checks..."
# Check container status
echo "📊 Staging container status:"
docker compose -f docker-compose.staging.yml ps
# Check application container
echo "🏥 Checking staging application container..."
if curl -f http://localhost:3002/api/health; then
echo "✅ Staging application health check passed!"
else
echo "⚠️ Staging application health check failed, but continuing..."
docker compose -f docker-compose.staging.yml logs --tail=50
fi
# Check main page
if curl -f http://localhost:3002/ > /dev/null; then
echo "✅ Staging main page is accessible!"
else
echo "⚠️ Staging main page check failed, but continuing..."
fi
echo "✅ Staging deployment verification completed!"
- name: Cleanup old staging images
run: |
echo "🧹 Cleaning up old staging images..."
docker image prune -f --filter "label=stage=staging" || true
echo "✅ Cleanup completed"

View File

@@ -1,41 +0,0 @@
name: Test and Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
NODE_VERSION: '20'
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Run security scan
run: |
echo "🔍 Running npm audit..."
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."

View File

@@ -1,105 +0,0 @@
name: Test Gitea Variables and Secrets
on:
push:
branches: [ production ]
workflow_dispatch:
jobs:
test-variables:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Test Variables and Secrets Access
run: |
echo "🔍 Testing Gitea Variables and Secrets access..."
# Test Variables
echo "📝 Testing Variables:"
echo "NEXT_PUBLIC_BASE_URL: '${{ vars.NEXT_PUBLIC_BASE_URL }}'"
echo "MY_EMAIL: '${{ vars.MY_EMAIL }}'"
echo "MY_INFO_EMAIL: '${{ vars.MY_INFO_EMAIL }}'"
echo "NODE_ENV: '${{ vars.NODE_ENV }}'"
echo "LOG_LEVEL: '${{ vars.LOG_LEVEL }}'"
echo "NEXT_PUBLIC_UMAMI_URL: '${{ vars.NEXT_PUBLIC_UMAMI_URL }}'"
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID: '${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}'"
# Test Secrets (without revealing values)
echo ""
echo "🔐 Testing Secrets:"
echo "MY_PASSWORD: '$([ -n "${{ secrets.MY_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
echo "MY_INFO_PASSWORD: '$([ -n "${{ secrets.MY_INFO_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
echo "ADMIN_BASIC_AUTH: '$([ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ] && echo "[SET]" || echo "[NOT SET]")'"
# Check if variables are empty
echo ""
echo "🔍 Checking for empty variables:"
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL is empty or not set"
else
echo "✅ NEXT_PUBLIC_BASE_URL is set"
fi
if [ -z "${{ vars.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL is empty or not set"
else
echo "✅ MY_EMAIL is set"
fi
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
echo "❌ MY_INFO_EMAIL is empty or not set"
else
echo "✅ MY_INFO_EMAIL is set"
fi
# Check secrets
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
echo "❌ MY_PASSWORD secret is empty or not set"
else
echo "✅ MY_PASSWORD secret is set"
fi
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
echo "❌ MY_INFO_PASSWORD secret is empty or not set"
else
echo "✅ MY_INFO_PASSWORD secret is set"
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is empty or not set"
else
echo "✅ ADMIN_BASIC_AUTH secret is set"
fi
echo ""
echo "📊 Summary:"
echo "Variables set: $(echo '${{ vars.NEXT_PUBLIC_BASE_URL }}' | wc -c)"
echo "Secrets set: $(echo '${{ secrets.MY_PASSWORD }}' | wc -c)"
- name: Test Environment Variable Export
run: |
echo "🧪 Testing environment variable export..."
# Export variables as environment variables
export NODE_ENV="${{ vars.NODE_ENV }}"
export LOG_LEVEL="${{ vars.LOG_LEVEL }}"
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
export NEXT_PUBLIC_UMAMI_URL="${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
export NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
echo "📝 Exported environment variables:"
echo "NODE_ENV: ${NODE_ENV:-[NOT SET]}"
echo "LOG_LEVEL: ${LOG_LEVEL:-[NOT SET]}"
echo "NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-[NOT SET]}"
echo "MY_EMAIL: ${MY_EMAIL:-[NOT SET]}"
echo "MY_INFO_EMAIL: ${MY_INFO_EMAIL:-[NOT SET]}"
echo "MY_PASSWORD: $([ -n "${MY_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
echo "MY_INFO_PASSWORD: $([ -n "${MY_INFO_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
echo "ADMIN_BASIC_AUTH: $([ -n "${ADMIN_BASIC_AUTH}" ] && echo "[SET]" || echo "[NOT SET]")"

View File

@@ -198,7 +198,7 @@ jobs:
# Wait for health check
echo "Waiting for staging application to be healthy..."
for i in {1..30}; do
if curl -f http://localhost:3001/api/health > /dev/null 2>&1; then
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
echo "✅ Staging deployment successful!"
break
fi
@@ -206,7 +206,7 @@ jobs:
done
# Verify deployment
if curl -f http://localhost:3001/api/health; then
if curl -f http://localhost:3002/api/health; then
echo "✅ Staging deployment verified!"
else
echo "⚠️ Staging health check failed, but container is running"

View File

@@ -1,85 +0,0 @@
# 🚀 Auto-Deployment Status
## Current Setup
### GitHub Actions Workflow (`.github/workflows/ci-cd.yml`)
**Triggers on**: Push to `main` OR `production` branches
**What happens on `main` branch**:
- ✅ Runs tests
- ✅ Runs linting
- ✅ Builds Docker image
- ✅ Pushes image to registry
-**Does NOT deploy to server**
**What happens on `production` branch**:
- ✅ Runs tests
- ✅ Runs linting
- ✅ Builds Docker image
- ✅ Pushes image to registry
-**Deploys to server automatically**
### Key Line in Workflow
```yaml
# Line 159 in .github/workflows/ci-cd.yml
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
```
This means deployment **only** happens on `production` branch.
## Answer: Can you merge to main and auto-deploy?
**❌ NO** - Merging to `main` will:
- Build and test everything
- Create Docker image
- **But NOT deploy to your server**
**✅ YES** - Merging to `production` will:
- Build and test everything
- Create Docker image
- **AND deploy to your server automatically**
## Options
### Option 1: Use Production Branch (Current Setup)
```bash
# Merge dev → main (tests/build only)
git checkout main
git merge dev
git push origin main
# Then merge main → production (auto-deploys)
git checkout production
git merge main
git push origin production # ← This triggers deployment
```
### Option 2: Enable Auto-Deploy on Main
If you want `main` to auto-deploy, I can update the workflow to deploy on `main` as well.
### Option 3: Manual Deployment
After merging to `main`, manually run:
```bash
./scripts/gitea-deploy.sh
# or
./scripts/auto-deploy.sh
```
## Recommendation
**Keep current setup** (deploy only on `production`):
- ✅ Safer: `main` is for testing builds
-`production` is explicitly for deployments
- ✅ Can test on `main` without deploying
- ✅ Clear separation of concerns
**Workflow**:
1. Merge `dev``main` (validates build works)
2. Test the built image if needed
3. Merge `main``production` (auto-deploys)
---
**Current Status**: Auto-deployment is configured, but only for `production` branch.

View File

@@ -1,66 +0,0 @@
# 🧹 Codebase Cleanup Plan
## MD Files Analysis
### ✅ KEEP (Essential Documentation)
1. **README.md** - Main project documentation
2. **docs/ai-image-generation/README.md** - AI feature docs
3. **docs/ai-image-generation/SETUP.md** - Setup guide
4. **docs/ai-image-generation/QUICKSTART.md** - Quick start
5. **docs/ai-image-generation/WEBHOOK_SETUP.md** - Webhook setup (just created)
6. **TESTING_GUIDE.md** - Testing documentation
7. **SAFE_PUSH_TO_MAIN.md** - Deployment guide
8. **AUTO_DEPLOYMENT_STATUS.md** - Deployment status (just created)
### ❌ REMOVE (Old/Duplicate/Outdated)
1. **CHANGELOG_DEV.md** - Old changelog, can be in git history
2. **PUSH_READY.md** - One-time status file
3. **COMMIT_MESSAGE.txt** - One-time commit message
4. **DEPLOYMENT-FIXES.md** - Old fixes, should be in git
5. **DEPLOYMENT-IMPROVEMENTS.md** - Old improvements
6. **DEPLOYMENT.md** - Duplicate of PRODUCTION-DEPLOYMENT.md
7. **AFTER_PUSH_SETUP.md** - One-time setup guide
8. **PRE_PUSH_CHECKLIST.md** - Can merge into SAFE_PUSH_TO_MAIN.md
9. **TEST_FIXES.md** - One-time fix notes
10. **AUTOMATED_TESTING_SETUP.md** - Info now in TESTING_GUIDE.md
11. **SECURITY-UPDATE.md** - Old update notes
12. **SECURITY-CHECKLIST.md** - Can merge into SECURITY.md
13. **ANALYTICS.md** - If not actively used
14. **PRODUCTION-DEPLOYMENT.md** - If DEPLOYMENT.md covers it
### 📁 CONSOLIDATE (Merge into main docs)
- **docs/IMPROVEMENTS_SUMMARY.md** → Merge into README or remove
- **docs/CODING_DETECTION_DEBUG.md** → Remove if not needed
- **docs/DYNAMIC_ACTIVITY_MANAGEMENT.md** → Keep if actively used
- **docs/ACTIVITY_FEATURES.md** → Keep if actively used
- **docs/N8N_CHAT_SETUP.md** → Keep if using n8n chat
- **docs/N8N_INTEGRATION.md** → Keep if using n8n
## Old/Unused Files to Remove
### Scripts (Many duplicates)
- `scripts/test-fix.sh` - One-time fix
- `scripts/test-deployment.sh` - One-time test
- `scripts/quick-health-fix.sh` - One-time fix
- `scripts/fix-connection.sh` - One-time fix
- `scripts/debug-gitea-actions.sh` - Debug script, not needed
- Multiple docker-compose files (keep only needed ones)
### Disabled Workflows
- `.gitea/workflows/*.disabled` - Remove all disabled workflows
### Old Test Results
- `test-results/` - Can be regenerated
- `playwright-report/` - Can be regenerated
### Logs
- `logs/*.log` - Should be in .gitignore
## Git Remote Issue
Current: `https://git.dk0.dev/denshooter/portfolio`
Issue: Can't connect to git.dk0.dev:443
Options:
1. Check if server is up
2. Use SSH instead: `git@git.dk0.dev:denshooter/portfolio.git`
3. Check if URL changed

View File

@@ -1,95 +0,0 @@
# 🧹 Cleanup Summary
## Files Removed
### Documentation (15 files)
- ✅ CHANGELOG_DEV.md - Old changelog
- ✅ PUSH_READY.md - One-time status
- ✅ COMMIT_MESSAGE.txt - One-time commit message
- ✅ DEPLOYMENT-FIXES.md - Old fixes
- ✅ DEPLOYMENT-IMPROVEMENTS.md - Old improvements
- ✅ DEPLOYMENT.md - Duplicate
- ✅ AFTER_PUSH_SETUP.md - One-time setup
- ✅ PRE_PUSH_CHECKLIST.md - Merged into SAFE_PUSH_TO_MAIN.md
- ✅ TEST_FIXES.md - One-time fixes
- ✅ AUTOMATED_TESTING_SETUP.md - Info in TESTING_GUIDE.md
- ✅ SECURITY-UPDATE.md - Old update
- ✅ SECURITY-CHECKLIST.md - Merged into SECURITY.md
- ✅ PRODUCTION-DEPLOYMENT.md - Duplicate
- ✅ ANALYTICS.md - Not actively used
- ✅ docs/IMPROVEMENTS_SUMMARY.md - Old summary
- ✅ docs/CODING_DETECTION_DEBUG.md - Debug notes
### Scripts (4 files)
- ✅ scripts/quick-health-fix.sh - One-time fix
- ✅ scripts/fix-connection.sh - One-time fix
- ✅ scripts/debug-gitea-actions.sh - Debug script
### Workflows (7 files)
- ✅ .gitea/workflows/*.disabled - All disabled workflows removed
### Docker Configs (2 files)
- ✅ docker-compose.zero-downtime.yml - Old version
- ✅ docker-compose.zero-downtime-fixed.yml - Old version
- ✅ nginx-zero-downtime.conf - Unused
## Files Kept (Essential)
### Documentation
- ✅ README.md - Main docs
- ✅ DEV-SETUP.md - Setup guide
- ✅ SECURITY.md - Security info
- ✅ TESTING_GUIDE.md - Testing docs
- ✅ SAFE_PUSH_TO_MAIN.md - Deployment guide
- ✅ AUTO_DEPLOYMENT_STATUS.md - Deployment status
- ✅ docs/ai-image-generation/* - AI feature docs
- ✅ docs/ACTIVITY_FEATURES.md - Activity features
- ✅ docs/DYNAMIC_ACTIVITY_MANAGEMENT.md - Activity management
- ✅ docs/N8N_CHAT_SETUP.md - n8n chat setup
- ✅ docs/N8N_INTEGRATION.md - n8n integration
### Docker Configs
- ✅ docker-compose.yml - Main config
- ✅ docker-compose.production.yml - Production
- ✅ docker-compose.dev.minimal.yml - Dev minimal
## Git Remote Fixed
**Before**: `https://git.dk0.dev/denshooter/portfolio` (HTTPS - connection issues)
**After**: `git@git.dk0.dev:denshooter/portfolio.git` (SSH - more reliable)
## .gitignore Updated
Added:
- `logs/*.log` - Log files
- `test-results/` - Test results
- `playwright-report/` - Playwright reports
- `coverage/` - Coverage reports
- `.idea/` - IDE files
- `.vscode/` - IDE files
## Next Steps
1. **Test Git connection**:
```bash
git fetch
```
2. **If SSH doesn't work**, switch back to HTTPS:
```bash
git remote set-url origin https://git.dk0.dev/denshooter/portfolio.git
```
3. **Commit cleanup**:
```bash
git add .
git commit -m "chore: Clean up old documentation and unused files"
git push origin dev
```
## Result
- **Removed**: ~30 files
- **Kept**: Essential documentation and configs
- **Fixed**: Git remote connection
- **Updated**: .gitignore for better file management

200
DEPLOYMENT_SETUP.md Normal file
View File

@@ -0,0 +1,200 @@
# 🚀 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 <container-name>`
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)

View File

@@ -3,11 +3,10 @@ FROM node:20 AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install dependencies based on the preferred package manager
# Copy package files first for better caching
COPY package.json package-lock.json* ./
RUN npm ci --only=production && npm cache clean --force
@@ -19,22 +18,38 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install all dependencies (including dev dependencies for build)
RUN npm ci
# Use npm ci with cache mount for faster builds
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy source code
COPY . .
# Copy Prisma schema first (for better caching)
COPY prisma ./prisma
# Install type definitions for react-responsive-masonry and node-fetch
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
# Generate Prisma client
# Generate Prisma client (cached if schema unchanged)
RUN npx prisma generate
# Copy source code (this invalidates cache when code changes)
COPY . .
# Build the application
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Verify standalone output was created and show structure for debugging
RUN if [ ! -d .next/standalone ]; then \
echo "ERROR: .next/standalone directory not found!"; \
echo "Contents of .next directory:"; \
ls -la .next/ || true; \
echo "Checking if standalone exists in different location:"; \
find .next -name "standalone" -type d || true; \
exit 1; \
fi && \
echo "✅ Standalone output found" && \
ls -la .next/standalone/ && \
echo "Standalone structure:" && \
find .next/standalone -type f -name "server.js" || echo "server.js not found in standalone"
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
@@ -42,6 +57,9 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -55,7 +73,10 @@ RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/app ./
# Copy standalone output (contains server.js and all dependencies)
# The standalone output structure is: .next/standalone/ (not .next/standalone/app/)
# Next.js creates: .next/standalone/server.js, .next/standalone/.next/, .next/standalone/node_modules/
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma files

185
GITEA_VARIABLES_SETUP.md Normal file
View File

@@ -0,0 +1,185 @@
# 🔧 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?

View File

@@ -1,53 +0,0 @@
# 🔧 Git Connection Fix
## Issue
```
fatal: unable to access 'https://git.dk0.dev/denshooter/portfolio/':
Failed to connect to git.dk0.dev port 443 after 75002 ms: Couldn't connect to server
```
## Solutions
### Option 1: Check Server Status
The server is reachable via HTTP (tested), but Git might need authentication.
### Option 2: Configure Git Credentials
```bash
# Store credentials
git config --global credential.helper store
# Or use keychain (macOS)
git config --global credential.helper osxkeychain
```
### Option 3: Use Personal Access Token
1. Go to: https://git.dk0.dev/user/settings/applications
2. Generate a new token
3. Use it when pushing:
```bash
git push https://YOUR_TOKEN@git.dk0.dev/denshooter/portfolio.git
```
### Option 4: Check Firewall/Network
- Port 443 might be blocked
- Try from different network
- Check if VPN is needed
### Option 5: Use SSH (if port 22 opens)
```bash
git remote set-url origin git@git.dk0.dev:denshooter/portfolio.git
```
## Current Status
- Remote URL: `https://git.dk0.dev/denshooter/portfolio.git`
- Server reachable: ✅ (HTTP works)
- Git connection: ⚠️ (May need credentials)
## Quick Test
```bash
# Test connection
curl -I https://git.dk0.dev
# Test Git
git ls-remote https://git.dk0.dev/denshooter/portfolio.git
```

View File

@@ -0,0 +1,198 @@
# 🔧 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

120
SECURITY_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,120 @@
# 🔒 Security Improvements
## Implemented Security Features
### 1. n8n API Endpoint Protection
All n8n endpoints are now protected with:
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
- **Rate Limiting**:
- `/api/n8n/generate-image`: 10 requests/minute
- `/api/n8n/chat`: 20 requests/minute
- `/api/n8n/status`: 30 requests/minute
### 2. Email Obfuscation
Email addresses can now be obfuscated to prevent automated scraping:
```typescript
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
// React component
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
// HTML string
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```
**How it works:**
- Emails are base64 encoded in the HTML
- JavaScript decodes them on click
- Prevents simple regex-based email scrapers
- Still functional for real users
### 3. URL Obfuscation
Sensitive URLs can be obfuscated:
```typescript
import { createObfuscatedLink } from '@/lib/email-obfuscate';
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
```
### 4. Rate Limiting
All API endpoints have rate limiting:
- Prevents brute force attacks
- Protects against DDoS
- Configurable per endpoint
## Code Obfuscation
**Note**: Full code obfuscation for Next.js is **not recommended** because:
1. **Next.js already minifies code** in production builds
2. **Obfuscation breaks source maps** (harder to debug)
3. **Performance impact** (slower execution)
4. **Not effective** - determined attackers can still reverse engineer
5. **Maintenance burden** - harder to debug issues
**Better alternatives:**
- ✅ Minification (already enabled in Next.js)
- ✅ Environment variables for secrets
- ✅ Server-side rendering (code not exposed)
- ✅ API authentication
- ✅ Rate limiting
- ✅ Security headers
## Best Practices
### For Email Protection:
1. Use obfuscated emails in public HTML
2. Use contact forms instead of direct mailto links
3. Monitor for spam patterns
### For API Protection:
1. Always require authentication for sensitive endpoints
2. Use rate limiting
3. Log suspicious activity
4. Use HTTPS only
5. Validate all inputs
### For Webhook Protection:
1. Use secret tokens (`N8N_SECRET_TOKEN`)
2. Verify webhook signatures
3. Rate limit webhook endpoints
4. Monitor webhook usage
## Implementation Status
- ✅ n8n endpoints protected with auth + rate limiting
- ✅ Email obfuscation utility created
- ✅ URL obfuscation utility created
- ✅ Rate limiting on all n8n endpoints
- ⚠️ Email obfuscation not yet applied to pages (manual step)
- ⚠️ Code obfuscation not implemented (not recommended)
## Next Steps
To apply email obfuscation to your pages:
1. Import the utility:
```typescript
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
```
2. Replace email links:
```tsx
// Before
<a href="mailto:contact@dk0.dev">Contact</a>
// After
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
```
3. For static HTML, use the string function:
```typescript
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```

View File

@@ -5,11 +5,11 @@
You now have **two separate Docker stacks**:
1. **Staging** - Deploys automatically on `dev` or `main` branch
- Port: `3001`
- Port: `3002`
- Container: `portfolio-app-staging`
- Database: `portfolio_staging_db` (port 5433)
- Redis: `portfolio-redis-staging` (port 6380)
- URL: `https://staging.dk0.dev` (or `http://localhost:3001`)
- URL: `https://staging.dk0.dev` (or `http://localhost:3002`)
2. **Production** - Deploys automatically on `production` branch
- Port: `3000`
@@ -25,7 +25,7 @@ When you push to `dev` or `main` branch:
1. ✅ Tests run
2. ✅ Docker image is built and tagged as `staging`
3. ✅ Staging stack deploys automatically
4. ✅ Available on port 3001
4. ✅ Available on port 3002
### Automatic Production Deployment
When you merge to `production` branch:
@@ -55,9 +55,9 @@ When you merge to `production` branch:
| Service | Staging | Production |
|---------|---------|------------|
| App | 3001 | 3000 |
| PostgreSQL | 5433 | 5432 |
| Redis | 6380 | 6379 |
| App | 3002 | 3000 |
| PostgreSQL | 5434 | 5432 |
| Redis | 6381 | 6379 |
## Workflow
@@ -69,10 +69,10 @@ git checkout dev
# 2. Push to dev (triggers staging deployment)
git push origin dev
# → Staging deploys automatically on port 3001
# → Staging deploys automatically on port 3002
# 3. Test staging
curl http://localhost:3001/api/health
curl http://localhost:3002/api/health
# 4. Merge to main (also triggers staging)
git checkout main
@@ -101,7 +101,7 @@ docker compose -f docker-compose.staging.yml down
docker compose -f docker-compose.staging.yml logs -f
# Check staging health
curl http://localhost:3001/api/health
curl http://localhost:3002/api/health
```
### Production
@@ -142,7 +142,7 @@ curl http://localhost:3000/api/health
### Check Both Environments
```bash
# Staging
curl http://localhost:3001/api/health
curl http://localhost:3002/api/health
# Production
curl http://localhost:3000/api/health
@@ -174,11 +174,11 @@ docker ps | grep -v staging
4. Manual rollback: Restart old container if needed
### Port Conflicts
- Staging uses 3001, 5433, 6380
- Staging uses 3002, 5434, 6381
- Production uses 3000, 5432, 6379
- If conflicts occur, check what's using the ports:
```bash
lsof -i :3001
lsof -i :3002
lsof -i :3000
```

View File

@@ -3,412 +3,199 @@ import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
// Email templates with beautiful designs
const BRAND = {
siteUrl: "https://dk0.dev",
email: "contact@dk0.dev",
bg: "#FDFCF8",
sand: "#F3F1E7",
border: "#E7E5E4",
text: "#292524",
muted: "#78716C",
mint: "#A7F3D0",
red: "#EF4444",
};
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function nl2br(input: string): string {
return input.replace(/\r\n|\r|\n/g, "<br>");
}
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) {
const sentAt = new Date().toLocaleString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(opts.title)}</title>
</head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:${BRAND.text};">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid ${BRAND.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<div style="background:${BRAND.text};padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:800;font-size:16px;color:${BRAND.bg};">Dennis Konkol</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:800;font-size:14px;color:${BRAND.bg};">
dk<span style="color:${BRAND.red};">0</span>.dev
</div>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)}${sentAt}</div>
</div>
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
</div>
<div style="padding:26px;">
${opts.bodyHtml}
</div>
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};">
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;">
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> •
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a>
</div>
</div>
</div>
</div>
</body>
</html>
`.trim();
}
const emailTemplates = {
welcome: {
subject: "Vielen Dank für deine Nachricht! 👋",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
👋 Hallo ${name}!
</h1>
<p style="color: #d1fae5; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Vielen Dank für deine Nachricht
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Welcome Message -->
<div style="background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #bbf7d0;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">✓</span>
</div>
<h2 style="color: #065f46; margin: 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
</div>
<p style="color: #047857; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich habe sie erhalten und werde mich so schnell wie möglich bei dir melden.
</p>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Next Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🚀 Was passiert als nächstes?
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">📧</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Schnelle Antwort</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich antworte normalerweise innerhalb von 24 Stunden</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">💼</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Projekt-Diskussion</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Gerne besprechen wir dein Projekt im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🤝</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Zusammenarbeit</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Lass uns gemeinsam etwas Großartiges schaffen!</p>
</div>
</div>
</div>
</div>
<!-- Portfolio Links -->
<div style="text-align: center; margin-top: 30px;">
<h3 style="color: #374151; margin: 0 0 20px 0; font-size: 18px; font-weight: 600;">Entdecke mehr von mir</h3>
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
<a href="https://dk0.dev" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
🌐 Portfolio
</a>
<a href="https://github.com/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #374151 0%, #111827 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💻 GitHub
</a>
<a href="https://linkedin.com/in/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #0077b5 0%, #005885 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💼 LinkedIn
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dk0.dev" style="color: #10b981; text-decoration: none; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-weight: bold;">dk<span style="color: #ef4444;">0</span>.dev</a> •
<a href="mailto:contact@dk0.dev" style="color: #10b981; text-decoration: none;">contact@dk0.dev</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
subtitle: "Nachricht erhalten",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Portfolio ansehen
</a>
</div>
`.trim(),
});
},
},
project: {
subject: "Projekt-Anfrage erhalten! 🚀",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projekt-Anfrage - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
🚀 Projekt-Anfrage erhalten!
</h1>
<p style="color: #e9d5ff; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hallo ${name}, lass uns etwas Großartiges schaffen!
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Project Message -->
<div style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e9d5ff;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">💼</span>
</div>
<h2 style="color: #6b21a8; margin: 0; font-size: 22px; font-weight: 600;">Bereit für dein Projekt!</h2>
</div>
<p style="color: #7c2d12; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Projekt-Anfrage! Ich bin gespannt darauf, mehr über deine Ideen zu erfahren und wie wir sie gemeinsam umsetzen können.
</p>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 10px;"></span>
Deine Projekt-Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Process Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🎯 Mein Arbeitsprozess
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">💬</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">1. Erstgespräch</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Wir besprechen deine Anforderungen im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">📋</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">2. Konzept & Planung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich erstelle ein detailliertes Konzept für dein Projekt</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #10b981;">
<span style="color: #10b981; font-size: 20px; margin-right: 15px;">⚡</span>
<div>
<h4 style="color: #059669; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">3. Entwicklung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Agile Entwicklung mit regelmäßigen Updates</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🎉</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">4. Launch & Support</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Deployment und kontinuierlicher Support</p>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:contact@dk0.dev?subject=Projekt-Diskussion mit ${name}" style="display: inline-block; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
💬 Projekt besprechen
</a>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #8b5cf6; text-decoration: none;">dki.one</a> •
<a href="mailto:contact@dk0.dev" style="color: #8b5cf6; text-decoration: none;">contact@dk0.dev</a>
</p>
</div>
</div>
</body>
</html>
`
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Projekt-Anfrage: danke, ${safeName}!`,
subtitle: "Ich melde mich zeitnah",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Kontakt aufnehmen
</a>
</div>
`.trim(),
});
},
},
quick: {
subject: "Danke für deine Nachricht! ⚡",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quick Response - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
⚡ Schnelle Antwort!
</h1>
<p style="color: #fef3c7; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hallo ${name}, danke für deine Nachricht!
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Quick Response -->
<div style="background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #fde68a;">
<div style="text-align: center;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">⚡</span>
</div>
<h2 style="color: #92400e; margin: 0 0 15px 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
<p style="color: #a16207; margin: 0; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich werde mich so schnell wie möglich bei dir melden.
</p>
</div>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 10px;"></span>
Deine Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #f59e0b;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Quick Info -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 25px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; text-align: center;">
📞 Kontakt
</h3>
<p style="color: #1e40af; margin: 0; text-align: center; line-height: 1.6; font-size: 14px;">
<strong>E-Mail:</strong> <a href="mailto:contact@dk0.dev" style="color: #1e40af; text-decoration: none;">contact@dk0.dev</a><br>
<strong>Portfolio:</strong> <a href="https://dki.one" style="color: #1e40af; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #f59e0b; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
</body>
</html>
`
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
subtitle: "Kurze Bestätigung",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
`.trim(),
});
},
},
reply: {
subject: "Antwort auf deine Nachricht 📧",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Antwort - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
📧 Hallo ${name}!
</h1>
<p style="color: #dbeafe; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hier ist meine Antwort auf deine Nachricht
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Reply Message -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #93c5fd;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">💬</span>
</div>
<h2 style="color: #1e40af; margin: 0; font-size: 22px; font-weight: 600;">Meine Antwort</h2>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #1e40af; margin: 0; line-height: 1.6; font-size: 16px; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Contact Info -->
<div style="background: #f8fafc; padding: 25px; border-radius: 12px; text-align: center; border: 1px solid #e2e8f0;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 18px; font-weight: 600;">Weitere Fragen?</h3>
<p style="color: #6b7280; margin: 0 0 20px 0; line-height: 1.6;">
Falls du weitere Fragen hast oder mehr über meine Projekte erfahren möchtest, zögere nicht, mir zu schreiben!
</p>
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap;">
<a href="https://dki.one" style="display: inline-flex; align-items: center; padding: 12px 24px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500; transition: all 0.2s;">
🌐 Portfolio besuchen
</a>
<a href="mailto:contact@dk0.dev" style="display: inline-flex; align-items: center; padding: 12px 24px; background: #ffffff; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: 500; border: 2px solid #3b82f6; transition: all 0.2s;">
📧 Direkt antworten
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; margin: 0 0 10px 0; font-size: 14px; font-weight: 500;">
<strong>Dennis Konkol</strong> • <a href="https://dki.one" style="color: #3b82f6; text-decoration: none;">dki.one</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
}
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Antwort für ${safeName}`,
subtitle: "Neue Nachricht",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
hier ist meine Antwort:
</div>
<div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
`.trim(),
});
},
},
};
export async function POST(request: NextRequest) {

View File

@@ -15,6 +15,15 @@ function sanitizeInput(input: string, maxLength: number = 10000): string {
.trim();
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function POST(request: NextRequest) {
try {
// Rate limiting (defensive: headers may be undefined in tests)
@@ -155,6 +164,22 @@ export async function POST(request: NextRequest) {
}
}
const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const safeSubject = escapeHtml(subject);
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
to: "contact@dk0.dev", // Send to your contact email
@@ -168,86 +193,80 @@ export async function POST(request: NextRequest) {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neue Kontaktanfrage - Portfolio</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
📧 Neue Kontaktanfrage
</h1>
<p style="color: #e2e8f0; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Von deinem Portfolio
</p>
<body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<!-- Top bar -->
<div style="background:#292524;padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
Dennis Konkol
</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
dk<span style="color:#ef4444;">0</span>.dev
</div>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Contact Info Card -->
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e2e8f0;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px;">
<span style="color: #ffffff; font-size: 20px; font-weight: bold;">${name.charAt(0).toUpperCase()}</span>
</div>
<div>
<h2 style="color: #1e293b; margin: 0; font-size: 24px; font-weight: 600;">${name}</h2>
<p style="color: #64748b; margin: 4px 0 0 0; font-size: 14px;">Kontaktanfrage</p>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<h4 style="color: #059669; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">E-Mail</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${email}</p>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<h4 style="color: #2563eb; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Betreff</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${subject}</p>
</div>
</div>
</div>
<!-- Message Card -->
<div style="background: #ffffff; padding: 30px; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 8px; height: 8px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; margin-right: 12px;"></div>
<h3 style="color: #1e293b; margin: 0; font-size: 18px; font-weight: 600;">Nachricht</h3>
</div>
<div style="background: #f8fafc; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea;">
<p style="color: #374151; margin: 0; line-height: 1.7; font-size: 16px; white-space: pre-wrap;">${message}</p>
</div>
</div>
<!-- Action Button -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:${email}?subject=Re: ${subject}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transition: all 0.2s;">
📬 Antworten
</a>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
Neue Kontaktanfrage
</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
Eingegangen am ${sentAt}
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 1px;"></span>
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
</div>
<!-- Content -->
<div style="padding:26px;">
<!-- Sender -->
<div style="display:flex;align-items:flex-start;gap:14px;">
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
${escapeHtml(initial)}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
${safeName}
</div>
<p style="color: #64748b; margin: 0; font-size: 14px; line-height: 1.5;">
Diese E-Mail wurde automatisch von deinem Portfolio generiert.<br>
<strong>Dennis Konkol Portfolio</strong> • <a href="https://dki.one" style="color: #667eea; text-decoration: none;">dki.one</a>
</p>
<p style="color: #94a3b8; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
<div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
<span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
<span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
</div>
</div>
</div>
<!-- Message -->
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
Nachricht
</div>
</div>
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
${safeMessageHtml}
</div>
</div>
<!-- CTA -->
<div style="margin-top:22px;text-align:center;">
<a href="${escapeHtml(replyHref)}"
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Antworten
</a>
<div style="margin-top:10px;font-size:12px;color:#78716c;">
Oder antworte direkt auf diese E-Mail.
</div>
</div>
</div>
<!-- Footer -->
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
<div style="font-size:12px;color:#78716c;line-height:1.5;">
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
</div>
</div>
</div>
</div>
</body>
</html>
`,
@@ -261,7 +280,7 @@ Nachricht:
${message}
---
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
Diese E-Mail wurde automatisch von dk0.dev generiert.
`,
};

View File

@@ -1,9 +1,21 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
let userMessage = "";
try {
// Rate limiting for n8n chat endpoint
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
const json = await request.json();
userMessage = json.message;
const history = json.history || [];
@@ -18,65 +30,193 @@ export async function POST(request: Request) {
// Call your n8n chat webhook
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
console.error("N8N_WEBHOOK_URL not configured");
if (!n8nWebhookUrl || n8nWebhookUrl.trim() === '') {
console.error("N8N_WEBHOOK_URL not configured. Environment check:", {
hasUrl: !!process.env.N8N_WEBHOOK_URL,
urlValue: process.env.N8N_WEBHOOK_URL || '(empty)',
nodeEnv: process.env.NODE_ENV,
});
return NextResponse.json({
reply: getFallbackResponse(userMessage),
});
}
console.log(`Sending to n8n: ${n8nWebhookUrl}/webhook/chat`);
const response = await fetch(`${n8nWebhookUrl}/webhook/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.N8N_API_KEY && {
Authorization: `Bearer ${process.env.N8N_API_KEY}`,
}),
},
body: JSON.stringify({
message: userMessage,
history: history,
}),
// Ensure URL doesn't have trailing slash before adding /webhook/chat
const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/webhook/chat`;
console.log(`Sending to n8n: ${webhookUrl}`, {
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
});
if (!response.ok) {
console.error(`n8n webhook failed with status: ${response.status}`);
throw new Error(`n8n webhook failed: ${response.status}`);
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
...(process.env.N8N_API_KEY && {
"X-API-Key": process.env.N8N_API_KEY,
}),
},
body: JSON.stringify({
message: userMessage,
history: history,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
console.error(`n8n webhook failed with status: ${response.status}`, {
status: response.status,
statusText: response.statusText,
error: errorText,
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
});
throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
console.log("n8n response data (full):", JSON.stringify(data, null, 2));
console.log("n8n response data type:", typeof data);
console.log("n8n response is array:", Array.isArray(data));
// Try multiple ways to extract the reply
let reply: string | undefined = undefined;
// Direct fields
if (data.reply) reply = data.reply;
else if (data.message) reply = data.message;
else if (data.response) reply = data.response;
else if (data.text) reply = data.text;
else if (data.content) reply = data.content;
else if (data.answer) reply = data.answer;
else if (data.output) reply = data.output;
else if (data.result) reply = data.result;
// Array handling
else if (Array.isArray(data) && data.length > 0) {
const firstItem = data[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.output || firstItem.result;
}
}
// Nested structures (common in n8n)
else if (data && typeof data === "object") {
// Check nested data field
if (data.data) {
if (typeof data.data === 'string') {
reply = data.data;
} else if (typeof data.data === 'object') {
reply = data.data.reply || data.data.message || data.data.response ||
data.data.text || data.data.content || data.data.answer;
}
}
// Check nested json field
if (!reply && data.json) {
if (typeof data.json === 'string') {
reply = data.json;
} else if (typeof data.json === 'object') {
reply = data.json.reply || data.json.message || data.json.response ||
data.json.text || data.json.content || data.json.answer;
}
}
// Check items array (n8n often wraps in items)
if (!reply && Array.isArray(data.items) && data.items.length > 0) {
const firstItem = data.items[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.json?.reply || firstItem.json?.message;
}
}
// Last resort: if it's a single string value object, try to extract
if (!reply && Object.keys(data).length === 1) {
const value = Object.values(data)[0];
if (typeof value === 'string') {
reply = value;
}
}
// If still no reply but data exists, stringify it (for debugging)
if (!reply && Object.keys(data).length > 0) {
console.warn("n8n response structure not recognized, attempting to extract any string value");
// Try to find any string value in the object
const findStringValue = (obj: unknown): string | undefined => {
if (typeof obj === 'string' && obj.length > 0) return obj;
if (Array.isArray(obj) && obj.length > 0) {
return findStringValue(obj[0]);
}
if (obj && typeof obj === 'object' && obj !== null) {
const objRecord = obj as Record<string, unknown>;
for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) {
if (objRecord[key] && typeof objRecord[key] === 'string') {
return objRecord[key] as string;
}
}
// Recursively search
for (const value of Object.values(objRecord)) {
const found = findStringValue(value);
if (found) return found;
}
}
return undefined;
};
reply = findStringValue(data);
}
}
const data = await response.json();
console.log("n8n response data:", data);
const reply =
data.reply ||
data.message ||
data.response ||
data.text ||
data.content ||
(Array.isArray(data) && data[0]?.reply);
if (!reply) {
console.warn("n8n response missing reply field:", data);
// If n8n returns successfully but without a clear reply field,
// we might want to show the fallback or a generic error,
// but strictly speaking we shouldn't show "Couldn't process".
// Let's try to stringify the whole data if it's small, or use fallback.
if (data && typeof data === "object" && Object.keys(data).length > 0) {
// It returned something, but we don't know what field to use.
// Check for common n8n structure
if (data.output) return NextResponse.json({ reply: data.output });
if (data.data) return NextResponse.json({ reply: data.data });
}
throw new Error("Invalid response format from n8n");
console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2));
throw new Error("Invalid response format from n8n - no reply field found");
}
return NextResponse.json({
reply: reply,
});
} catch (error) {
// Decode HTML entities in the reply
const decodedReply = decodeHtmlEntitiesServer(String(reply));
return NextResponse.json({
reply: decodedReply,
});
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n webhook request timed out");
} else {
console.error("n8n webhook fetch error:", fetchError);
}
throw fetchError;
}
} catch (error: unknown) {
console.error("Chat API error:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? `configured (${process.env.N8N_WEBHOOK_URL})` : 'missing',
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
nodeEnv: process.env.NODE_ENV,
});
// Fallback to mock responses
// Now using the variable captured at the start

View File

@@ -13,6 +13,24 @@ import { NextRequest, NextResponse } from "next/server";
*/
export async function POST(req: NextRequest) {
try {
// Rate limiting for n8n endpoints
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 10, 60000)) { // 10 requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
// Require admin authentication for n8n endpoints
const { requireAdminAuth } = await import('@/lib/auth');
const authError = requireAdminAuth(req);
if (authError) {
return authError;
}
const body = await req.json();
const { projectId, regenerate = false } = body;

View File

@@ -1,49 +1,101 @@
// app/api/n8n/status/route.ts
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
// Cache für 30 Sekunden, damit wir n8n nicht zuspammen
export const revalidate = 30;
export async function GET() {
export async function GET(request: NextRequest) {
// Rate limiting for n8n status endpoint
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for status
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
try {
// Check if n8n webhook URL is configured
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
console.warn("N8N_WEBHOOK_URL not configured for status endpoint");
// Return fallback if n8n is not configured
return NextResponse.json({
status: { text: "offline", color: "gray" },
music: null,
gaming: null,
coding: null,
});
}
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const res = await fetch(
`${process.env.N8N_WEBHOOK_URL}/webhook/denshooter-71242/status?t=${Date.now()}`,
{
const statusUrl = `${n8nWebhookUrl}/webhook/denshooter-71242/status?t=${Date.now()}`;
console.log(`Fetching status from: ${statusUrl}`);
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
try {
const res = await fetch(statusUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
},
next: { revalidate: 30 },
},
);
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`n8n error: ${res.status}`);
clearTimeout(timeoutId);
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error');
console.error(`n8n status webhook failed: ${res.status}`, errorText);
throw new Error(`n8n error: ${res.status} - ${errorText}`);
}
const data = await res.json();
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
const statusData = Array.isArray(data) ? data[0] : data;
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
if (!statusData) {
throw new Error("Empty data received from n8n");
}
// Ensure coding object has proper structure
if (statusData.coding && typeof statusData.coding === "object") {
// Already properly formatted from n8n
} else if (statusData.coding === null || statusData.coding === undefined) {
// No coding data - keep as null
statusData.coding = null;
}
return NextResponse.json(statusData);
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n status webhook request timed out");
} else {
console.error("n8n status webhook fetch error:", fetchError);
}
throw fetchError;
}
const data = await res.json();
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
const statusData = Array.isArray(data) ? data[0] : data;
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
if (!statusData) {
throw new Error("Empty data received from n8n");
}
// Ensure coding object has proper structure
if (statusData.coding && typeof statusData.coding === "object") {
// Already properly formatted from n8n
} else if (statusData.coding === null || statusData.coding === undefined) {
// No coding data - keep as null
statusData.coding = null;
}
return NextResponse.json(statusData);
} catch (error) {
} catch (error: unknown) {
console.error("Error fetching n8n status:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
});
// Leeres Fallback-Objekt, damit die Seite nicht abstürzt
return NextResponse.json({
status: { text: "offline", color: "gray" },

View File

@@ -1,6 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import { motion, Variants } from "framer-motion";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
@@ -16,24 +15,18 @@ const staggerContainer: Variants = {
};
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 30 },
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 1,
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
const About = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const techStack = [
{
category: "Frontend & Mobile",
@@ -64,8 +57,6 @@ const About = () => {
{ icon: Activity, text: "Jogging to clear my mind and stay active" },
];
if (!mounted) return null;
return (
<section
id="about"

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,17 @@
"use client";
import dynamic from "next/dynamic";
import React from "react";
// Dynamically import the heavy framer-motion component on the client only
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs"), { ssr: false });
import React, { useEffect, useState } from "react";
import BackgroundBlobs from "@/components/BackgroundBlobs";
export default function BackgroundBlobsClient() {
// Avoid SSR/webpack bailout issues from `next/dynamic({ ssr:false })`
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <BackgroundBlobs />;
}

View File

@@ -51,6 +51,14 @@ export default function ChatWidget() {
}
}, [isOpen]);
// Helper function to decode HTML entities
const decodeHtmlEntities = (text: string): string => {
if (!text || typeof text !== "string") return text;
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
// Load messages from localStorage
useEffect(() => {
if (typeof window !== "undefined") {
@@ -61,6 +69,7 @@ export default function ChatWidget() {
setMessages(
parsed.map((m: Message) => ({
...m,
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
timestamp: new Date(m.timestamp),
})),
);
@@ -72,7 +81,7 @@ export default function ChatWidget() {
setMessages([
{
id: "welcome",
text: "Hi! I&apos;m Dennis&apos;s AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
@@ -120,14 +129,34 @@ export default function ChatWidget() {
});
if (!response.ok) {
throw new Error("Failed to get response");
const errorText = await response.text().catch(() => "Unknown error");
console.error("Chat API error:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new Error(
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
);
}
const data = await response.json();
// Log response for debugging (only in development)
if (process.env.NODE_ENV === "development") {
console.log("Chat API response:", data);
}
// Decode HTML entities in the reply
let replyText =
data.reply || "Sorry, I couldn't process that. Please try again.";
// Decode HTML entities client-side (double safety)
replyText = decodeHtmlEntities(replyText);
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: data.reply || "Sorry, I couldn't process that. Please try again.",
text: replyText,
sender: "bot",
timestamp: new Date(),
};
@@ -192,15 +221,15 @@ export default function ChatWidget() {
setIsOpen(true);
}
}}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-2xl hover:shadow-blue-500/50 hover:scale-110 transition-all duration-300 group cursor-pointer"
className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-stone-800/80 backdrop-blur-md text-stone-50 p-3.5 rounded-full shadow-[0_8px_20px_rgba(41,37,36,0.2)] hover:bg-stone-800 hover:scale-105 transition-all duration-300 group cursor-pointer border border-white/10 ring-1 ring-white/20"
aria-label="Open chat"
>
<MessageCircle size={20} />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
<MessageCircle size={24} />
<span className="absolute top-0 right-0 w-3 h-3 bg-stone-400 rounded-full animate-pulse shadow-sm border-2 border-stone-800" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Chat with AI assistant
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI
</span>
</motion.div>
)}
@@ -210,40 +239,43 @@ export default function ChatWidget() {
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 w-[300px] sm:w-[340px] md:w-[380px] max-w-[calc(100vw-2rem)] h-[450px] sm:h-[500px] md:h-[550px] max-h-[calc(100vh-10rem)] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-gray-200 dark:border-gray-800"
data-chat-widget
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
transition={{ type: "spring", damping: 30, stiffness: 400 }}
className="fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-[#fdfcf8]/30 backdrop-blur-2xl saturate-150 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.15)] flex flex-col overflow-hidden border border-white/40 ring-1 ring-white/50"
>
{/* Header */}
<div className="bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 md:p-4 flex items-center justify-between">
<div className="bg-white/20 backdrop-blur-md text-stone-800 p-4 flex items-center justify-between border-b border-white/20">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Sparkles size={20} />
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-stone-100/80 to-white/80 flex items-center justify-center ring-1 ring-white shadow-sm backdrop-blur-sm">
<Sparkles size={18} className="text-stone-600" />
</div>
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white" />
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-400 rounded-full border-2 border-white shadow-sm" />
</div>
<div>
<h3 className="font-bold text-sm">
Dennis&apos;s AI Assistant
<div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
Assistant
</h3>
<p className="text-xs text-white/80">Always online</p>
<p className="text-[11px] font-medium text-stone-500 truncate">
Powered by AI
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
onClick={clearChat}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-white/80 hover:text-white"
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
title="Clear conversation"
>
<Trash2 size={18} />
<Trash2 size={16} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
aria-label="Close chat"
>
<X size={20} />
@@ -252,7 +284,7 @@ export default function ChatWidget() {
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-3 md:space-y-4 bg-gray-50 dark:bg-gray-950">
<div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
{messages.map((message) => (
<motion.div
key={message.id}
@@ -261,20 +293,22 @@ export default function ChatWidget() {
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
className={`max-w-[85%] rounded-2xl px-4 py-3 backdrop-blur-md shadow-sm ${
message.sender === "user"
? "bg-gradient-to-br from-blue-500 to-purple-600 text-white"
: "bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700"
? "bg-stone-800/90 text-stone-50 ring-1 ring-white/10"
: "bg-white/70 text-stone-800 border border-white/50 ring-1 ring-white/40"
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">
<p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
message.sender === "user" ? "text-stone-50 font-light" : "text-stone-800 font-medium"
}`}>
{message.text}
</p>
<p
className={`text-[10px] mt-1 ${
className={`text-[10px] mt-1.5 ${
message.sender === "user"
? "text-white/60"
: "text-gray-500 dark:text-gray-400"
? "text-stone-400"
: "text-stone-500"
}`}
>
{message.timestamp.toLocaleTimeString([], {
@@ -293,11 +327,11 @@ export default function ChatWidget() {
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3">
<div className="flex gap-1">
<div className="bg-white/60 backdrop-blur-md border border-white/40 rounded-2xl px-4 py-3 shadow-sm ring-1 ring-white/40">
<div className="flex gap-1.5">
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
@@ -305,8 +339,8 @@ export default function ChatWidget() {
}}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
@@ -314,8 +348,8 @@ export default function ChatWidget() {
}}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
@@ -331,7 +365,7 @@ export default function ChatWidget() {
</div>
{/* Input */}
<div className="p-3 md:p-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="p-4 bg-white/20 backdrop-blur-xl border-t border-white/20">
<div className="flex gap-2">
<input
ref={inputRef}
@@ -341,37 +375,37 @@ export default function ChatWidget() {
onKeyPress={handleKeyPress}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 px-3 md:px-4 py-2 text-sm bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-full border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 px-4 py-3 text-sm bg-white/50 backdrop-blur-sm text-stone-800 rounded-xl border border-white/40 focus:outline-none focus:ring-2 focus:ring-stone-200/50 focus:border-stone-400/50 focus:bg-white/70 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-stone-500 transition-all shadow-inner"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-full hover:shadow-lg hover:scale-110 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
className="p-3 bg-stone-800 text-stone-50 rounded-xl hover:bg-stone-700 hover:shadow-lg hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={20} />
<Send size={18} />
)}
</button>
</div>
{/* Quick Actions */}
<div className="flex gap-2 mt-2 overflow-x-auto pb-1 scrollbar-hide">
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
{[
"What are Dennis&apos;s skills?",
"Tell me about his projects",
"How can I contact him?",
"Skills 🛠️",
"Projects 🚀",
"Contact 📧",
].map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInputValue(suggestion);
setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
inputRef.current?.focus();
}}
disabled={isLoading}
className="px-2 md:px-3 py-1 text-[10px] md:text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors whitespace-nowrap disabled:opacity-50 flex-shrink-0"
className="px-3 py-1.5 text-xs font-medium bg-white/40 backdrop-blur-md text-stone-700 rounded-lg hover:bg-white/60 hover:text-stone-900 border border-white/40 transition-all whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
>
{suggestion}
</button>

View File

@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
export function ClientOnly({ children }: { children: React.ReactNode }) {
export default function ClientOnly({ children }: { children: React.ReactNode }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {

View File

@@ -0,0 +1,52 @@
"use client";
import React, { useEffect, useState, Suspense, lazy } from "react";
import { usePathname } from "next/navigation";
import { ToastProvider } from "@/components/Toast";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
// Lazy load heavy components to avoid webpack issues
const BackgroundBlobs = lazy(() => import("@/components/BackgroundBlobs"));
const ChatWidget = lazy(() => import("./ChatWidget"));
export default function ClientProviders({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const [is404Page, setIs404Page] = useState(false);
useEffect(() => {
setMounted(true);
// Check if we're on a 404 page by looking for the data attribute
const check404 = () => {
if (typeof window !== "undefined") {
const has404Component = document.querySelector('[data-404-page]');
setIs404Page(!!has404Component);
}
};
// Check immediately and after a short delay
check404();
const timeout = setTimeout(check404, 100);
return () => clearTimeout(timeout);
}, []);
return (
<AnalyticsProvider>
<ToastProvider>
{mounted && (
<Suspense fallback={null}>
<BackgroundBlobs />
</Suspense>
)}
<div className="relative z-10">{children}</div>
{mounted && !is404Page && (
<Suspense fallback={null}>
<ChatWidget />
</Suspense>
)}
</ToastProvider>
</AnalyticsProvider>
);
}

View File

@@ -155,10 +155,10 @@ const Contact = () => {
<div className="max-w-7xl mx-auto">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="text-center mb-16"
>
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
@@ -173,10 +173,10 @@ const Contact = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Information */}
<motion.div
initial={{ opacity: 0, x: -30 }}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="space-y-8"
>
<div>
@@ -196,12 +196,12 @@ const Contact = () => {
<motion.a
key={info.title}
href={info.href}
initial={{ opacity: 0, x: -20 }}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.8,
delay: index * 0.15,
duration: 0.5,
delay: index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
@@ -226,10 +226,10 @@ const Contact = () => {
{/* Contact Form */}
<motion.div
initial={{ opacity: 0, x: 30 }}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
>
<h3 className="text-2xl font-bold text-gray-800 mb-6">

View File

@@ -30,10 +30,10 @@ const Footer = () => {
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
{/* Brand */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className="flex items-center space-x-3"
>
<motion.div
@@ -53,10 +53,10 @@ const Footer = () => {
{/* Social Links */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.05 }}
className="flex space-x-3"
>
{socialLinks.map((social) => (
@@ -77,10 +77,10 @@ const Footer = () => {
{/* Copyright */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.1 }}
className="flex items-center space-x-2 text-stone-400 text-sm"
>
<span>© {currentYear}</span>
@@ -96,10 +96,10 @@ const Footer = () => {
{/* Legal Links */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.15 }}
className="mt-8 pt-6 border-t border-stone-100 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
>
<div className="flex space-x-6 text-sm">
@@ -115,6 +115,13 @@ const Footer = () => {
>
Privacy Policy
</Link>
<Link
href="/404"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
title="Kernel Panic 404"
>
404
</Link>
</div>
<div className="text-xs text-stone-400 flex items-center space-x-1">

View File

@@ -12,7 +12,10 @@ const Header = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Use requestAnimationFrame to ensure smooth transition
requestAnimationFrame(() => {
setMounted(true);
});
}, []);
useEffect(() => {
@@ -41,17 +44,16 @@ const Header = () => {
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
];
if (!mounted) {
return null;
}
// Always render to prevent flash, but use opacity transition
return (
<>
<motion.header
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
initial={false}
animate={{ y: 0, opacity: mounted ? 1 : 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
style={{ opacity: mounted ? 1 : 0 }}
>
<div
className={`pointer-events-auto transition-all duration-500 ease-out ${
@@ -59,9 +61,9 @@ const Header = () => {
}`}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: "easeOut" }}
initial={false}
animate={{ opacity: mounted ? 1 : 0, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`
backdrop-blur-xl transition-all duration-500
${

View File

@@ -1,27 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
import Image from "next/image";
const Hero = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const features = [
{ icon: Code, text: "Next.js & Flutter" },
{ icon: Zap, text: "Docker Swarm & CI/CD" },
{ icon: Rocket, text: "Self-Hosted Infrastructure" },
];
if (!mounted) {
return null;
}
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10">
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
@@ -29,7 +18,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1.2, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
className="mb-12 flex justify-center relative z-20"
>
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
@@ -111,7 +100,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.8, ease: "easeOut" }}
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
>
<div className="px-6 py-2.5 rounded-full glass-panel text-stone-700 font-mono text-sm tracking-wider shadow-lg backdrop-blur-xl border border-white/50">
@@ -123,7 +112,7 @@ const Hero = () => {
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 1.2, duration: 0.8, ease: "easeOut" }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: 5 }}
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
>
@@ -132,7 +121,7 @@ const Hero = () => {
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 1.4, duration: 0.8, ease: "easeOut" }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: -5 }}
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
>
@@ -145,7 +134,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
className="mb-8 flex flex-col items-center justify-center relative"
>
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
@@ -160,7 +149,7 @@ const Hero = () => {
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.9, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
>
Student and passionate{" "}
@@ -182,7 +171,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 1.1, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-wrap justify-center gap-4 mb-12"
>
{features.map((feature, index) => (
@@ -191,8 +180,8 @@ const Hero = () => {
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.8,
delay: 1.3 + index * 0.15,
duration: 0.5,
delay: 0.5 + index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{ scale: 1.03, y: -3 }}
@@ -210,7 +199,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 1.6, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
>
<motion.a

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,12 @@ import Link from "next/link";
import Image from "next/image";
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 40 },
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.8,
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
@@ -44,11 +44,9 @@ interface Project {
}
const Projects = () => {
const [mounted, setMounted] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
setMounted(true);
const loadProjects = async () => {
try {
const response = await fetch(
@@ -58,7 +56,7 @@ const Projects = () => {
const data = await response.json();
setProjects(data.projects || []);
}
} catch (error) {
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error loading projects:", error);
}
@@ -67,8 +65,6 @@ const Projects = () => {
loadProjects();
}, []);
if (!mounted) return null;
return (
<section
id="projects"
@@ -78,7 +74,7 @@ const Projects = () => {
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
viewport={{ once: true, margin: "-50px" }}
variants={fadeInUp}
className="text-center mb-20"
>
@@ -94,7 +90,7 @@ const Projects = () => {
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
viewport={{ once: true, margin: "-50px" }}
variants={staggerContainer}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
@@ -103,23 +99,23 @@ const Projects = () => {
key={project.id}
variants={fadeInUp}
whileHover={{
y: -12,
transition: { duration: 0.5, ease: "easeOut" },
y: -8,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="group relative flex flex-col bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-700 ease-out border border-stone-100 hover:border-stone-200"
className="group relative flex flex-col bg-white/40 backdrop-blur-xl backdrop-saturate-150 rounded-2xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500 ease-out border border-white/50 ring-1 ring-white/30"
>
{/* Project Cover / Header */}
<div className="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-stone-50 to-stone-100">
<div className="relative aspect-[4/3] overflow-hidden bg-stone-100/50">
{project.imageUrl ? (
<Image
src={project.imageUrl}
alt={project.title}
fill
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-stone-100 to-stone-200 flex items-center justify-center p-8 group-hover:from-stone-50 group-hover:to-stone-100 transition-colors duration-700 ease-out">
<div className="w-full h-full border-2 border-dashed border-stone-300 rounded-xl flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-br from-stone-100/50 to-stone-200/50 flex items-center justify-center p-8 group-hover:from-stone-50/50 group-hover:to-stone-100/50 transition-colors duration-700 ease-out">
<div className="w-full h-full border-2 border-dashed border-stone-300/50 rounded-xl flex items-center justify-center">
<Layers className="text-stone-300 w-12 h-12" />
</div>
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint/10 via-transparent to-liquid-rose/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
@@ -127,13 +123,13 @@ const Projects = () => {
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-700 ease-out flex items-center justify-center gap-4 backdrop-blur-sm">
<div className="absolute inset-0 bg-stone-900/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px]">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg"
className="p-3 bg-white/90 backdrop-blur-md rounded-full text-stone-800 hover:scale-110 transition-all duration-300 ease-out shadow-lg hover:shadow-xl border border-white/50"
aria-label="GitHub"
>
<Github size={20} />
@@ -144,7 +140,7 @@ const Projects = () => {
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg"
className="p-3 bg-white/90 backdrop-blur-md rounded-full text-stone-800 hover:scale-110 transition-all duration-300 ease-out shadow-lg hover:shadow-xl border border-white/50"
aria-label="Live Demo"
>
<ExternalLink size={20} />
@@ -156,15 +152,15 @@ const Projects = () => {
{/* Content */}
<div className="flex flex-col flex-1 p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-700 transition-colors duration-500">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-700 transition-colors duration-300 tracking-tight">
{project.title}
</h3>
<span className="text-xs font-mono text-stone-400 bg-stone-100 px-2 py-1 rounded">
<span className="text-xs font-mono text-stone-500 bg-white/60 border border-stone-200/50 px-2 py-1 rounded backdrop-blur-sm">
{new Date(project.date).getFullYear()}
</span>
</div>
<p className="text-stone-700 text-sm leading-relaxed mb-6 line-clamp-3 flex-1">
<p className="text-stone-700 font-medium text-sm leading-relaxed mb-6 line-clamp-3 flex-1 opacity-90">
{project.description}
</p>
@@ -173,13 +169,13 @@ const Projects = () => {
{project.tags.slice(0, 3).map((tag, tIdx) => (
<span
key={`${project.id}-${tag}-${tIdx}`}
className="text-xs px-2.5 py-1 bg-stone-50 border border-stone-100 rounded-md text-stone-600 font-medium hover:bg-stone-100 hover:border-stone-200 transition-all duration-400 ease-out"
className="text-xs px-2.5 py-1 bg-white/50 border border-white/60 rounded-md text-stone-700 font-medium hover:bg-white/80 hover:border-white transition-all duration-300 ease-out shadow-sm"
>
{tag}
</span>
))}
{project.tags.length > 3 && (
<span className="text-xs px-2 py-1 text-stone-400">
<span className="text-xs px-2 py-1 text-stone-500 font-medium">
+ {project.tags.length - 3}
</span>
)}
@@ -187,12 +183,12 @@ const Projects = () => {
<Link
href={`/projects/${project.title.toLowerCase().replace(/\s+/g, "-")}`}
className="inline-flex items-center text-sm font-semibold text-stone-900 hover:gap-3 transition-all duration-500 ease-out group/link"
className="inline-flex items-center text-sm font-bold text-stone-800 hover:gap-3 transition-all duration-300 ease-out group/link"
>
Read more{" "}
<ArrowRight
size={16}
className="ml-1 transition-transform duration-500 ease-out group-hover/link:translate-x-2"
className="ml-1 transition-transform duration-300 ease-out group-hover/link:translate-x-1"
/>
</Link>
</div>

View File

@@ -0,0 +1,51 @@
"use client";
import React, { useEffect, useState } from "react";
// Lazy load providers to avoid webpack module resolution issues
const AnalyticsProvider = React.lazy(() =>
import("@/components/AnalyticsProvider").then((mod) => ({
default: mod.AnalyticsProvider,
}))
);
const ToastProvider = React.lazy(() =>
import("@/components/Toast").then((mod) => ({
default: mod.ToastProvider,
}))
);
const BackgroundBlobs = React.lazy(() =>
import("@/components/BackgroundBlobs")
);
const ChatWidget = React.lazy(() => import("./ChatWidget"));
export default function RootProviders({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="relative z-10">{children}</div>;
}
return (
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
<AnalyticsProvider>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</AnalyticsProvider>
</React.Suspense>
);
}

View File

@@ -251,19 +251,13 @@ function EditorPageContent() {
};
// Markdown components for react-markdown with security
const markdownComponents = {
a: ({
node: _node,
...props
}: {
node?: unknown;
href?: string;
children?: React.ReactNode;
}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markdownComponents: any = {
a: ({ node: _node, ...props }: { node?: unknown; href?: string; children?: React.ReactNode }) => {
// Validate URLs to prevent javascript: and data: protocols
const href = props.href || "";
const isSafe =
href && !href.startsWith("javascript:") && !href.startsWith("data:");
href && typeof href === 'string' && !href.startsWith("javascript:") && !href.startsWith("data:");
return (
<a
{...props}
@@ -277,18 +271,11 @@ function EditorPageContent() {
/>
);
},
img: ({
node: _node,
...props
}: {
node?: unknown;
src?: string;
alt?: string;
}) => {
img: ({ node: _node, ...props }: { node?: unknown; src?: string; alt?: string }) => {
// Validate image URLs
const src = props.src || "";
const src = props.src;
const isSafe =
src && !src.startsWith("javascript:") && !src.startsWith("data:");
src && typeof src === 'string' && !src.startsWith("javascript:") && !src.startsWith("data:");
// eslint-disable-next-line @next/next/no-img-element
return isSafe ? <img {...props} src={src} alt={props.alt || ""} /> : null;
},

View File

@@ -121,6 +121,14 @@ div {
background: #a8a29e;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Animations */
@keyframes float {
0%,

View File

@@ -2,11 +2,7 @@ import "./globals.css";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import React from "react";
import { ToastProvider } from "@/components/Toast";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ClientOnly } from "./components/ClientOnly";
import BackgroundBlobsClient from "./components/BackgroundBlobsClient";
import ChatWidget from "./components/ChatWidget";
import ClientProviders from "./components/ClientProviders";
const inter = Inter({
variable: "--font-inter",
@@ -29,16 +25,8 @@ export default function RootLayout({
<meta charSet="utf-8" />
<title>Dennis Konkol&#39;s Portfolio</title>
</head>
<body className={inter.variable}>
<AnalyticsProvider>
<ToastProvider>
<ClientOnly>
<BackgroundBlobsClient />
</ClientOnly>
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</AnalyticsProvider>
<body className={inter.variable} suppressHydrationWarning>
<ClientProviders>{children}</ClientProviders>
</body>
</html>
);

View File

@@ -1,22 +1,30 @@
import Link from "next/link";
"use client";
import { Suspense } from "react";
import dynamic from "next/dynamic";
// Dynamically import KernelPanic404 to avoid SSR issues
const KernelPanic404 = dynamic(() => import("./components/KernelPanic404"), {
ssr: false,
loading: () => (
<div className="flex items-center justify-center min-h-screen bg-black text-[#33ff00] font-mono">
<div>Loading terminal...</div>
</div>
),
});
export default function NotFound() {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-800">
<div className="text-center p-10 bg-white dark:bg-gray-700 rounded shadow-md">
<h1 className="text-6xl font-bold text-gray-800 dark:text-white">
404
</h1>
<p className="mt-4 text-xl text-gray-600 dark:text-gray-300">
Oops! The page you&#39;re looking for doesn&#39;t exist.
</p>
<Link
href="/"
className="mt-6 inline-block text-blue-500 hover:underline"
>
Go Back Home
</Link>
</div>
</div>
);
return (
<main className="min-h-screen w-full bg-black overflow-hidden relative">
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-black text-[#33ff00] font-mono">
<div>Loading terminal...</div>
</div>
}
>
<KernelPanic404 />
</Suspense>
</main>
);
}

View File

@@ -1,733 +0,0 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Save,
X,
Eye,
Settings,
Globe,
Github,
Image as ImageIcon,
Bold,
Italic,
List,
Quote,
Code,
Link2,
ListOrdered,
Underline,
Strikethrough,
Type,
Columns
} from 'lucide-react';
interface Project {
id: string;
title: string;
description: string;
content?: string;
category: string;
difficulty?: string;
tags?: string[];
featured: boolean;
published: boolean;
github?: string;
live?: string;
image?: string;
createdAt: string;
updatedAt: string;
}
interface GhostEditorProps {
isOpen: boolean;
onClose: () => void;
project?: Project | null;
onSave: (projectData: Partial<Project>) => void;
isCreating: boolean;
}
export const GhostEditor: React.FC<GhostEditorProps> = ({
isOpen,
onClose,
project,
onSave,
isCreating
}) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [content, setContent] = useState('');
const [category, setCategory] = useState('Web Development');
const [tags, setTags] = useState<string[]>([]);
const [github, setGithub] = useState('');
const [live, setLive] = useState('');
const [featured, setFeatured] = useState(false);
const [published, setPublished] = useState(false);
const [difficulty, setDifficulty] = useState('Intermediate');
// Editor UI state
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split');
const [showSettings, setShowSettings] = useState(false);
const [wordCount, setWordCount] = useState(0);
const [readingTime, setReadingTime] = useState(0);
const titleRef = useRef<HTMLTextAreaElement>(null);
const contentRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
useEffect(() => {
if (project && !isCreating) {
setTitle(project.title);
setDescription(project.description);
setContent(project.content || '');
setCategory(project.category);
setTags(project.tags || []);
setGithub(project.github || '');
setLive(project.live || '');
setFeatured(project.featured);
setPublished(project.published);
setDifficulty(project.difficulty || 'Intermediate');
} else {
// Reset for new project
setTitle('');
setDescription('');
setContent('');
setCategory('Web Development');
setTags([]);
setGithub('');
setLive('');
setFeatured(false);
setPublished(false);
setDifficulty('Intermediate');
}
}, [project, isCreating, isOpen]);
// Calculate word count and reading time
useEffect(() => {
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
setWordCount(words);
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
}, [content]);
const handleSave = () => {
const projectData = {
title,
description,
content,
category,
tags,
github,
live,
featured,
published,
difficulty
};
onSave(projectData);
};
const addTag = (tag: string) => {
if (tag.trim() && !tags.includes(tag.trim())) {
setTags([...tags, tag.trim()]);
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
if (!contentRef.current) return;
const textarea = contentRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = selectedText || content.substring(start, end);
let newText = '';
let cursorOffset = 0;
switch (syntax) {
case 'bold':
newText = `**${selection || 'bold text'}**`;
cursorOffset = selection ? newText.length : 2;
break;
case 'italic':
newText = `*${selection || 'italic text'}*`;
cursorOffset = selection ? newText.length : 1;
break;
case 'underline':
newText = `<u>${selection || 'underlined text'}</u>`;
cursorOffset = selection ? newText.length : 3;
break;
case 'strikethrough':
newText = `~~${selection || 'strikethrough text'}~~`;
cursorOffset = selection ? newText.length : 2;
break;
case 'heading1':
newText = `# ${selection || 'Heading 1'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'heading2':
newText = `## ${selection || 'Heading 2'}`;
cursorOffset = selection ? newText.length : 3;
break;
case 'heading3':
newText = `### ${selection || 'Heading 3'}`;
cursorOffset = selection ? newText.length : 4;
break;
case 'list':
newText = `- ${selection || 'List item'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'list-ordered':
newText = `1. ${selection || 'List item'}`;
cursorOffset = selection ? newText.length : 3;
break;
case 'quote':
newText = `> ${selection || 'Quote'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'code':
if (selection.includes('\n')) {
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
cursorOffset = selection ? newText.length : 4;
} else {
newText = `\`${selection || 'code'}\``;
cursorOffset = selection ? newText.length : 1;
}
break;
case 'link':
newText = `[${selection || 'link text'}](url)`;
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
break;
case 'image':
newText = `![${selection || 'alt text'}](image-url)`;
cursorOffset = selection ? newText.length - 11 : newText.length - 11;
break;
case 'divider':
newText = '\n---\n';
cursorOffset = newText.length;
break;
default:
return;
}
const newContent = content.substring(0, start) + newText + content.substring(end);
setContent(newContent);
// Focus and set cursor position
setTimeout(() => {
textarea.focus();
const newPosition = start + cursorOffset;
textarea.setSelectionRange(newPosition, newPosition);
}, 0);
}, [content]);
const autoResizeTextarea = (element: HTMLTextAreaElement) => {
element.style.height = 'auto';
element.style.height = element.scrollHeight + 'px';
};
// Render markdown preview
const renderMarkdownPreview = (markdown: string) => {
// Simple markdown renderer for preview
const html = markdown
// Headers
.replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>')
.replace(/^# (.*$)/gim, '<h1 class="text-3xl font-bold text-white mb-6 mt-8">$1</h1>')
// Bold and Italic
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
// Underline and Strikethrough
.replace(/<u>(.*?)<\/u>/g, '<u class="underline">$1</u>')
.replace(/~~(.*?)~~/g, '<del class="line-through opacity-75">$1</del>')
// Code
.replace(/```([^`]+)```/g, '<pre class="bg-gray-800 border border-gray-700 rounded-lg p-4 my-4 overflow-x-auto"><code class="text-green-400 font-mono text-sm">$1</code></pre>')
.replace(/`([^`]+)`/g, '<code class="bg-gray-800 border border-gray-700 rounded px-2 py-1 font-mono text-sm text-green-400">$1</code>')
// Lists
.replace(/^\- (.*$)/gim, '<li class="ml-4 mb-1">• $1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 mb-1 list-decimal">$1</li>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:text-blue-300 underline" target="_blank">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg my-4" />')
// Quotes
.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-blue-500 pl-4 py-2 my-4 bg-gray-800/50 italic text-gray-300">$1</blockquote>')
// Dividers
.replace(/^---$/gim, '<hr class="border-gray-600 my-8" />')
// Paragraphs
.replace(/\n\n/g, '</p><p class="mb-4 text-gray-200 leading-relaxed">')
.replace(/\n/g, '<br />');
return `<div class="prose prose-invert max-w-none"><p class="mb-4 text-gray-200 leading-relaxed">${html}</p></div>`;
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/95 backdrop-blur-sm z-50"
>
{/* Professional Ghost Editor */}
<div className="h-full flex flex-col bg-gray-900">
{/* Top Navigation Bar */}
<div className="flex items-center justify-between p-4 border-b border-gray-700 bg-gray-800">
<div className="flex items-center space-x-4">
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium text-white">
{isCreating ? 'New Project' : 'Editing Project'}
</span>
</div>
<div className="flex items-center space-x-2">
{published ? (
<span className="px-3 py-1 bg-green-600 text-white rounded-full text-sm font-medium">
Published
</span>
) : (
<span className="px-3 py-1 bg-gray-600 text-gray-300 rounded-full text-sm font-medium">
Draft
</span>
)}
{featured && (
<span className="px-3 py-1 bg-purple-600 text-white rounded-full text-sm font-medium">
Featured
</span>
)}
</div>
</div>
{/* View Mode Toggle */}
<div className="flex items-center space-x-2">
<div className="flex items-center bg-gray-700 rounded-lg p-1">
<button
onClick={() => setViewMode('edit')}
className={`p-2 rounded transition-colors ${
viewMode === 'edit' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Edit Mode"
>
<Type className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('split')}
className={`p-2 rounded transition-colors ${
viewMode === 'split' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Split View"
>
<Columns className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('preview')}
className={`p-2 rounded transition-colors ${
viewMode === 'preview' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
}`}
title="Preview Mode"
>
<Eye className="w-4 h-4" />
</button>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-colors ${
showSettings ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
<Settings className="w-5 h-5" />
</button>
<button
onClick={handleSave}
className="flex items-center space-x-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
>
<Save className="w-4 h-4" />
<span>Save</span>
</button>
</div>
</div>
{/* Rich Text Toolbar */}
<div className="flex items-center justify-between p-3 border-b border-gray-700 bg-gray-800/50">
<div className="flex items-center space-x-1">
{/* Text Formatting */}
<div className="flex items-center space-x-1 pr-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('bold')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Bold (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('italic')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Italic (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('underline')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Underline"
>
<Underline className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('strikethrough')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Strikethrough"
>
<Strikethrough className="w-4 h-4" />
</button>
</div>
{/* Headers */}
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('heading1')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 1"
>
H1
</button>
<button
onClick={() => insertMarkdown('heading2')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 2"
>
H2
</button>
<button
onClick={() => insertMarkdown('heading3')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 3"
>
H3
</button>
</div>
{/* Lists */}
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('list')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('list-ordered')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
</div>
{/* Insert Elements */}
<div className="flex items-center space-x-1 px-2">
<button
onClick={() => insertMarkdown('link')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Insert Link"
>
<Link2 className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('image')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Insert Image"
>
<ImageIcon className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('code')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Code Block"
>
<Code className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('quote')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Quote"
>
<Quote className="w-4 h-4" />
</button>
</div>
</div>
{/* Stats */}
<div className="flex items-center space-x-4 text-sm text-gray-400">
<span>{wordCount} words</span>
<span>{readingTime} min read</span>
</div>
</div>
{/* Main Editor Area */}
<div className="flex-1 flex overflow-hidden">
{/* Content Area */}
<div className="flex-1 flex">
{/* Editor Pane */}
{(viewMode === 'edit' || viewMode === 'split') && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} flex flex-col bg-gray-900`}>
{/* Title & Description */}
<div className="p-8 border-b border-gray-800">
<textarea
ref={titleRef}
value={title}
onChange={(e) => {
setTitle(e.target.value);
autoResizeTextarea(e.target);
}}
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
placeholder="Project title..."
className="w-full text-5xl font-bold text-white bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-tight mb-6"
rows={1}
/>
<textarea
value={description}
onChange={(e) => {
setDescription(e.target.value);
autoResizeTextarea(e.target);
}}
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
placeholder="Brief description of your project..."
className="w-full text-xl text-gray-300 bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-relaxed"
rows={1}
/>
</div>
{/* Content Editor */}
<div className="flex-1 p-8">
<textarea
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Start writing your story...
Use Markdown for formatting:
**Bold text** or *italic text*
# Large heading
## Medium heading
### Small heading
- Bullet points
1. Numbered lists
> Quotes
`code`
[Links](https://example.com)
![Images](image-url)"
className="w-full h-full text-lg text-white bg-transparent border-none outline-none placeholder-gray-600 resize-none font-mono leading-relaxed focus:ring-0"
style={{ minHeight: '500px' }}
/>
</div>
</div>
)}
{/* Preview Pane */}
{(viewMode === 'preview' || viewMode === 'split') && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-l border-gray-700' : 'w-full'} bg-gray-850 overflow-y-auto`}>
<div className="p-8">
{/* Preview Header */}
<div className="mb-8 border-b border-gray-700 pb-8">
<h1 className="text-5xl font-bold text-white mb-6 leading-tight">
{title || 'Project title...'}
</h1>
<p className="text-xl text-gray-300 leading-relaxed">
{description || 'Brief description of your project...'}
</p>
</div>
{/* Preview Content */}
<div
ref={previewRef}
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={{
__html: content ? renderMarkdownPreview(content) : '<p class="text-gray-500 italic">Start writing to see the preview...</p>'
}}
/>
</div>
</div>
)}
</div>
{/* Settings Sidebar */}
<AnimatePresence>
{showSettings && (
<motion.div
initial={{ x: 320 }}
animate={{ x: 0 }}
exit={{ x: 320 }}
className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col"
>
<div className="p-6 border-b border-gray-700">
<h3 className="text-lg font-semibold text-white">Project Settings</h3>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Status */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Publication</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-white">Published</span>
<button
onClick={() => setPublished(!published)}
className={`w-12 h-6 rounded-full transition-colors relative ${
published ? 'bg-green-600' : 'bg-gray-600'
}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
published ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-white">Featured</span>
<button
onClick={() => setFeatured(!featured)}
className={`w-12 h-6 rounded-full transition-colors relative ${
featured ? 'bg-purple-600' : 'bg-gray-600'
}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
featured ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
{/* Category & Difficulty */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Classification</h4>
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-300 text-sm mb-2">Difficulty</label>
<select
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{difficulties.map(diff => (
<option key={diff} value={diff}>{diff}</option>
))}
</select>
</div>
</div>
</div>
{/* Links */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">External Links</h4>
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">
<Github className="w-4 h-4 inline mr-1" />
GitHub Repository
</label>
<input
type="url"
value={github}
onChange={(e) => setGithub(e.target.value)}
placeholder="https://github.com/..."
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-gray-300 text-sm mb-2">
<Globe className="w-4 h-4 inline mr-1" />
Live Demo
</label>
<input
type="url"
value={live}
onChange={(e) => setLive(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Tags */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Tags</h4>
<div className="space-y-3">
<input
type="text"
placeholder="Add a tag and press Enter"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(e.currentTarget.value);
e.currentTarget.value = '';
}
}}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-full text-sm"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-blue-200 hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
</AnimatePresence>
);
};

View File

@@ -0,0 +1,31 @@
'use client';
import React from 'react';
import { obfuscateEmail, deobfuscateEmail } from '@/lib/email-obfuscate';
interface ObfuscatedEmailProps {
email: string;
children?: React.ReactNode;
className?: string;
}
export function ObfuscatedEmail({ email, children, className }: ObfuscatedEmailProps) {
const obfuscated = obfuscateEmail(email);
return (
<a
href="#"
data-email={obfuscated}
className={className || "obfuscated-email"}
onClick={(e) => {
e.preventDefault();
const link = e.currentTarget;
const decoded = deobfuscateEmail(obfuscated);
link.href = `mailto:${decoded}`;
window.location.href = link.href;
}}
>
{children || email}
</a>
);
}

View File

@@ -1,750 +0,0 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Save,
X,
Eye,
EyeOff,
Settings,
Globe,
Github,
Bold,
Italic,
List,
Quote,
Code,
Link2,
ListOrdered,
Underline,
Strikethrough,
GripVertical,
Image as ImageIcon
} from 'lucide-react';
interface Project {
id: string;
title: string;
description: string;
content?: string;
category: string;
difficulty?: string;
tags?: string[];
featured: boolean;
published: boolean;
github?: string;
live?: string;
image?: string;
createdAt: string;
updatedAt: string;
}
interface ResizableGhostEditorProps {
project?: Project | null;
onSave: (projectData: Partial<Project>) => void;
onClose: () => void;
isCreating: boolean;
}
export const ResizableGhostEditor: React.FC<ResizableGhostEditorProps> = ({
project,
onSave,
onClose,
isCreating
}) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [content, setContent] = useState('');
const [category, setCategory] = useState('Web Development');
const [tags, setTags] = useState<string[]>([]);
const [github, setGithub] = useState('');
const [live, setLive] = useState('');
const [featured, setFeatured] = useState(false);
const [published, setPublished] = useState(false);
const [difficulty, setDifficulty] = useState('Intermediate');
// Editor UI state
const [showPreview, setShowPreview] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [previewWidth, setPreviewWidth] = useState(50); // Percentage
const [wordCount, setWordCount] = useState(0);
const [readingTime, setReadingTime] = useState(0);
const [isResizing, setIsResizing] = useState(false);
const titleRef = useRef<HTMLTextAreaElement>(null);
const contentRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const resizeRef = useRef<HTMLDivElement>(null);
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
useEffect(() => {
if (project && !isCreating) {
setTitle(project.title);
setDescription(project.description);
setContent(project.content || '');
setCategory(project.category);
setTags(project.tags || []);
setGithub(project.github || '');
setLive(project.live || '');
setFeatured(project.featured);
setPublished(project.published);
setDifficulty(project.difficulty || 'Intermediate');
} else {
// Reset for new project
setTitle('');
setDescription('');
setContent('');
setCategory('Web Development');
setTags([]);
setGithub('');
setLive('');
setFeatured(false);
setPublished(false);
setDifficulty('Intermediate');
}
}, [project, isCreating]);
// Calculate word count and reading time
useEffect(() => {
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
setWordCount(words);
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
}, [content]);
// Handle resizing
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const containerWidth = window.innerWidth - (showSettings ? 320 : 0); // Account for settings sidebar
const newWidth = Math.max(20, Math.min(80, (e.clientX / containerWidth) * 100));
setPreviewWidth(100 - newWidth); // Invert since we're setting editor width
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, showSettings]);
const handleSave = () => {
const projectData = {
title,
description,
content,
category,
tags,
github,
live,
featured,
published,
difficulty
};
onSave(projectData);
};
const addTag = (tag: string) => {
if (tag.trim() && !tags.includes(tag.trim())) {
setTags([...tags, tag.trim()]);
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
if (!contentRef.current) return;
const textarea = contentRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = selectedText || content.substring(start, end);
let newText = '';
let cursorOffset = 0;
switch (syntax) {
case 'bold':
newText = `**${selection || 'bold text'}**`;
cursorOffset = selection ? newText.length : 2;
break;
case 'italic':
newText = `*${selection || 'italic text'}*`;
cursorOffset = selection ? newText.length : 1;
break;
case 'underline':
newText = `<u>${selection || 'underlined text'}</u>`;
cursorOffset = selection ? newText.length : 3;
break;
case 'strikethrough':
newText = `~~${selection || 'strikethrough text'}~~`;
cursorOffset = selection ? newText.length : 2;
break;
case 'heading1':
newText = `# ${selection || 'Heading 1'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'heading2':
newText = `## ${selection || 'Heading 2'}`;
cursorOffset = selection ? newText.length : 3;
break;
case 'heading3':
newText = `### ${selection || 'Heading 3'}`;
cursorOffset = selection ? newText.length : 4;
break;
case 'list':
newText = `- ${selection || 'List item'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'list-ordered':
newText = `1. ${selection || 'List item'}`;
cursorOffset = selection ? newText.length : 3;
break;
case 'quote':
newText = `> ${selection || 'Quote'}`;
cursorOffset = selection ? newText.length : 2;
break;
case 'code':
if (selection.includes('\n')) {
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
cursorOffset = selection ? newText.length : 4;
} else {
newText = `\`${selection || 'code'}\``;
cursorOffset = selection ? newText.length : 1;
}
break;
case 'link':
newText = `[${selection || 'link text'}](url)`;
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
break;
case 'image':
newText = `![${selection || 'alt text'}](image-url)`;
cursorOffset = selection ? newText.length - 11 : newText.length - 11;
break;
case 'divider':
newText = '\n---\n';
cursorOffset = newText.length;
break;
default:
return;
}
const newContent = content.substring(0, start) + newText + content.substring(end);
setContent(newContent);
// Focus and set cursor position
setTimeout(() => {
textarea.focus();
const newPosition = start + cursorOffset;
textarea.setSelectionRange(newPosition, newPosition);
}, 0);
}, [content]);
const autoResizeTextarea = (element: HTMLTextAreaElement) => {
element.style.height = 'auto';
element.style.height = element.scrollHeight + 'px';
};
// Enhanced markdown renderer with proper white text
const renderMarkdownPreview = (markdown: string) => {
const html = markdown
// Headers - WHITE TEXT
.replace(/^### (.*$)/gim, '<h3 class="text-xl font-semibold text-white mb-3 mt-6">$1</h3>')
.replace(/^## (.*$)/gim, '<h2 class="text-2xl font-bold text-white mb-4 mt-8">$1</h2>')
.replace(/^# (.*$)/gim, '<h1 class="text-3xl font-bold text-white mb-6 mt-8">$1</h1>')
// Bold and Italic - WHITE TEXT
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold text-white">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic text-white">$1</em>')
// Underline and Strikethrough - WHITE TEXT
.replace(/<u>(.*?)<\/u>/g, '<u class="underline text-white">$1</u>')
.replace(/~~(.*?)~~/g, '<del class="line-through opacity-75 text-white">$1</del>')
// Code
.replace(/```([^`]+)```/g, '<pre class="bg-gray-800 border border-gray-700 rounded-lg p-4 my-4 overflow-x-auto"><code class="text-green-400 font-mono text-sm">$1</code></pre>')
.replace(/`([^`]+)`/g, '<code class="bg-gray-800 border border-gray-700 rounded px-2 py-1 font-mono text-sm text-green-400">$1</code>')
// Lists - WHITE TEXT
.replace(/^\- (.*$)/gim, '<li class="ml-4 mb-1 text-white">• $1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 mb-1 list-decimal text-white">$1</li>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:text-blue-300 underline" target="_blank">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg my-4" />')
// Quotes - WHITE TEXT
.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-blue-500 pl-4 py-2 my-4 bg-gray-800/50 italic text-gray-300">$1</blockquote>')
// Dividers
.replace(/^---$/gim, '<hr class="border-gray-600 my-8" />')
// Paragraphs - WHITE TEXT
.replace(/\n\n/g, '</p><p class="mb-4 text-white leading-relaxed">')
.replace(/\n/g, '<br />');
return `<div class="prose prose-invert max-w-none text-white"><p class="mb-4 text-white leading-relaxed">${html}</p></div>`;
};
return (
<div className="min-h-screen animated-bg">
{/* Professional Ghost Editor */}
<div className="h-screen flex flex-col bg-gray-900/80 backdrop-blur-sm">
{/* Top Navigation Bar */}
<div className="flex items-center justify-between p-4 border-b border-gray-700 admin-glass-card">
<div className="flex items-center space-x-4">
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium text-white">
{isCreating ? 'New Project' : 'Editing Project'}
</span>
</div>
<div className="flex items-center space-x-2">
{published ? (
<span className="px-3 py-1 bg-green-600 text-white rounded-full text-sm font-medium">
Published
</span>
) : (
<span className="px-3 py-1 bg-gray-600 text-gray-300 rounded-full text-sm font-medium">
Draft
</span>
)}
{featured && (
<span className="px-3 py-1 bg-purple-600 text-white rounded-full text-sm font-medium">
Featured
</span>
)}
</div>
</div>
{/* Controls */}
<div className="flex items-center space-x-2">
{/* Preview Toggle */}
<button
onClick={() => setShowPreview(!showPreview)}
className={`p-2 rounded transition-colors ${
showPreview ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
title="Toggle Preview"
>
{showPreview ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-colors ${
showSettings ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
<Settings className="w-5 h-5" />
</button>
<button
onClick={handleSave}
className="flex items-center space-x-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium"
>
<Save className="w-4 h-4" />
<span>Save</span>
</button>
</div>
</div>
{/* Rich Text Toolbar */}
<div className="flex items-center justify-between p-3 border-b border-gray-700 admin-glass-light">
<div className="flex items-center space-x-1">
{/* Text Formatting */}
<div className="flex items-center space-x-1 pr-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('bold')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Bold (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('italic')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Italic (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('underline')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Underline"
>
<Underline className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('strikethrough')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Strikethrough"
>
<Strikethrough className="w-4 h-4" />
</button>
</div>
{/* Headers */}
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('heading1')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 1"
>
H1
</button>
<button
onClick={() => insertMarkdown('heading2')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 2"
>
H2
</button>
<button
onClick={() => insertMarkdown('heading3')}
className="px-2 py-1 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors text-sm font-bold"
title="Heading 3"
>
H3
</button>
</div>
{/* Lists */}
<div className="flex items-center space-x-1 px-2 border-r border-gray-600">
<button
onClick={() => insertMarkdown('list')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('list-ordered')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
</div>
{/* Insert Elements */}
<div className="flex items-center space-x-1 px-2">
<button
onClick={() => insertMarkdown('link')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Insert Link"
>
<Link2 className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('image')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Insert Image"
>
<ImageIcon className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('code')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Code Block"
>
<Code className="w-4 h-4" />
</button>
<button
onClick={() => insertMarkdown('quote')}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Quote"
>
<Quote className="w-4 h-4" />
</button>
</div>
</div>
{/* Stats */}
<div className="flex items-center space-x-4 text-sm text-gray-400">
<span>{wordCount} words</span>
<span>{readingTime} min read</span>
{showPreview && (
<span>Preview: {previewWidth}%</span>
)}
</div>
</div>
{/* Main Editor Area */}
<div className="flex-1 flex overflow-hidden">
{/* Content Area */}
<div className="flex-1 flex">
{/* Editor Pane */}
<div
className={`flex flex-col bg-gray-900/90 transition-all duration-300 ${
showPreview ? `w-[${100 - previewWidth}%]` : 'w-full'
}`}
style={{ width: showPreview ? `${100 - previewWidth}%` : '100%' }}
>
{/* Title & Description */}
<div className="p-8 border-b border-gray-800">
<textarea
ref={titleRef}
value={title}
onChange={(e) => {
setTitle(e.target.value);
autoResizeTextarea(e.target);
}}
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
placeholder="Project title..."
className="w-full text-5xl font-bold text-white bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-tight mb-6"
rows={1}
/>
<textarea
value={description}
onChange={(e) => {
setDescription(e.target.value);
autoResizeTextarea(e.target);
}}
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
placeholder="Brief description of your project..."
className="w-full text-xl text-gray-300 bg-transparent border-none outline-none placeholder-gray-500 resize-none overflow-hidden leading-relaxed"
rows={1}
/>
</div>
{/* Content Editor */}
<div className="flex-1 p-8">
<textarea
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Start writing your story...
Use Markdown for formatting:
**Bold text** or *italic text*
# Large heading
## Medium heading
### Small heading
- Bullet points
1. Numbered lists
> Quotes
`code`
[Links](https://example.com)
![Images](image-url)"
className="w-full h-full text-lg text-white bg-transparent border-none outline-none placeholder-gray-600 resize-none font-mono leading-relaxed focus:ring-0"
style={{ minHeight: '500px' }}
/>
</div>
</div>
{/* Resize Handle */}
{showPreview && (
<div
ref={resizeRef}
className="w-1 bg-gray-700 hover:bg-blue-500 cursor-col-resize flex items-center justify-center transition-colors group"
onMouseDown={() => setIsResizing(true)}
>
<GripVertical className="w-4 h-4 text-gray-600 group-hover:text-blue-400 transition-colors" />
</div>
)}
{/* Preview Pane */}
{showPreview && (
<div
className={`bg-gray-850 overflow-y-auto transition-all duration-300`}
style={{ width: `${previewWidth}%` }}
>
<div className="p-8">
{/* Preview Header */}
<div className="mb-8 border-b border-gray-700 pb-8">
<h1 className="text-5xl font-bold text-white mb-6 leading-tight">
{title || 'Project title...'}
</h1>
<p className="text-xl text-gray-300 leading-relaxed">
{description || 'Brief description of your project...'}
</p>
</div>
{/* Preview Content */}
<div
ref={previewRef}
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={{
__html: content ? renderMarkdownPreview(content) : '<p class="text-gray-500 italic">Start writing to see the preview...</p>'
}}
/>
</div>
</div>
)}
</div>
{/* Settings Sidebar */}
<AnimatePresence>
{showSettings && (
<motion.div
initial={{ x: 320 }}
animate={{ x: 0 }}
exit={{ x: 320 }}
className="w-80 admin-glass-card border-l border-gray-700 flex flex-col"
>
<div className="p-6 border-b border-gray-700">
<h3 className="text-lg font-semibold text-white">Project Settings</h3>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Status */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Publication</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-white">Published</span>
<button
onClick={() => setPublished(!published)}
className={`w-12 h-6 rounded-full transition-colors relative ${
published ? 'bg-green-600' : 'bg-gray-600'
}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
published ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-white">Featured</span>
<button
onClick={() => setFeatured(!featured)}
className={`w-12 h-6 rounded-full transition-colors relative ${
featured ? 'bg-purple-600' : 'bg-gray-600'
}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-transform absolute top-1 ${
featured ? 'translate-x-7' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
{/* Category & Difficulty */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Classification</h4>
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-300 text-sm mb-2">Difficulty</label>
<select
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{difficulties.map(diff => (
<option key={diff} value={diff}>{diff}</option>
))}
</select>
</div>
</div>
</div>
{/* Links */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">External Links</h4>
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">
<Github className="w-4 h-4 inline mr-1" />
GitHub Repository
</label>
<input
type="url"
value={github}
onChange={(e) => setGithub(e.target.value)}
placeholder="https://github.com/..."
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-gray-300 text-sm mb-2">
<Globe className="w-4 h-4 inline mr-1" />
Live Demo
</label>
<input
type="url"
value={live}
onChange={(e) => setLive(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Tags */}
<div>
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">Tags</h4>
<div className="space-y-3">
<input
type="text"
placeholder="Add a tag and press Enter"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(e.currentTarget.value);
e.currentTarget.value = '';
}
}}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-full text-sm"
>
<span>{tag}</span>
<button
onClick={() => removeTag(tag)}
className="text-blue-200 hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};

View File

@@ -139,10 +139,27 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | undefined>(undefined);
// No-op fallback for SSR or when outside provider
const noopToast: ToastContextType = {
addToast: () => {},
showToast: () => {},
showSuccess: () => {},
showError: () => {},
showWarning: () => {},
showInfo: () => {},
showEmailSent: () => {},
showEmailError: () => {},
showProjectSaved: () => {},
showProjectDeleted: () => {},
showImportSuccess: () => {},
showImportError: () => {},
};
export const useToast = () => {
const context = useContext(ToastContext);
// Return no-op fallback during SSR or if used outside provider
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
return noopToast;
}
return context;
};

View File

@@ -1,7 +1,5 @@
# Production Docker Compose configuration for dk0.dev
# Optimized for production deployment
version: '3.8'
# Optimized for production deployment with zero-downtime support
services:
portfolio:
@@ -21,6 +19,9 @@ services:
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
- LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
- N8N_API_KEY=${N8N_API_KEY:-}
volumes:
- portfolio_data:/app/.next/cache
networks:

View File

@@ -2,30 +2,31 @@
# Deploys automatically on dev/main branch
# Uses different ports and container names to avoid conflicts with production
version: '3.8'
services:
portfolio-staging:
image: portfolio-app:staging
container_name: portfolio-app-staging
restart: unless-stopped
ports:
- "3001:3000" # Different port from production (3000)
- "3002:3000" # Different port from production (3000) - using 3002 to avoid conflicts
environment:
- NODE_ENV=staging
- DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public
- REDIS_URL=redis://redis-staging:6379
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dev.dk0.dev}
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
- MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:staging_password}
- LOG_LEVEL=debug
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
volumes:
- portfolio_staging_data:/app/.next/cache
networks:
- portfolio_staging_net
- proxy
depends_on:
postgres-staging:
condition: service_healthy
@@ -59,7 +60,7 @@ services:
networks:
- portfolio_staging_net
ports:
- "5433:5432" # Different port from production (5432)
- "5434:5432" # Different port from production (5432) - using 5434 to avoid conflicts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_staging_db"]
interval: 10s
@@ -85,7 +86,7 @@ services:
networks:
- portfolio_staging_net
ports:
- "6380:6379" # Different port from production (6379)
- "6381:6379" # Different port from production (6379) - using 6381 to avoid conflicts
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@@ -112,3 +113,5 @@ volumes:
networks:
portfolio_staging_net:
driver: bridge
proxy:
external: true

View File

@@ -0,0 +1,314 @@
# 🚀 Kernel Panic 404 Projekt auf Production hinzufügen
## Übersicht
Das "Kernel Panic 404 - Interactive Terminal" Projekt wurde bereits zum Seed-Script hinzugefügt, aber um es auf Production zu bekommen, gibt es mehrere Optionen.
---
## Option 1: Über den Editor (Empfohlen) ⭐
### Schritt 1: Editor öffnen
1. Gehe zu `https://dk0.dev/editor` (oder `https://dev.dk0.dev/editor` für Staging)
2. Logge dich mit Admin-Credentials ein
### Schritt 2: Neues Projekt erstellen
1. Klicke auf "New Project" oder gehe direkt zu `/editor`
2. Fülle die Felder aus:
**Grunddaten:**
- **Title**: `Kernel Panic 404 - Interactive Terminal`
- **Description**: `An interactive terminal-style 404 page with a fully functional command line, file system navigation, and Easter eggs inspired by Stranger Things, Mr. Robot, and Hitchhiker's Guide to the Galaxy.`
- **Category**: `Web Development`
- **Tags**: `Next.js`, `React`, `TypeScript`, `Terminal`, `404`, `Interactive`, `Easter Eggs`
- **Featured**: ✅ (Checkbox aktivieren)
- **Published**: ✅ (Checkbox aktivieren)
**Content (Markdown):**
```markdown
# Kernel Panic 404 - Interactive Terminal
An immersive, retro-style 404 page that transforms the error experience into an interactive terminal adventure.
## 🎯 Purpose
Instead of showing a boring "Page Not Found" message, visitors are greeted with a fully functional terminal emulator where they can explore a virtual file system, run commands, and discover hidden Easter eggs.
## 🚀 Features
- **Interactive Terminal**: Full command-line interface with command history
- **Virtual File System**: Navigate directories, read files, and explore hidden content
- **CRT Monitor Effects**: Authentic retro computer terminal aesthetics
- **Easter Eggs**: Hidden references to pop culture (Stranger Things, Mr. Robot, Hitchhiker's Guide)
- **Command Autocomplete**: Tab completion for commands and file paths
- **Audio Synthesis**: Sound effects for key presses and special events
- **Visual Effects**: Glitch effects, color shifts, and screen distortions
## 🛠️ Technologies Used
- Next.js 15 (App Router)
- React (Client Components)
- TypeScript
- CSS Animations
- Web Audio API
- Framer Motion (for effects)
## 💻 Available Commands
### System Commands
- \`ls\`, \`cd\`, \`cat\`, \`grep\`, \`find\`, \`pwd\`
- \`whoami\`, \`uname\`, \`date\`, \`uptime\`
- \`clear\`, \`history\`, \`help\`
### Easter Egg Commands
- \`hawkins\` / \`011\` / \`eleven\` - Enter the Upside Down
- \`fsociety\` / \`elliot\` / \`bonsoir\` - Mr. Robot mode
- \`42\` / \`answer\` - Deep Thought calculation
- \`rm -rf /\` - Trigger kernel panic
## 🎨 Design Features
- **CRT Monitor Aesthetics**: Scanlines, phosphor glow, authentic terminal colors
- **Retro Typography**: Monospace font with terminal-style appearance
- **Interactive Elements**: Fully functional command line with history navigation
- **Visual Effects**: Screen glitches, color shifts, and distortion effects
## 🔍 Hidden Content
The file system contains hidden clues and references:
- \`/var/log/syslog\` - Contains hints about Easter eggs
- \`~/.bash_history\` - Shows previous commands
- \`~/readme.txt\` - Welcome message and hints
## 💡 What I Learned
- Building interactive terminal emulators in React
- Web Audio API for sound synthesis
- Complex state management for file systems
- CSS animations for retro effects
- Creating engaging error pages
## 🔮 Future Enhancements
- More Easter eggs and hidden commands
- Additional visual effects
- Sound theme variations
- More complex file system structures
- Network commands (ping, curl, etc.)
## 🎮 Try It Out
Visit any non-existent page on the site to see the terminal in action. Or click the "404" link in the footer!
**Try these commands:**
- \`ls -la\` - List all files including hidden ones
- \`cat readme.txt\` - Read the welcome message
- \`cd /var/log\` - Navigate to system logs
- \`hawkins\` - Enter the Upside Down mode
- \`42\` - Get the answer to everything
```
**Links:**
- **Live URL**: `/404`
- **GitHub**: (optional, leer lassen)
3. Klicke auf "Save"
---
## Option 2: Direkt über die API
### Mit curl:
```bash
curl -X POST https://dk0.dev/api/projects \
-H "Content-Type: application/json" \
-H "x-admin-request: true" \
-u "admin:your_password" \
-d '{
"title": "Kernel Panic 404 - Interactive Terminal",
"description": "An interactive terminal-style 404 page with a fully functional command line, file system navigation, and Easter eggs inspired by Stranger Things, Mr. Robot, and Hitchhiker's Guide to the Galaxy.",
"content": "# Kernel Panic 404...",
"category": "Web Development",
"tags": ["Next.js", "React", "TypeScript", "Terminal", "404", "Interactive", "Easter Eggs"],
"featured": true,
"published": true,
"live": "/404",
"date": "2025-01-09",
"difficulty": "INTERMEDIATE",
"timeToComplete": "1-2 weeks",
"technologies": ["Next.js", "React", "TypeScript", "CSS", "Web Audio API"],
"challenges": [
"Terminal emulator implementation",
"File system state management",
"Command parsing and execution"
],
"lessonsLearned": [
"Building interactive UIs",
"Web Audio API usage",
"Complex state management"
],
"futureImprovements": [
"More Easter eggs",
"Additional visual effects",
"Sound theme variations"
],
"colorScheme": "Retro terminal green on black",
"accessibility": true,
"performance": {
"lighthouse": 0,
"bundleSize": "0KB",
"loadTime": "0s"
},
"analytics": {
"views": 0,
"likes": 0,
"shares": 0
}
}'
```
---
## Option 3: Über die Datenbank (SQL)
### Schritt 1: Verbinde zur Production-Datenbank
```bash
# Wenn du Zugriff auf den Production-Container hast:
docker exec -it portfolio-postgres psql -U portfolio_user -d portfolio_db
```
### Schritt 2: Projekt einfügen
```sql
INSERT INTO project (
title,
description,
content,
category,
tags,
featured,
published,
live,
date,
difficulty,
"timeToComplete",
technologies,
challenges,
"lessonsLearned",
"futureImprovements",
"colorScheme",
accessibility,
performance,
analytics,
"createdAt",
"updatedAt"
) VALUES (
'Kernel Panic 404 - Interactive Terminal',
'An interactive terminal-style 404 page with a fully functional command line, file system navigation, and Easter eggs inspired by Stranger Things, Mr. Robot, and Hitchhiker's Guide to the Galaxy.',
'# Kernel Panic 404 - Interactive Terminal...',
'Web Development',
ARRAY['Next.js', 'React', 'TypeScript', 'Terminal', '404', 'Interactive', 'Easter Eggs'],
true,
true,
'/404',
'2025-01-09',
'INTERMEDIATE',
'1-2 weeks',
ARRAY['Next.js', 'React', 'TypeScript', 'CSS', 'Web Audio API'],
ARRAY['Terminal emulator implementation', 'File system state management', 'Command parsing and execution'],
ARRAY['Building interactive UIs', 'Web Audio API usage', 'Complex state management'],
ARRAY['More Easter eggs', 'Additional visual effects', 'Sound theme variations'],
'Retro terminal green on black',
true,
'{"lighthouse": 0, "bundleSize": "0KB", "loadTime": "0s"}'::json,
'{"views": 0, "likes": 0, "shares": 0}'::json,
NOW(),
NOW()
);
```
---
## Option 4: Seed-Script ausführen (⚠️ Achtung: Löscht alle Projekte!)
**WARNUNG**: Das Seed-Script löscht alle bestehenden Projekte und erstellt sie neu!
```bash
# Im Production-Container:
docker exec -it portfolio-app npm run seed
# Oder lokal, wenn du Zugriff auf die Production-DB hast:
DATABASE_URL="postgresql://portfolio_user:portfolio_pass@your-production-db:5432/portfolio_db" npm run seed
```
---
## ✅ Verifizierung
Nach dem Hinzufügen des Projekts:
1. **Projekte-Seite prüfen**: Gehe zu `https://dk0.dev/projects`
- Das Projekt sollte in der Liste erscheinen
- Es sollte als "Featured" markiert sein
2. **Projekt-Detail-Seite prüfen**:
- Klicke auf das Projekt
- Die Detail-Seite sollte alle Informationen anzeigen
3. **404-Seite testen**:
- Gehe zu `https://dk0.dev/404` oder einer nicht existierenden Route
- Die Terminal-404-Seite sollte erscheinen
4. **Footer-Link prüfen**:
- Scrolle zum Footer
- Der "404" Link sollte sichtbar sein
---
## 🔧 Troubleshooting
### Problem: Projekt erscheint nicht
**Lösung:**
1. Prüfe, ob `published: true` gesetzt ist
2. Prüfe die Datenbank direkt: `SELECT * FROM project WHERE title LIKE '%404%';`
3. Prüfe die Browser-Konsole auf Fehler
4. Leere den Cache: `docker exec portfolio-app npm run build`
### Problem: Editor funktioniert nicht
**Lösung:**
1. Prüfe, ob du eingeloggt bist (`/manage`)
2. Prüfe die Admin-Credentials in den Environment Variables
3. Prüfe die Browser-Konsole auf Fehler
### Problem: 404-Seite zeigt nicht die Terminal-Seite
**Lösung:**
1. Prüfe, ob `app/not-found.tsx` existiert
2. Prüfe, ob `app/components/KernelPanic404.tsx` existiert
3. Baue die App neu: `npm run build`
4. Prüfe die Browser-Konsole auf Fehler
---
## 📝 Quick Reference
**Editor URL**: `https://dk0.dev/editor`
**Admin Dashboard**: `https://dk0.dev/manage`
**404-Seite**: `https://dk0.dev/404` oder jede nicht existierende Route
**Projekte-Seite**: `https://dk0.dev/projects`
---
## 🎯 Empfohlener Workflow
1. **Lokal testen**: Füge das Projekt lokal hinzu und teste es
2. **Auf Staging deployen**: Teste auf `dev.dk0.dev`
3. **Auf Production deployen**: Wenn alles funktioniert, auf `dk0.dev` deployen
---
Happy coding! 🚀

View File

@@ -0,0 +1,146 @@
# 🔧 n8n Chat Setup für Production
## Problem: AI Chat funktioniert nicht auf Production
Wenn der AI Chat auf Production nicht funktioniert, liegt es meist an fehlenden Environment-Variablen.
## ✅ Lösung: Environment-Variablen in Gitea setzen
### Schritt 1: Gehe zu Gitea Repository Settings
1. Öffne: `https://git.dk0.dev/denshooter/portfolio/settings`
2. Klicke auf **"Variables"** im linken Menü
### Schritt 2: Setze die n8n Variables
#### Variables (öffentlich):
- **Name:** `N8N_WEBHOOK_URL`
- **Value:** `https://n8n.dk0.dev`
- **Protect:** ✅ (optional)
- **Name:** `N8N_API_KEY` (optional, falls dein n8n eine API-Key benötigt)
- **Value:** Dein n8n API Key
- **Protect:** ✅
#### Secrets (verschlüsselt):
- **Name:** `N8N_SECRET_TOKEN`
- **Value:** Dein n8n Secret Token (falls du einen verwendest)
- **Protect:** ✅
### Schritt 3: Prüfe die n8n Webhook URL
Stelle sicher, dass dein n8n Workflow:
1. **Aktiv** ist (Toggle oben rechts)
2. Den Webhook-Pfad `/webhook/chat` hat
3. Die vollständige URL ist: `https://n8n.dk0.dev/webhook/chat`
### Schritt 4: Teste die Webhook-URL direkt
```bash
curl -X POST https://n8n.dk0.dev/webhook/chat \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
```
Wenn du einen `N8N_SECRET_TOKEN` verwendest:
```bash
curl -X POST https://n8n.dk0.dev/webhook/chat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SECRET_TOKEN" \
-d '{"message": "Hello"}'
```
### Schritt 5: Deploy neu starten
Nach dem Setzen der Variablen:
1. Push einen Commit zum `production` Branch
2. Oder manuell den Workflow in Gitea starten
3. Die Variablen werden automatisch an den Container übergeben
## 🔍 Debugging
### Prüfe Container-Logs
```bash
docker logs portfolio-app | grep -i n8n
```
### Prüfe Environment-Variablen im Container
```bash
docker exec portfolio-app env | grep N8N
```
Sollte zeigen:
```
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=*** (wenn gesetzt)
N8N_API_KEY=*** (wenn gesetzt)
```
### Prüfe Browser-Konsole
Öffne die Browser-Konsole (F12) und schaue nach Fehlern beim Senden einer Chat-Nachricht.
### Prüfe Server-Logs
Die Chat-API loggt jetzt detaillierter:
- Ob `N8N_WEBHOOK_URL` gesetzt ist
- Die vollständige Webhook-URL (ohne Credentials)
- HTTP-Fehler mit Status-Codes
## 🐛 Häufige Probleme
### Problem 1: "N8N_WEBHOOK_URL not configured"
**Lösung:** Variable in Gitea setzen (siehe Schritt 2)
### Problem 2: "n8n webhook failed: 404"
**Lösung:**
- Prüfe, ob der n8n Workflow aktiv ist
- Prüfe, ob der Webhook-Pfad `/webhook/chat` ist
- Teste die URL direkt mit curl
### Problem 3: "n8n webhook failed: 401/403"
**Lösung:**
- Prüfe, ob `N8N_SECRET_TOKEN` in Gitea Secrets gesetzt ist
- Prüfe, ob der Token im n8n Workflow korrekt konfiguriert ist
### Problem 4: "Connection timeout"
**Lösung:**
- Prüfe, ob n8n erreichbar ist: `curl https://n8n.dk0.dev`
- Prüfe Firewall-Regeln
- Prüfe, ob n8n im gleichen Netzwerk ist (Docker Network)
## 📝 Aktuelle Konfiguration
Die Chat-API verwendet:
- **Webhook URL:** `${N8N_WEBHOOK_URL}/webhook/chat`
- **Authentication:**
- `Authorization: Bearer ${N8N_SECRET_TOKEN}` (wenn gesetzt)
- `X-API-Key: ${N8N_API_KEY}` (wenn gesetzt)
- **Timeout:** 30 Sekunden
- **Fallback:** Wenn n8n nicht erreichbar ist, werden intelligente Fallback-Antworten verwendet
## ✅ Checkliste
- [ ] `N8N_WEBHOOK_URL` in Gitea Variables gesetzt
- [ ] `N8N_SECRET_TOKEN` in Gitea Secrets gesetzt (falls benötigt)
- [ ] `N8N_API_KEY` in Gitea Variables gesetzt (falls benötigt)
- [ ] n8n Workflow ist aktiv
- [ ] Webhook-Pfad ist `/webhook/chat`
- [ ] Container wurde nach dem Setzen der Variablen neu deployed
- [ ] Container-Logs zeigen keine n8n-Fehler
## 🚀 Nach dem Setup
Nach dem Setzen der Variablen und einem neuen Deployment sollte der Chat funktionieren. Falls nicht:
1. Prüfe die Container-Logs: `docker logs portfolio-app`
2. Prüfe die Browser-Konsole für Client-seitige Fehler
3. Teste die n8n Webhook-URL direkt mit curl
4. Prüfe, ob die Environment-Variablen im Container gesetzt sind

View File

@@ -0,0 +1,312 @@
# 📝 n8n Status-Text ändern - Anleitung
## Übersicht
Der Status-Text (z.B. "dnd", "online", "offline", "away") wird von deinem n8n Workflow zurückgegeben und auf der Website angezeigt.
---
## 🔍 Wo kommt der Status-Text her?
Der Status-Text kommt von deinem n8n Webhook:
- **Webhook URL**: `/webhook/denshooter-71242/status`
- **Methode**: GET
- **Antwort-Format**: JSON mit `status: { text: string, color: string }`
---
## 🎯 Option 1: Status-Text direkt im n8n Workflow ändern
### Schritt 1: Workflow finden
1. Öffne dein n8n Dashboard
2. Suche nach dem Workflow, der den Status zurückgibt
3. Der Workflow sollte einen **Webhook** oder **HTTP Response** Node haben
### Schritt 2: Status-Text im Workflow anpassen
**Beispiel: Function Node oder Set Node**
```javascript
// In einem Function Node oder Set Node
return [{
json: {
status: {
text: "dnd", // ← Hier kannst du den Text ändern
color: "red" // ← Und hier die Farbe (green, yellow, red, gray)
},
music: { /* ... */ },
gaming: { /* ... */ },
coding: { /* ... */ }
}
}];
```
**Mögliche Status-Texte:**
- `"online"` → Wird als "Online" angezeigt
- `"offline"` → Wird als "Offline" angezeigt
- `"away"` → Wird als "Abwesend" angezeigt
- `"dnd"` → Wird als "Nicht stören" angezeigt
- `"custom"` → Wird als "Custom" angezeigt (oder beliebiger Text)
**Mögliche Farben:**
- `"green"` → Grüner Punkt
- `"yellow"` → Gelber Punkt
- `"red"` → Roter Punkt
- `"gray"` → Grauer Punkt
---
## 🎯 Option 2: Status über Datenbank setzen
Falls dein n8n Workflow die Datenbank liest, kannst du den Status dort setzen:
### Schritt 1: Datenbank-Update
```sql
-- Status über status_mood und status_message setzen
UPDATE activity_status
SET
status_mood = '🔴', -- Emoji für den Status
status_message = 'Do Not Disturb - In Deep Work'
WHERE id = 1;
```
### Schritt 2: n8n Workflow anpassen
Dein n8n Workflow muss dann die Datenbank-Daten in das richtige Format umwandeln:
```javascript
// Function Node: Convert Database to API Format
const dbData = items[0].json;
// Bestimme Status-Text basierend auf status_mood oder status_message
let statusText = "online";
let statusColor = "green";
if (dbData.status_message?.toLowerCase().includes("dnd") ||
dbData.status_message?.toLowerCase().includes("do not disturb")) {
statusText = "dnd";
statusColor = "red";
} else if (dbData.status_message?.toLowerCase().includes("away") ||
dbData.status_message?.toLowerCase().includes("abwesend")) {
statusText = "away";
statusColor = "yellow";
} else if (dbData.status_message?.toLowerCase().includes("offline")) {
statusText = "offline";
statusColor = "gray";
}
return [{
json: {
status: {
text: statusText,
color: statusColor
},
// ... rest of data
}
}];
```
---
## 🎯 Option 3: Status über Webhook setzen
Erstelle einen separaten n8n Workflow, um den Status manuell zu ändern:
### Workflow: "Set Status"
**Node 1: Webhook (POST)**
- Path: `set-status`
- Method: POST
**Node 2: Function Node**
```javascript
// Parse incoming data
const { statusText, statusColor } = items[0].json.body;
// Update database
return [{
json: {
query: "UPDATE activity_status SET status_message = $1 WHERE id = 1",
params: [statusText]
}
}];
```
**Node 3: PostgreSQL Node**
- Operation: Execute Query
- Query: `={{$json.query}}`
- Parameters: `={{$json.params}}`
**Node 4: Respond to Webhook**
```json
{
"success": true,
"message": "Status updated"
}
```
**Verwendung:**
```bash
curl -X POST https://your-n8n.com/webhook/set-status \
-H "Content-Type: application/json" \
-d '{"statusText": "dnd", "statusColor": "red"}'
```
---
## 🎨 Status-Text Übersetzungen in der Website
Die Website übersetzt folgende Status-Texte automatisch:
| n8n Status-Text | Website-Anzeige |
|----------------|-----------------|
| `"dnd"` | "Nicht stören" |
| `"online"` | "Online" |
| `"offline"` | "Offline" |
| `"away"` | "Abwesend" |
| Andere | Wird 1:1 angezeigt |
**Wo wird übersetzt?**
- Datei: `app/components/ActivityFeed.tsx`
- Zeile: ~1559-1567
Falls du einen neuen Status-Text hinzufügen willst, musst du die Übersetzung dort hinzufügen.
---
## 🔧 Praktische Beispiele
### Beispiel 1: "Focus Mode" Status
**In n8n Function Node:**
```javascript
return [{
json: {
status: {
text: "focus", // Neuer Status
color: "red"
},
// ... rest
}
}];
```
**In ActivityFeed.tsx hinzufügen:**
```typescript
{data.status.text === "dnd"
? "Nicht stören"
: data.status.text === "focus" // ← Neue Übersetzung
? "Fokus-Modus"
: data.status.text === "online"
? "Online"
// ... rest
}
```
### Beispiel 2: Status basierend auf Uhrzeit
**In n8n Function Node:**
```javascript
const hour = new Date().getHours();
let statusText = "online";
let statusColor = "green";
if (hour >= 22 || hour < 7) {
statusText = "dnd";
statusColor = "red";
} else if (hour >= 9 && hour < 17) {
statusText = "online";
statusColor = "green";
} else {
statusText = "away";
statusColor = "yellow";
}
return [{
json: {
status: { text: statusText, color: statusColor },
// ... rest
}
}];
```
### Beispiel 3: Status über Discord Bot
**Discord Command:**
```
!status dnd
!status online
!status away
```
**n8n Workflow:**
```javascript
// Parse Discord command
const command = items[0].json.content.split(' ')[1]; // "dnd", "online", etc.
return [{
json: {
status: {
text: command,
color: command === "dnd" ? "red" : command === "away" ? "yellow" : "green"
}
}
}];
```
---
## 🐛 Troubleshooting
### Problem: Status-Text ändert sich nicht
**Lösung:**
1. Prüfe, ob der n8n Workflow aktiviert ist
2. Prüfe die Webhook-URL in `app/api/n8n/status/route.ts`
3. Prüfe die Browser-Konsole auf Fehler
4. Prüfe n8n Execution Logs
### Problem: Status wird nicht angezeigt
**Lösung:**
1. Prüfe, ob das `status` Objekt im JSON vorhanden ist
2. Prüfe, ob `status.text` und `status.color` gesetzt sind
3. Prüfe die Browser-Konsole: `console.log("ActivityFeed data:", json)`
### Problem: Übersetzung funktioniert nicht
**Lösung:**
1. Prüfe, ob der Status-Text exakt übereinstimmt (case-sensitive)
2. Füge die Übersetzung in `ActivityFeed.tsx` hinzu
3. Baue die Website neu: `npm run build`
---
## 📚 Weitere Ressourcen
- [n8n Documentation](https://docs.n8n.io/)
- [N8N_INTEGRATION.md](./N8N_INTEGRATION.md) - Vollständige n8n Integration
- [DYNAMIC_ACTIVITY_MANAGEMENT.md](./DYNAMIC_ACTIVITY_MANAGEMENT.md) - Activity Management
---
## 💡 Quick Reference
**Status-Text ändern:**
1. Öffne n8n Dashboard
2. Finde den Status-Workflow
3. Ändere `status.text` im Function/Set Node
4. Aktiviere den Workflow
5. Warte 30 Sekunden (Cache-Intervall)
**Neue Übersetzung hinzufügen:**
1. Öffne `app/components/ActivityFeed.tsx`
2. Füge neue Bedingung hinzu (Zeile ~1559)
3. Baue neu: `npm run build`
4. Deploy
---
Happy automating! 🎉

View File

@@ -0,0 +1,283 @@
# Production Troubleshooting Guide
## 502 Bad Gateway Errors
### Symptome
- Website zeigt 502 Bad Gateway
- Activity Feed zeigt nur "Loading"
- Chat funktioniert nicht
- API-Endpunkte geben 502 zurück
### Ursachen
1. **Container läuft nicht** - Der `portfolio-app` Container ist gestoppt oder crashed
2. **Proxy Netzwerk fehlt** - Das `proxy` Netzwerk existiert nicht oder Container ist nicht verbunden
3. **Nginx Proxy Manager Konfiguration** - Falsche Hostname/IP oder Port-Konfiguration
4. **Container Health Check fehlgeschlagen** - Container läuft, aber die Anwendung ist nicht bereit
### Lösungsschritte
#### 1. Diagnose ausführen
```bash
./scripts/diagnose-production.sh
```
Dieses Script prüft:
- Container-Status
- Netzwerk-Verbindungen
- Health Checks
- API-Endpunkte
- Environment Variables
#### 2. Automatischer Fix
```bash
./scripts/fix-production.sh
```
Dieses Script:
- Erstellt das `proxy` Netzwerk falls es fehlt
- Verbindet den Container mit dem `proxy` Netzwerk
- Startet den Container neu falls nötig
- Prüft Health Checks
#### 3. Manuelle Schritte
**Container-Status prüfen:**
```bash
docker ps -a | grep portfolio-app
docker logs portfolio-app --tail=50
```
**Proxy Netzwerk prüfen:**
```bash
docker network ls | grep proxy
docker inspect portfolio-app | grep -A 10 Networks
```
**Container neu starten:**
```bash
cd /workspace/denshooter/portfolio
docker compose -f docker-compose.production.yml restart portfolio
```
**Container mit Proxy-Netzwerk verbinden:**
```bash
# Falls Netzwerk fehlt
docker network create proxy
# Container neu erstellen
docker compose -f docker-compose.production.yml up -d --force-recreate
```
#### 4. Nginx Proxy Manager Konfiguration prüfen
1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts
2. **Öffne die Konfiguration für `dk0.dev`**
3. **Details Tab prüfen:**
- **Forward Hostname/IP:** Muss `portfolio-app` sein (NICHT `localhost` oder `127.0.0.1`)
- **Forward Port:** `3000`
- **Forward Scheme:** `http`
4. **Advanced Tab prüfen:**
```
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;
```
#### 5. Container-Logs prüfen
```bash
# Alle Logs
docker logs portfolio-app
# Letzte 100 Zeilen
docker logs portfolio-app --tail=100
# Logs in Echtzeit
docker logs portfolio-app -f
```
## React Hydration Error #418
### Symptome
- Browser Console zeigt: `Error: Minified React error #418`
- Website funktioniert, aber es gibt Warnungen
- Unterschiede zwischen Server- und Client-Rendering
### Ursachen
1. **Client-only Komponenten werden auf dem Server gerendert**
2. **Unterschiedliche Daten zwischen Server und Client**
3. **Browser-spezifische APIs auf dem Server**
### Lösung
**ActivityFeed Komponente:**
- Die Komponente ist bereits als `"use client"` markiert
- Verwendet `ClientOnly` Wrapper für client-only Features
- Loading-State verhindert Hydration-Mismatches
**Wenn der Fehler weiterhin auftritt:**
1. Browser Cache leeren
2. Hard Refresh (Ctrl+Shift+R / Cmd+Shift+R)
3. Container-Logs auf React-Fehler prüfen
## Activity Feed zeigt nur "Loading"
### Ursachen
1. **n8n Webhook nicht erreichbar** - `N8N_WEBHOOK_URL` ist nicht gesetzt oder falsch
2. **API-Endpunkt gibt Fehler zurück** - `/api/n8n/status` funktioniert nicht
3. **CORS-Probleme** - n8n blockiert Requests
4. **Rate Limiting** - Zu viele Requests
### Lösung
**1. Environment Variables prüfen:**
```bash
docker exec portfolio-app printenv | grep N8N
```
**2. API-Endpunkt direkt testen:**
```bash
# Von außen
curl https://dk0.dev/api/n8n/status
# Von innen
docker exec portfolio-app curl http://localhost:3000/api/n8n/status
```
**3. n8n Webhook testen:**
```bash
# Ersetze WEBHOOK_URL mit deiner tatsächlichen URL
curl "https://n8n.dk0.dev/webhook/denshooter-71242/status"
```
**4. Gitea Variables prüfen:**
- Gehe zu Gitea → Repository → Settings → Variables
- Prüfe ob `N8N_WEBHOOK_URL`, `N8N_SECRET_TOKEN`, `N8N_API_KEY` gesetzt sind
- Stelle sicher, dass diese auch in `docker-compose.production.yml` verwendet werden
## Chat funktioniert nicht
### Ursachen
1. **n8n Webhook nicht erreichbar** - Gleiche Ursache wie Activity Feed
2. **API-Endpunkt gibt 502 zurück** - Container-Problem
3. **CORS-Probleme** - n8n blockiert Requests
4. **Timeout** - n8n antwortet zu langsam
### Lösung
**1. API-Endpunkt testen:**
```bash
# POST Request testen
curl -X POST https://dk0.dev/api/n8n/chat \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
```
**2. Container-Logs prüfen:**
```bash
docker logs portfolio-app | grep -i "chat\|n8n"
```
**3. n8n Webhook direkt testen:**
```bash
curl -X POST "https://n8n.dk0.dev/webhook/chat" \
-H "Content-Type: application/json" \
-d '{"message": "Hello", "history": []}'
```
## Häufige Probleme und Lösungen
### Problem: Container startet, aber Health Check schlägt fehl
**Lösung:**
```bash
# Prüfe ob curl im Container verfügbar ist
docker exec portfolio-app which curl
# Teste Health Endpoint manuell
docker exec portfolio-app curl -f http://localhost:3000/api/health
```
### Problem: Container läuft, aber Port 3000 ist nicht erreichbar
**Lösung:**
```bash
# Prüfe Port-Mapping
docker port portfolio-app
# Teste von innen
docker exec portfolio-app curl http://localhost:3000/api/health
# Teste von außen (sollte funktionieren wenn Port gemappt ist)
curl http://localhost:3000/api/health
```
### Problem: Proxy Netzwerk existiert nicht
**Lösung:**
```bash
# Erstelle Proxy Netzwerk
docker network create proxy
# Verbinde Container
docker network connect proxy portfolio-app
# Oder Container neu erstellen
docker compose -f docker-compose.production.yml up -d --force-recreate
```
### Problem: Nginx Proxy Manager kann Container nicht erreichen
**Lösung:**
1. Stelle sicher, dass beide im `proxy` Netzwerk sind:
```bash
docker network inspect proxy
```
2. Prüfe Nginx Proxy Manager Container:
```bash
docker ps | grep nginx
docker inspect <nginx-container> | grep -A 10 Networks
```
3. Teste Verbindung von Nginx Container zu Portfolio Container:
```bash
docker exec <nginx-container> ping portfolio-app
```
## Nützliche Befehle
```bash
# Container-Status
docker ps -a | grep portfolio
# Container-Logs
docker logs portfolio-app --tail=100 -f
# Container-Netzwerke
docker inspect portfolio-app | grep -A 20 Networks
# Health Check Status
docker inspect portfolio-app --format='{{.State.Health.Status}}'
# Environment Variables
docker exec portfolio-app printenv
# Shell im Container öffnen
docker exec -it portfolio-app sh
# Container neu starten
docker compose -f docker-compose.production.yml restart portfolio
# Container neu erstellen
docker compose -f docker-compose.production.yml up -d --force-recreate
# Alle Container stoppen
docker compose -f docker-compose.production.yml down
# Container mit Logs starten
docker compose -f docker-compose.production.yml up
```
## Support
Wenn die Probleme weiterhin bestehen:
1. Führe `./scripts/diagnose-production.sh` aus
2. Speichere die Ausgabe
3. Prüfe Container-Logs: `docker logs portfolio-app --tail=100`
4. Prüfe Nginx Proxy Manager Logs
5. Erstelle ein Issue mit allen Informationen

69
lib/email-obfuscate.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Email and URL obfuscation utilities
* Prevents automated scraping while keeping functionality
*/
/**
* Obfuscates an email address by encoding it
* @param email - The email address to obfuscate
* @returns Obfuscated email string that can be decoded by JavaScript
*/
export function obfuscateEmail(email: string): string {
// Simple base64 encoding (can be decoded by bots, but adds a layer)
// For better protection, use a custom encoding scheme
return Buffer.from(email).toString('base64');
}
/**
* Deobfuscates an email address
* @param obfuscated - The obfuscated email string
* @returns Original email address
*/
export function deobfuscateEmail(obfuscated: string): string {
try {
return Buffer.from(obfuscated, 'base64').toString('utf-8');
} catch {
return obfuscated; // Return as-is if decoding fails
}
}
/**
* Creates an obfuscated mailto link component
* @param email - The email address
* @param displayText - Text to display (optional, defaults to email)
* @returns HTML string with obfuscated email
*/
export function createObfuscatedMailto(email: string, displayText?: string): string {
const obfuscated = obfuscateEmail(email);
const text = displayText || email;
// Use data attributes and JavaScript to decode
return `<a href="#" data-email="${obfuscated}" class="obfuscated-email" onclick="this.href='mailto:'+atob(this.dataset.email); return true;">${text}</a>`;
}
/**
* Obfuscates a URL by encoding parts of it
* @param url - The URL to obfuscate
* @returns Obfuscated URL string
*/
export function obfuscateUrl(url: string): string {
// Encode the URL
return Buffer.from(url).toString('base64');
}
/**
* Creates an obfuscated link
* @param url - The URL
* @param displayText - Text to display
* @returns HTML string with obfuscated URL
*/
export function createObfuscatedLink(url: string, displayText: string): string {
const obfuscated = obfuscateUrl(url);
return `<a href="#" data-url="${obfuscated}" class="obfuscated-link" onclick="this.href=atob(this.dataset.url); return true;">${displayText}</a>`;
}
/**
* React component helper for obfuscated emails
* Note: This is a TypeScript utility file. For React components, create a separate .tsx file
* or use the HTML string functions instead.
*/

58
lib/html-decode.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Decode HTML entities in strings
* Converts &apos; &quot; &amp; &lt; &gt; etc. to their actual characters
*/
export function decodeHtmlEntities(text: string): string {
if (!text || typeof text !== 'string') {
return text;
}
// Create a temporary element to decode HTML entities
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
/**
* Server-side HTML entity decoding (for Node.js/Next.js API routes)
*/
export function decodeHtmlEntitiesServer(text: string): string {
if (!text || typeof text !== 'string') {
return text;
}
// Map of common HTML entities (including all variations of apostrophe)
const entityMap: Record<string, string> = {
'&apos;': "'",
'&quot;': '"',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&#39;': "'",
'&#x27;': "'",
'&#x2F;': '/',
'&#x60;': '`',
'&#x3D;': '=',
'&rsquo;': "'",
'&lsquo;': "'",
'&rdquo;': '"',
'&ldquo;': '"',
};
// First replace known entities
let decoded = text;
for (const [entity, replacement] of Object.entries(entityMap)) {
decoded = decoded.replace(new RegExp(entity, 'gi'), replacement);
}
// Then handle numeric entities (&#39; &#x27; etc.)
decoded = decoded.replace(/&#(\d+);/g, (match, num) => {
return String.fromCharCode(parseInt(num, 10));
});
decoded = decoded.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
return String.fromCharCode(parseInt(hex, 16));
});
return decoded;
}

View File

@@ -5,8 +5,18 @@ export function middleware(request: NextRequest) {
// For /manage and /editor routes, the pages handle their own authentication
// No middleware redirect needed - let the pages show login forms
// Fix for 421 Misdirected Request with Nginx Proxy Manager
// Ensure proper host header handling for reverse proxy
const hostname = request.headers.get('host') || request.headers.get('x-forwarded-host') || '';
// Add security headers to all responses
const response = NextResponse.next();
// Set proper headers for Nginx Proxy Manager
if (hostname) {
response.headers.set('X-Forwarded-Host', hostname);
response.headers.set('X-Real-IP', request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for') || '');
}
// Security headers (complementing next.config.ts headers)
response.headers.set("X-DNS-Prefetch-Control", "on");

View File

@@ -29,9 +29,14 @@ const nextConfig: NextConfig = {
},
// Performance optimizations
experimental: {
optimizePackageImports: ["lucide-react", "framer-motion"],
},
// NOTE: `optimizePackageImports` can cause dev-time webpack runtime issues with some setups.
// Keep it enabled for production builds only.
experimental:
process.env.NODE_ENV === "production"
? {
optimizePackageImports: ["lucide-react", "framer-motion"],
}
: {},
// Image optimization
images: {
@@ -54,7 +59,7 @@ const nextConfig: NextConfig = {
},
// Webpack configuration
webpack: (config, { isServer, dev, webpack }) => {
webpack: (config) => {
// Fix for module resolution issues
config.resolve.fallback = {
...config.resolve.fallback,
@@ -63,24 +68,6 @@ const nextConfig: NextConfig = {
tls: false,
};
// Safari + React 19 + Next.js 15 compatibility fixes
if (dev && !isServer) {
// Disable module concatenation to prevent factory initialization issues
config.optimization = {
...config.optimization,
concatenateModules: false,
providedExports: false,
usedExports: false,
};
// Add DefinePlugin to ensure proper environment detection
config.plugins.push(
new webpack.DefinePlugin({
"process.env.__NEXT_DISABLE_REACT_STRICT_MODE": JSON.stringify(false),
}),
);
}
return config;
},

2085
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"pre-push": "./scripts/pre-push.sh",
"pre-push:full": "./scripts/pre-push-full.sh",
"pre-push:quick": "./scripts/pre-push-quick.sh",
"test:all": "./scripts/test-all.sh",
"test:all": "npm run test && npm run test:e2e",
"buildAnalyze": "cross-env ANALYZE=true next build",
"test": "jest",
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
@@ -25,7 +25,6 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:all": "npm run test && npm run test:e2e",
"test:critical": "playwright test e2e/critical-paths.spec.ts",
"test:hydration": "playwright test e2e/hydration.spec.ts",
"test:email": "playwright test e2e/email.spec.ts",
@@ -37,8 +36,8 @@
"db:reset": "prisma db push --force-reset",
"docker:build": "docker build -t portfolio-app .",
"docker:run": "docker run -p 3000:3000 portfolio-app",
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
"docker:down": "docker compose -f docker-compose.prod.yml down",
"docker:compose": "docker compose -f docker-compose.production.yml up -d",
"docker:down": "docker compose -f docker-compose.production.yml down",
"docker:dev": "docker compose -f docker-compose.dev.minimal.yml up -d",
"docker:dev:down": "docker compose -f docker-compose.dev.minimal.yml down",
"deploy": "./scripts/deploy.sh",
@@ -56,8 +55,8 @@
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
"clsx": "^2.1.1",
"dotenv": "^16.6.1",
"framer-motion": "^12.24.10",
"gray-matter": "^4.0.3",
"lucide-react": "^0.542.0",
@@ -65,13 +64,13 @@
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-responsive-masonry": "^2.7.1",
"redis": "^5.8.2",
"tailwind-merge": "^2.2.1"
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@@ -816,6 +816,135 @@ Built to demonstrate ML model deployment, API design for ML services, and scalab
shares: 29,
},
},
{
title: "Kernel Panic 404 - Interactive Terminal",
description:
"An interactive terminal-style 404 page with a fully functional command line, file system navigation, and Easter eggs inspired by Stranger Things, Mr. Robot, and Hitchhiker's Guide to the Galaxy.",
content: `# Kernel Panic 404 - Interactive Terminal
An immersive, retro-style 404 page that transforms the error experience into an interactive terminal adventure.
## 🎯 Purpose
Instead of showing a boring "Page Not Found" message, visitors are greeted with a fully functional terminal emulator where they can explore a virtual file system, run commands, and discover hidden Easter eggs.
## 🚀 Features
- **Interactive Terminal**: Full command-line interface with command history
- **Virtual File System**: Navigate directories, read files, and explore hidden content
- **CRT Monitor Effects**: Authentic retro computer terminal aesthetics
- **Easter Eggs**: Hidden references to pop culture (Stranger Things, Mr. Robot, Hitchhiker's Guide)
- **Command Autocomplete**: Tab completion for commands and file paths
- **Audio Synthesis**: Sound effects for key presses and special events
- **Visual Effects**: Glitch effects, color shifts, and screen distortions
## 🛠️ Technologies Used
- Next.js 15 (App Router)
- React (Client Components)
- TypeScript
- CSS Animations
- Web Audio API
- Framer Motion (for effects)
## 💻 Available Commands
### System Commands
- \`ls\`, \`cd\`, \`cat\`, \`grep\`, \`find\`, \`pwd\`
- \`whoami\`, \`uname\`, \`date\`, \`uptime\`
- \`clear\`, \`history\`, \`help\`
### Easter Egg Commands
- \`hawkins\` / \`011\` / \`eleven\` - Enter the Upside Down
- \`fsociety\` / \`elliot\` / \`bonsoir\` - Mr. Robot mode
- \`42\` / \`answer\` - Deep Thought calculation
- \`rm -rf /\` - Trigger kernel panic
## 🎨 Design Features
- **CRT Monitor Aesthetics**: Scanlines, phosphor glow, authentic terminal colors
- **Retro Typography**: Monospace font with terminal-style appearance
- **Interactive Elements**: Fully functional command line with history navigation
- **Visual Effects**: Screen glitches, color shifts, and distortion effects
## 🔍 Hidden Content
The file system contains hidden clues and references:
- \`/var/log/syslog\` - Contains hints about Easter eggs
- \`~/.bash_history\` - Shows previous commands
- \`~/readme.txt\` - Welcome message and hints
## 💡 What I Learned
- Building interactive terminal emulators in React
- Web Audio API for sound synthesis
- Complex state management for file systems
- CSS animations for retro effects
- Creating engaging error pages
## 🔮 Future Enhancements
- More Easter eggs and hidden commands
- Additional visual effects
- Sound theme variations
- More complex file system structures
- Network commands (ping, curl, etc.)
## 🎮 Try It Out
Visit any non-existent page on the site to see the terminal in action. Or click the "404" link in the footer!
**Try these commands:**
- \`ls -la\` - List all files including hidden ones
- \`cat readme.txt\` - Read the welcome message
- \`cd /var/log\` - Navigate to system logs
- \`hawkins\` - Enter the Upside Down mode
- \`42\` - Get the answer to everything`,
tags: ["Next.js", "React", "TypeScript", "Terminal", "404", "Interactive", "Easter Eggs"],
featured: true,
category: "Web Development",
date: "2025",
published: true,
difficulty: "INTERMEDIATE",
timeToComplete: "1-2 weeks",
technologies: ["Next.js", "React", "TypeScript", "CSS", "Web Audio API"],
challenges: [
"Terminal emulator implementation",
"File system state management",
"Command parsing and execution",
"Audio synthesis for effects",
"Retro visual effects with CSS"
],
lessonsLearned: [
"Building interactive UIs",
"Web Audio API usage",
"Complex state management",
"CSS animation techniques",
"Creating engaging error pages"
],
futureImprovements: [
"More Easter eggs",
"Additional visual effects",
"Sound theme variations",
"Network command simulation"
],
demoVideo: "",
screenshots: [],
colorScheme: "Retro terminal green on black",
accessibility: true,
performance: {
lighthouse: 0,
bundleSize: "0KB",
loadTime: "0s",
},
analytics: {
views: 0,
likes: 0,
shares: 0,
},
live: "/404",
github: "",
},
];
for (const project of projects) {

158
scripts/diagnose-production.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/bash
# Production diagnosis script
# Checks container status, network connectivity, and API endpoints
set -e
echo "🔍 Production Diagnosis Script"
echo "=============================="
echo ""
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if docker is running
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}❌ Docker is not running${NC}"
exit 1
fi
echo -e "${GREEN}✅ Docker is running${NC}"
echo ""
# Check container status
echo "📦 Container Status:"
echo "-------------------"
CONTAINER_ID=$(docker ps -q -f "name=portfolio-app")
if [ -z "$CONTAINER_ID" ]; then
echo -e "${RED}❌ portfolio-app container is not running${NC}"
echo ""
echo "Checking for stopped containers:"
docker ps -a -f "name=portfolio-app" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
exit 1
else
echo -e "${GREEN}✅ Container is running (ID: ${CONTAINER_ID})${NC}"
docker ps -f "name=portfolio-app" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Health}}"
fi
echo ""
# Check container health
echo "🏥 Container Health:"
echo "-------------------"
HEALTH=$(docker inspect portfolio-app --format='{{.State.Health.Status}}' 2>/dev/null || echo "no-healthcheck")
STATUS=$(docker inspect portfolio-app --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
echo "Status: $STATUS"
echo "Health: $HEALTH"
echo ""
# Check networks
echo "🌐 Network Connectivity:"
echo "----------------------"
if docker network ls | grep -q "proxy"; then
echo -e "${GREEN}✅ 'proxy' network exists${NC}"
if docker inspect portfolio-app --format='{{range $net, $conf := .NetworkSettings.Networks}}{{$net}} {{end}}' | grep -q "proxy"; then
echo -e "${GREEN}✅ Container is connected to 'proxy' network${NC}"
else
echo -e "${YELLOW}⚠️ Container is NOT connected to 'proxy' network${NC}"
echo "Connected networks:"
docker inspect portfolio-app --format='{{range $net, $conf := .NetworkSettings.Networks}}{{$net}} {{end}}'
fi
else
echo -e "${RED}❌ 'proxy' network does not exist${NC}"
echo "Creating proxy network..."
docker network create proxy || echo "Failed to create network (may already exist)"
fi
echo ""
# Check port mapping
echo "🔌 Port Mapping:"
echo "---------------"
PORT_MAPPING=$(docker port portfolio-app 2>/dev/null | grep 3000 || echo "No port mapping found")
if [ -n "$PORT_MAPPING" ]; then
echo -e "${GREEN}✅ Port mapping: $PORT_MAPPING${NC}"
else
echo -e "${RED}❌ No port mapping found for port 3000${NC}"
fi
echo ""
# Test from inside container
echo "🧪 Testing from inside container:"
echo "-------------------------------"
if docker exec portfolio-app curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Health endpoint responds from inside container${NC}"
docker exec portfolio-app curl -s http://localhost:3000/api/health | head -5
else
echo -e "${RED}❌ Health endpoint does not respond from inside container${NC}"
echo "Container logs (last 20 lines):"
docker logs portfolio-app --tail=20
fi
echo ""
# Test from host
echo "🧪 Testing from host:"
echo "-------------------"
if curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Health endpoint responds from host${NC}"
curl -s http://localhost:3000/api/health | head -5
else
echo -e "${RED}❌ Health endpoint does not respond from host${NC}"
echo "This is normal if the container is only accessible via proxy network"
fi
echo ""
# Check environment variables
echo "🔐 Environment Variables:"
echo "------------------------"
echo "N8N_WEBHOOK_URL: $(docker exec portfolio-app printenv N8N_WEBHOOK_URL 2>/dev/null | grep -v '^$' || echo 'NOT SET')"
echo "N8N_SECRET_TOKEN: $(docker exec portfolio-app printenv N8N_SECRET_TOKEN 2>/dev/null | grep -v '^$' | sed 's/./*/g' || echo 'NOT SET')"
echo "N8N_API_KEY: $(docker exec portfolio-app printenv N8N_API_KEY 2>/dev/null | grep -v '^$' | sed 's/./*/g' || echo 'NOT SET')"
echo "NODE_ENV: $(docker exec portfolio-app printenv NODE_ENV 2>/dev/null || echo 'NOT SET')"
echo "NEXT_PUBLIC_BASE_URL: $(docker exec portfolio-app printenv NEXT_PUBLIC_BASE_URL 2>/dev/null || echo 'NOT SET')"
echo ""
# Check container logs for errors
echo "📋 Recent Container Logs (last 30 lines):"
echo "----------------------------------------"
docker logs portfolio-app --tail=30 2>&1 | tail -30
echo ""
# Check API endpoints
echo "🌐 Testing API Endpoints:"
echo "------------------------"
ENDPOINTS=("/api/health" "/api/n8n/status" "/api/n8n/chat")
for endpoint in "${ENDPOINTS[@]}"; do
echo -n "Testing $endpoint: "
if docker exec portfolio-app curl -f -s --max-time 5 "http://localhost:3000${endpoint}" > /dev/null 2>&1; then
echo -e "${GREEN}✅ OK${NC}"
else
echo -e "${RED}❌ FAILED${NC}"
fi
done
echo ""
# Summary
echo "📊 Summary:"
echo "---------"
if [ "$STATUS" == "running" ] && [ "$HEALTH" == "healthy" ]; then
echo -e "${GREEN}✅ Container appears to be healthy${NC}"
echo ""
echo "If you're still seeing 502 errors:"
echo "1. Check Nginx Proxy Manager configuration"
echo "2. Ensure the proxy host points to: portfolio-app:3000 (not localhost:3000)"
echo "3. Ensure the proxy network is correctly configured"
echo "4. Check Nginx Proxy Manager logs"
else
echo -e "${RED}❌ Container has issues${NC}"
echo ""
echo "Recommended actions:"
if [ "$STATUS" != "running" ]; then
echo "1. Restart the container: docker compose -f docker-compose.production.yml restart portfolio"
fi
if [ "$HEALTH" != "healthy" ]; then
echo "2. Check container logs: docker logs portfolio-app --tail=50"
echo "3. Check if the application is starting correctly"
fi
fi

118
scripts/fix-production.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/bash
# Quick fix script for production issues
# Ensures proxy network exists and container is properly connected
set -e
echo "🔧 Production Fix Script"
echo "======================="
echo ""
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
COMPOSE_FILE="docker-compose.production.yml"
# Check if proxy network exists
echo "🌐 Checking proxy network..."
if docker network ls | grep -q "proxy"; then
echo -e "${GREEN}✅ 'proxy' network exists${NC}"
else
echo -e "${YELLOW}⚠️ 'proxy' network does not exist. Creating...${NC}"
docker network create proxy || {
echo -e "${RED}❌ Failed to create proxy network${NC}"
echo "This might be because Nginx Proxy Manager hasn't created it yet."
echo "Please ensure Nginx Proxy Manager is running and has created the 'proxy' network."
exit 1
}
echo -e "${GREEN}✅ 'proxy' network created${NC}"
fi
echo ""
# Check if container is running
echo "📦 Checking container status..."
CONTAINER_ID=$(docker ps -q -f "name=portfolio-app")
if [ -z "$CONTAINER_ID" ]; then
echo -e "${RED}❌ Container is not running${NC}"
echo "Starting container..."
docker compose -f $COMPOSE_FILE up -d
echo "Waiting for container to start..."
sleep 10
else
echo -e "${GREEN}✅ Container is running${NC}"
fi
echo ""
# Check if container is connected to proxy network
echo "🔗 Checking network connectivity..."
if docker inspect portfolio-app --format='{{range $net, $conf := .NetworkSettings.Networks}}{{$net}} {{end}}' | grep -q "proxy"; then
echo -e "${GREEN}✅ Container is connected to 'proxy' network${NC}"
else
echo -e "${YELLOW}⚠️ Container is NOT connected to 'proxy' network${NC}"
echo "Connecting container to proxy network..."
# Stop container
docker compose -f $COMPOSE_FILE stop portfolio
# Connect to proxy network
docker network connect proxy portfolio-app || {
echo -e "${RED}❌ Failed to connect container to proxy network${NC}"
echo "Trying to recreate container..."
docker compose -f $COMPOSE_FILE up -d --force-recreate
}
# Start container
docker compose -f $COMPOSE_FILE start portfolio
echo -e "${GREEN}✅ Container recreated and connected to proxy network${NC}"
fi
echo ""
# Wait for health check
echo "⏳ Waiting for container to be healthy..."
for i in {1..30}; do
HEALTH=$(docker inspect portfolio-app --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo -e "${GREEN}✅ Container is healthy${NC}"
break
fi
if [ $i -eq 30 ]; then
echo -e "${YELLOW}⚠️ Container health check timeout${NC}"
echo "Container logs:"
docker logs portfolio-app --tail=30
else
echo "Waiting... ($i/30)"
sleep 2
fi
done
echo ""
# Test API endpoints
echo "🧪 Testing API endpoints..."
if docker exec portfolio-app curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Health endpoint is working${NC}"
else
echo -e "${RED}❌ Health endpoint is not working${NC}"
echo "Container logs:"
docker logs portfolio-app --tail=50
exit 1
fi
echo ""
# Summary
echo "📊 Summary:"
echo "---------"
echo -e "${GREEN}✅ Production fix completed${NC}"
echo ""
echo "Next steps:"
echo "1. Verify Nginx Proxy Manager configuration:"
echo " - Forward Hostname/IP: portfolio-app"
echo " - Forward Port: 3000"
echo " - Ensure 'proxy' network is selected"
echo ""
echo "2. Test the website: https://dk0.dev"
echo ""
echo "3. If still having issues, run: ./scripts/diagnose-production.sh"

View File

@@ -7,7 +7,7 @@ set -e
# Configuration
CONTAINER_NAME="portfolio-app"
COMPOSE_FILE="docker-compose.prod.yml"
COMPOSE_FILE="docker-compose.production.yml"
# Colors for output
RED='\033[0;31m'

121
scripts/rollback.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
# Rollback Script for Portfolio Deployment
# Restores previous version of the application
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() {
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Check if environment is specified
ENV=${1:-production}
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
IMAGE_TAG="production"
if [ "$ENV" == "dev" ] || [ "$ENV" == "staging" ]; then
COMPOSE_FILE="docker-compose.staging.yml"
CONTAINER_NAME="portfolio-app-staging"
IMAGE_TAG="staging"
HEALTH_PORT="3002"
else
HEALTH_PORT="3000"
fi
log "🔄 Starting rollback for $ENV environment..."
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
error "Docker is not running. Please start Docker and try again."
exit 1
fi
# List available image tags
log "📋 Available image versions:"
docker images portfolio-app --format "table {{.Tag}}\t{{.ID}}\t{{.CreatedAt}}" | head -10
# Get current container image
CURRENT_IMAGE=$(docker inspect $CONTAINER_NAME --format='{{.Config.Image}}' 2>/dev/null || echo "")
if [ ! -z "$CURRENT_IMAGE" ]; then
log "Current image: $CURRENT_IMAGE"
fi
# Find previous image tags
PREVIOUS_TAGS=$(docker images portfolio-app --format "{{.Tag}}" | grep -E "^(production|staging|latest|previous|backup)" | grep -v "^$IMAGE_TAG$" | head -5)
if [ -z "$PREVIOUS_TAGS" ]; then
error "No previous images found for rollback!"
log "Available images:"
docker images portfolio-app
exit 1
fi
# Use the first previous tag (most recent)
PREVIOUS_TAG=$(echo "$PREVIOUS_TAGS" | head -1)
log "Selected previous image: portfolio-app:$PREVIOUS_TAG"
# Confirm rollback
read -p "Do you want to rollback to portfolio-app:$PREVIOUS_TAG? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Rollback cancelled."
exit 0
fi
# Tag the previous image as current
log "🔄 Tagging previous image as current..."
docker tag "portfolio-app:$PREVIOUS_TAG" "portfolio-app:$IMAGE_TAG" || {
error "Failed to tag previous image"
exit 1
}
# Stop current container
log "🛑 Stopping current container..."
docker compose -f $COMPOSE_FILE down || true
# Start with previous image
log "🚀 Starting previous version..."
docker compose -f $COMPOSE_FILE up -d
# Wait for health check
log "⏳ Waiting for health check..."
for i in {1..40}; do
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
success "✅ Rollback successful! Application is healthy."
break
fi
echo -n "."
sleep 3
done
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
error "❌ Health check failed after rollback!"
log "Container logs:"
docker compose -f $COMPOSE_FILE logs --tail=50
exit 1
fi
success "🎉 Rollback completed successfully!"
log "Application is available at: http://localhost:$HEALTH_PORT"
log "To rollback further, run: ./scripts/rollback.sh $ENV"