1 Commits

Author SHA1 Message Date
denshooter
40a18676e5 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:41:41 +01:00
52 changed files with 3019 additions and 6860 deletions

View File

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

@@ -0,0 +1,232 @@
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

@@ -0,0 +1,123 @@
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

@@ -1,132 +0,0 @@
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

@@ -1,273 +0,0 @@
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,41 @@
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

@@ -0,0 +1,105 @@
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]")"

85
AUTO_DEPLOYMENT_STATUS.md Normal file
View File

@@ -0,0 +1,85 @@
# 🚀 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.

66
CLEANUP_PLAN.md Normal file
View File

@@ -0,0 +1,66 @@
# 🧹 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

95
CLEANUP_SUMMARY.md Normal file
View File

@@ -0,0 +1,95 @@
# 🧹 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

89
DEPLOYMENT_FIX.md Normal file
View File

@@ -0,0 +1,89 @@
# 🔧 Deployment Fixes Applied
## Issues Fixed
### 1. Port 3001 Already Allocated ❌ → ✅
**Problem**: Port 3001 was already in use, causing staging deployment to fail.
**Fix**:
- Changed staging port from `3001` to `3002`
- Changed PostgreSQL staging port from `5433` to `5434`
- Changed Redis staging port from `6380` to `6381`
### 2. Docker Compose Version Warning ❌ → ✅
**Problem**: `version: '3.8'` is obsolete in newer Docker Compose.
**Fix**: Removed `version` line from `docker-compose.staging.yml`
### 3. Missing N8N Environment Variables ❌ → ✅
**Problem**: `N8N_SECRET_TOKEN` warning appeared.
**Fix**: Added `N8N_WEBHOOK_URL` and `N8N_SECRET_TOKEN` to staging compose file
### 4. Wrong Compose File Used ❌ → ✅
**Problem**: Gitea workflow was using wrong compose file (stopping production containers).
**Fix**:
- Updated `ci-cd-with-gitea-vars.yml` to detect branch and use correct compose file
- Created dedicated `staging-deploy.yml` workflow
- Staging now uses `docker-compose.staging.yml`
- Production uses `docker-compose.production.yml`
## Updated Ports
| Service | Staging | Production |
|---------|---------|------------|
| App | **3002** ✅ | **3000** |
| PostgreSQL | **5434** ✅ | **5432** |
| Redis | **6381** ✅ | **6379** |
## How It Works Now
### Staging (dev/main branch)
```bash
git push origin dev
# → Uses docker-compose.staging.yml
# → Deploys to port 3002
# → Does NOT touch production containers
```
### Production (production branch)
```bash
git push origin production
# → Uses docker-compose.production.yml
# → Deploys to port 3000
# → Zero-downtime deployment
# → Does NOT touch staging containers
```
## Files Updated
-`docker-compose.staging.yml` - Fixed ports, removed version, added N8N vars
-`.gitea/workflows/ci-cd-with-gitea-vars.yml` - Branch detection, correct compose files
-`.gitea/workflows/staging-deploy.yml` - New dedicated staging workflow
-`STAGING_SETUP.md` - Updated port references
## Next Steps
1. **Test staging deployment**:
```bash
git push origin dev
# Should deploy to port 3002 without errors
```
2. **Verify staging**:
```bash
curl http://localhost:3002/api/health
```
3. **When ready for production**:
```bash
git checkout production
git merge main
git push origin production
# Deploys safely to port 3000
```
---
**All fixes applied!** Staging and production are now completely isolated. 🚀

View File

@@ -1,200 +0,0 @@
# 🚀 Deployment Setup Guide
## Overview
This project uses a **dual-branch deployment strategy** with zero-downtime deployments:
- **Production Branch** (`production`) → Serves `https://dk0.dev` on port 3000
- **Dev Branch** (`dev`) → Serves `https://dev.dk0.dev` on port 3002
Both environments are completely isolated with separate:
- Docker containers
- Databases (PostgreSQL)
- Redis instances
- Networks
- Volumes
## Branch Strategy
### Production Branch
- **Branch**: `production`
- **Domain**: `https://dk0.dev`
- **Port**: `3000`
- **Container**: `portfolio-app`
- **Database**: `portfolio_db` (port 5432)
- **Redis**: `portfolio-redis` (port 6379)
- **Image Tag**: `portfolio-app:production` / `portfolio-app:latest`
### Dev Branch
- **Branch**: `dev`
- **Domain**: `https://dev.dk0.dev`
- **Port**: `3002`
- **Container**: `portfolio-app-staging`
- **Database**: `portfolio_staging_db` (port 5434)
- **Redis**: `portfolio-redis-staging` (port 6381)
- **Image Tag**: `portfolio-app:staging`
## Automatic Deployment
### How It Works
1. **Push to `production` branch**:
- Triggers `.gitea/workflows/production-deploy.yml`
- Runs tests, builds, and deploys to production
- Zero-downtime deployment (starts new container, waits for health, removes old)
2. **Push to `dev` branch**:
- Triggers `.gitea/workflows/dev-deploy.yml`
- Runs tests, builds, and deploys to dev/staging
- Zero-downtime deployment
### Zero-Downtime Process
1. Build new Docker image
2. Start new container with updated image
3. Wait for new container to be healthy (health checks)
4. Verify HTTP endpoints respond correctly
5. Remove old container (if different)
6. Cleanup old images
## Manual Deployment
### Production
```bash
# Build and deploy production
docker build -t portfolio-app:latest .
docker compose -f docker-compose.production.yml up -d --build
```
### Dev/Staging
```bash
# Build and deploy dev
docker build -t portfolio-app:staging .
docker compose -f docker-compose.staging.yml up -d --build
```
## Environment Variables
### Required Gitea Variables
- `NEXT_PUBLIC_BASE_URL` - Base URL for the application
- `MY_EMAIL` - Email address for contact
- `MY_INFO_EMAIL` - Info email address
- `LOG_LEVEL` - Logging level (info/debug)
### Required Gitea Secrets
- `MY_PASSWORD` - Email password
- `MY_INFO_PASSWORD` - Info email password
- `ADMIN_BASIC_AUTH` - Admin basic auth credentials
- `N8N_SECRET_TOKEN` - Optional: n8n webhook secret
### Optional Variables
- `N8N_WEBHOOK_URL` - n8n webhook URL for automation
## Health Checks
Both environments have health check endpoints:
- Production: `http://localhost:3000/api/health`
- Dev: `http://localhost:3002/api/health`
## Monitoring
### Check Container Status
```bash
# Production
docker compose -f docker-compose.production.yml ps
# Dev
docker compose -f docker-compose.staging.yml ps
```
### View Logs
```bash
# Production
docker logs portfolio-app --tail=100 -f
# Dev
docker logs portfolio-app-staging --tail=100 -f
```
### Health Check
```bash
# Production
curl http://localhost:3000/api/health
# Dev
curl http://localhost:3002/api/health
```
## Troubleshooting
### Container Won't Start
1. Check logs: `docker logs <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,10 +3,11 @@ 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
# Copy package files first for better caching
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci --only=production && npm cache clean --force
@@ -18,38 +19,22 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install all dependencies (including dev dependencies for build)
# Use npm ci with cache mount for faster builds
RUN --mount=type=cache,target=/root/.npm \
npm ci
RUN npm ci
# Copy Prisma schema first (for better caching)
COPY prisma ./prisma
# Generate Prisma client (cached if schema unchanged)
RUN npx prisma generate
# Copy source code (this invalidates cache when code changes)
# Copy source code
COPY . .
# 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
RUN npx prisma generate
# 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
@@ -57,9 +42,6 @@ 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
@@ -73,10 +55,7 @@ RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
# 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/standalone/app ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma files

View File

@@ -1,185 +0,0 @@
# 🔧 Gitea Variables & Secrets Setup Guide
## Übersicht
In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt) für dein Repository setzen. Diese werden in den CI/CD Workflows verwendet.
## 📍 Wo findest du die Einstellungen?
1. Gehe zu deinem Repository auf Gitea
2. Klicke auf **Settings** (Einstellungen)
3. Klicke auf **Variables** oder **Secrets** im linken Menü
## 🔑 Variablen für Production Branch
Für den `production` Branch brauchst du:
### Variables (öffentlich sichtbar):
- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev`
- `MY_EMAIL` = `contact@dk0.dev` (oder deine Email)
- `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email)
- `LOG_LEVEL` = `info`
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional)
### Secrets (verschlüsselt):
- `MY_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort`
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## 🧪 Variablen für Dev Branch
Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
### Variables:
- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
- `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein)
- `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein)
- `LOG_LEVEL` = `debug` (für Dev mehr Logging)
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional)
### Secrets:
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein)
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## ✅ Lösung: Automatische Branch-Erkennung
**Gitea unterstützt keine branch-spezifischen Variablen, aber die Workflows erkennen automatisch den Branch!**
### Wie es funktioniert:
Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch die richtigen Defaults:
**Production Workflow** (`.gitea/workflows/production-deploy.yml`):
- Triggert nur auf `production` Branch
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev`
**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`):
- Triggert nur auf `dev` Branch
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev`
**Das bedeutet:**
- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea
- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`)
- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`)
### ⚠️ WICHTIG:
Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben:
**Option 1: Variable NICHT setzen (Empfohlen)**
- Production verwendet automatisch: `https://dk0.dev`
- Dev verwendet automatisch: `https://dev.dk0.dev`
- ✅ Funktioniert perfekt ohne Konfiguration!
**Option 2: Variable setzen**
- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt
- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev)
- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet
## ✅ Empfohlene Konfiguration
### ⭐ Einfachste Lösung: NICHTS setzen!
Die Workflows haben bereits die richtigen Defaults:
- **Production Branch** → automatisch `https://dk0.dev`
- **Dev Branch** → automatisch `https://dev.dk0.dev`
Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
### Wenn du Variablen setzen willst:
**Nur diese Variablen setzen (für beide Branches):**
- `MY_EMAIL` = `contact@dk0.dev`
- `MY_INFO_EMAIL` = `info@dk0.dev`
- `LOG_LEVEL` = `info` (wird für Production verwendet, Dev überschreibt mit `debug`)
**Secrets (für beide Branches):**
- `MY_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
**⚠️ NICHT setzen:**
- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet!
## 📝 Schritt-für-Schritt Anleitung
### 1. Gehe zu Repository Settings
```
https://git.dk0.dev/denshooter/portfolio/settings
```
### 2. Klicke auf "Variables" oder "Secrets"
### 3. Für Variables (öffentlich):
- Klicke auf **"New Variable"**
- **Name:** `NEXT_PUBLIC_BASE_URL`
- **Value:** `https://dk0.dev` (für Production)
- **Protect:** ✅ (optional, schützt vor Änderungen)
- Klicke **"Add Variable"**
### 4. Für Secrets (verschlüsselt):
- Klicke auf **"New Secret"**
- **Name:** `MY_PASSWORD`
- **Value:** Dein Passwort
- Klicke **"Add Secret"**
## 🔄 Aktuelle Workflow-Logik
Die Workflows verwenden diese einfache Logik:
```yaml
# Production Workflow (triggert nur auf production branch)
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
# Dev Workflow (triggert nur auf dev branch)
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
```
**Das bedeutet:**
- Jeder Workflow hat seinen **eigenen Default**
- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet
- Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default
**⭐ Beste Lösung:**
- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen
- Dann verwendet Production automatisch `https://dk0.dev`
- Und Dev verwendet automatisch `https://dev.dk0.dev`
- ✅ Perfekt getrennt, ohne Konfiguration!
## 🎯 Best Practice
1. **Production:** Setze alle Variablen explizit in Gitea
2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen)
3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code!
## 🔍 Prüfen ob Variablen gesetzt sind
In den Workflow-Logs siehst du:
```
📝 Using Gitea Variables and Secrets:
- NEXT_PUBLIC_BASE_URL: https://dk0.dev
```
Wenn eine Variable fehlt, wird der Default verwendet.
## ⚙️ Alternative: Environment-spezifische Variablen
Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen:
```yaml
# Production
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
# Dev
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
```
Dann könntest du setzen:
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev`
Soll ich die Workflows entsprechend anpassen?

53
GIT_CONNECTION_FIX.md Normal file
View File

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

@@ -1,198 +0,0 @@
# 🔧 Nginx Proxy Manager Setup Guide
## Übersicht
Dieses Projekt nutzt **Nginx Proxy Manager** als Reverse Proxy. Die Container sind im `proxy` Netzwerk, damit Nginx Proxy Manager auf sie zugreifen kann.
## 🐳 Docker Netzwerk-Konfiguration
Die Container sind bereits im `proxy` Netzwerk konfiguriert:
**Production:**
```yaml
networks:
- portfolio_net
- proxy # ✅ Bereits konfiguriert
```
**Staging:**
```yaml
networks:
- portfolio_staging_net
- proxy # ✅ Bereits konfiguriert
```
## 📋 Nginx Proxy Manager Konfiguration
### Production (dk0.dev)
1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host
2. **Details Tab:**
- **Domain Names:** `dk0.dev`, `www.dk0.dev`
- **Scheme:** `http`
- **Forward Hostname/IP:** `portfolio-app` (Container-Name)
- **Forward Port:** `3000`
- **Cache Assets:** ✅ (optional)
- **Block Common Exploits:** ✅
- **Websockets Support:** ✅ (für Chat/Activity)
3. **SSL Tab:**
- **SSL Certificate:** Request a new SSL Certificate
- **Force SSL:** ✅
- **HTTP/2 Support:** ✅
- **HSTS Enabled:** ✅
4. **Advanced Tab:**
```
# Custom Nginx Configuration
# Fix for 421 Misdirected Request
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Fix HTTP/2 connection reuse issues
proxy_http_version 1.1;
proxy_set_header Connection "";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
```
### Staging (dev.dk0.dev)
1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host
2. **Details Tab:**
- **Domain Names:** `dev.dk0.dev`
- **Scheme:** `http`
- **Forward Hostname/IP:** `portfolio-app-staging` (Container-Name)
- **Forward Port:** `3000` (interner Port im Container)
- **Cache Assets:** ❌ (für Dev besser deaktiviert)
- **Block Common Exploits:** ✅
- **Websockets Support:** ✅
3. **SSL Tab:**
- **SSL Certificate:** Request a new SSL Certificate
- **Force SSL:** ✅
- **HTTP/2 Support:** ✅
- **HSTS Enabled:** ✅
4. **Advanced Tab:**
```
# Custom Nginx Configuration
# Fix for 421 Misdirected Request
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Fix HTTP/2 connection reuse issues
proxy_http_version 1.1;
proxy_set_header Connection "";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
```
## 🔍 421 Misdirected Request - Lösung
Der **421 Misdirected Request** Fehler tritt auf, wenn:
1. **HTTP/2 Connection Reuse:** Nginx Proxy Manager versucht, eine HTTP/2-Verbindung wiederzuverwenden, aber der Host-Header stimmt nicht überein
2. **Host-Header nicht richtig weitergegeben:** Der Container erhält den falschen Host-Header
### Lösung 1: Advanced Tab Konfiguration (Wichtig!)
Füge diese Zeilen im **Advanced Tab** von Nginx Proxy Manager hinzu:
```nginx
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
```
### Lösung 2: Container-Namen verwenden
Stelle sicher, dass du den **Container-Namen** (nicht IP) verwendest:
- Production: `portfolio-app`
- Staging: `portfolio-app-staging`
### Lösung 3: Netzwerk prüfen
Stelle sicher, dass beide Container im `proxy` Netzwerk sind:
```bash
# Prüfen
docker network inspect proxy
# Sollte enthalten:
# - portfolio-app
# - portfolio-app-staging
```
## ✅ Checkliste
- [ ] Container sind im `proxy` Netzwerk
- [ ] Nginx Proxy Manager nutzt Container-Namen (nicht IP)
- [ ] Advanced Tab Konfiguration ist gesetzt
- [ ] `proxy_http_version 1.1` ist gesetzt
- [ ] `proxy_set_header Host $host` ist gesetzt
- [ ] SSL-Zertifikat ist konfiguriert
- [ ] Websockets Support ist aktiviert
## 🐛 Troubleshooting
### 421 Fehler weiterhin vorhanden?
1. **Prüfe Container-Namen:**
```bash
docker ps --format "table {{.Names}}\t{{.Status}}"
```
2. **Prüfe Netzwerk:**
```bash
docker network inspect proxy | grep -A 5 portfolio
```
3. **Prüfe Nginx Proxy Manager Logs:**
- Gehe zu Nginx Proxy Manager → System Logs
- Suche nach "421" oder "misdirected"
4. **Teste direkt:**
```bash
# Vom Host aus
curl -H "Host: dk0.dev" http://portfolio-app:3000
# Sollte funktionieren
```
5. **Deaktiviere HTTP/2 temporär:**
- In Nginx Proxy Manager → SSL Tab
- **HTTP/2 Support:** ❌
- Teste ob es funktioniert
## 📝 Wichtige Hinweise
- **Container-Namen sind wichtig:** Nutze `portfolio-app` nicht `localhost` oder IP
- **Port:** Immer Port `3000` (interner Container-Port), nicht `3000:3000`
- **Netzwerk:** Beide Container müssen im `proxy` Netzwerk sein
- **HTTP/2:** Kann Probleme verursachen, wenn Advanced Config fehlt
## 🔄 Nach Deployment
Nach jedem Deployment:
1. Prüfe ob Container läuft: `docker ps | grep portfolio`
2. Prüfe ob Container im proxy-Netzwerk ist
3. Teste die URL im Browser
4. Prüfe Nginx Proxy Manager Logs bei Problemen

View File

@@ -1,120 +0,0 @@
# 🔒 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

@@ -1,21 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
import { NextResponse } from "next/server";
export async function POST(request: NextRequest) {
export async function POST(request: Request) {
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 || [];
@@ -30,193 +18,65 @@ export async function POST(request: NextRequest) {
// Call your n8n chat webhook
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
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,
});
if (!n8nWebhookUrl) {
console.error("N8N_WEBHOOK_URL not configured");
return NextResponse.json({
reply: getFallbackResponse(userMessage),
});
}
// 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,
});
console.log(`Sending to n8n: ${n8nWebhookUrl}/webhook/chat`);
// 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,
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}`,
}),
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);
}
},
body: JSON.stringify({
message: userMessage,
history: history,
}),
});
if (!response.ok) {
console.error(`n8n webhook failed with status: ${response.status}`);
throw new Error(`n8n webhook failed: ${response.status}`);
}
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.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");
}
// 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);
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 fetchError;
throw new Error("Invalid response format from n8n");
}
} 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,
return NextResponse.json({
reply: reply,
});
} catch (error) {
console.error("Chat API error:", error);
// Fallback to mock responses
// Now using the variable captured at the start

View File

@@ -13,24 +13,6 @@ 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,101 +1,49 @@
// app/api/n8n/status/route.ts
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
// Cache für 30 Sekunden, damit wir n8n nicht zuspammen
export const revalidate = 30;
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 }
);
}
export async function GET() {
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 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, {
const res = await fetch(
`${process.env.N8N_WEBHOOK_URL}/webhook/denshooter-71242/status?t=${Date.now()}`,
{
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,
});
},
);
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;
if (!res.ok) {
throw new Error(`n8n error: ${res.status}`);
}
} catch (error: unknown) {
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) {
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,5 +1,6 @@
"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";
@@ -15,18 +16,24 @@ const staggerContainer: Variants = {
};
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
duration: 1,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
const About = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const techStack = [
{
category: "Frontend & Mobile",
@@ -57,6 +64,8 @@ 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

@@ -51,14 +51,6 @@ 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") {
@@ -69,7 +61,6 @@ export default function ChatWidget() {
setMessages(
parsed.map((m: Message) => ({
...m,
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
timestamp: new Date(m.timestamp),
})),
);
@@ -81,7 +72,7 @@ export default function ChatWidget() {
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
text: "Hi! I&apos;m Dennis&apos;s AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
@@ -129,31 +120,14 @@ export default function ChatWidget() {
});
if (!response.ok) {
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)}`);
throw new Error("Failed to get response");
}
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: replyText,
text: data.reply || "Sorry, I couldn't process that. Please try again.",
sender: "bot",
timestamp: new Date(),
};
@@ -225,7 +199,7 @@ export default function ChatWidget() {
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-lg">
<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>
</motion.div>
@@ -251,11 +225,11 @@ export default function ChatWidget() {
</div>
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate">
Dennis{'\''}s AI Assistant
<div>
<h3 className="font-bold text-sm">
Dennis&apos;s AI Assistant
</h3>
<p className="text-xs text-white/80 truncate">Always online</p>
<p className="text-xs text-white/80">Always online</p>
</div>
</div>
@@ -386,7 +360,7 @@ export default function ChatWidget() {
{/* Quick Actions */}
<div className="flex gap-2 mt-2 overflow-x-auto pb-1 scrollbar-hide">
{[
"What are Dennis's skills?",
"What are Dennis&apos;s skills?",
"Tell me about his projects",
"How can I contact him?",
].map((suggestion, index) => (

View File

@@ -155,10 +155,10 @@ const Contact = () => {
<div className="max-w-7xl mx-auto">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, 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: -20 }}
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, 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: -10 }}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
viewport={{ once: true }}
transition={{
duration: 0.5,
delay: index * 0.1,
duration: 0.8,
delay: index * 0.15,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
@@ -226,10 +226,10 @@ const Contact = () => {
{/* Contact Form */}
<motion.div
initial={{ opacity: 0, x: 20 }}
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 1, 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: 10 }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="flex items-center space-x-3"
>
<motion.div
@@ -53,10 +53,10 @@ const Footer = () => {
{/* Social Links */}
<motion.div
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.05 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="flex space-x-3"
>
{socialLinks.map((social) => (
@@ -77,10 +77,10 @@ const Footer = () => {
{/* Copyright */}
<motion.div
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
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: 10 }}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.15 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
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,13 +115,6 @@ 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,10 +12,7 @@ const Header = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Use requestAnimationFrame to ensure smooth transition
requestAnimationFrame(() => {
setMounted(true);
});
setMounted(true);
}, []);
useEffect(() => {
@@ -44,16 +41,17 @@ const Header = () => {
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
];
// Always render to prevent flash, but use opacity transition
if (!mounted) {
return null;
}
return (
<>
<motion.header
initial={false}
animate={{ y: 0, opacity: mounted ? 1 : 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, 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 ${
@@ -61,9 +59,9 @@ const Header = () => {
}`}
>
<motion.div
initial={false}
animate={{ opacity: mounted ? 1 : 0, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: "easeOut" }}
className={`
backdrop-blur-xl transition-all duration-500
${

View File

@@ -1,16 +1,27 @@
"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">
@@ -18,7 +29,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 1.2, delay: 0.3, 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">
@@ -100,7 +111,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
transition={{ duration: 1, delay: 0.8, 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">
@@ -112,7 +123,7 @@ const Hero = () => {
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
transition={{ delay: 1.2, duration: 0.8, 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"
>
@@ -121,7 +132,7 @@ const Hero = () => {
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
transition={{ delay: 1.4, duration: 0.8, 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"
>
@@ -134,7 +145,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 1, delay: 0.6, 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">
@@ -149,7 +160,7 @@ const Hero = () => {
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 1, delay: 0.9, 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{" "}
@@ -171,7 +182,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 1, delay: 1.1, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-wrap justify-center gap-4 mb-12"
>
{features.map((feature, index) => (
@@ -180,8 +191,8 @@ const Hero = () => {
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.5,
delay: 0.5 + index * 0.1,
duration: 0.8,
delay: 1.3 + index * 0.15,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{ scale: 1.03, y: -3 }}
@@ -199,7 +210,7 @@ const Hero = () => {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
transition={{ duration: 1, delay: 1.6, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
>
<motion.a

View File

@@ -1,703 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface FileSystemNode {
type: 'file' | 'dir' | 'exe';
content?: string;
children?: Record<string, FileSystemNode>;
}
export default function KernelPanic404() {
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const [systemFrozen, setSystemFrozen] = useState(false);
const [currentMusic, setCurrentMusic] = useState<{ stop?: () => void } | null>(null);
const [hawkinsActive, setHawkinsActive] = useState(false);
const [fsocietyActive, setFsocietyActive] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [currentPath, setCurrentPath] = useState<FileSystemNode | null>(null);
const [pathStr, setPathStr] = useState("~");
const audioCtxRef = useRef<AudioContext | null>(null);
// File system structure
const fileSystem: { home: FileSystemNode; var: FileSystemNode; etc: FileSystemNode; bin: FileSystemNode; tmp: FileSystemNode } = {
home: {
type: "dir",
children: {
guest: {
type: "dir",
children: {
"readme.txt": {
type: "file",
content: "ERROR 404: Page Not Found.\n\nSystem Integrity: 89%\nCheck /var/log for clues.\n\nTry: ls -la, cat .bash_history"
},
".bash_history": {
type: "file",
content: "ls -la\nwhoami\nfsociety\nexit"
},
"todo.txt": {
type: "file",
content: "- Fix the internet\n- Calculate the Ultimate Answer (try: 42)\n- Buy milk\n- Check Hawkins Lab logs"
},
"projects": {
type: "dir",
children: {
website: {
type: "dir",
children: {
"index.html": { type: "file", content: "<html><body>404</body></html>" }
}
}
}
}
}
}
}
},
var: {
type: "dir",
children: {
log: {
type: "dir",
children: {
syslog: {
type: "file",
content: "[ERR] Reality breach detected in HAWKINS_LAB sector.\n[WARN] Subject 011 has escaped containment.\n[ALERT] Dimensional gate unstable.\n[INFO] Try command: hawkins or 011"
},
"kern.log": {
type: "file",
content: "[ 0.000000] Linus Torvalds: 'This kernel is garbage.'\n[ 0.100000] Kernel tainted: M (Module has bad license)\n[ 0.200000] Torvalds: 'I'm not angry, just disappointed.'"
},
"auth.log": {
type: "file",
content: "Failed password for root from 127.0.0.1\nFailed password for elliot from 127.0.0.1"
}
}
}
}
},
etc: {
type: "dir",
children: {
passwd: {
type: "file",
content: "root:x:0:0:root:/root:/bin/bash\nguest:x:1000:1000:guest:/home/guest:/bin/bash\nelliot:x:509:509:mr_robot:/home/elliot:/bin/sh"
},
hosts: {
type: "file",
content: "127.0.0.1 localhost\n127.0.0.1 e-corp.com\n0.0.0.0 reality"
}
}
},
bin: {
type: "dir",
children: {
ls: { type: "exe" },
cat: { type: "exe" },
grep: { type: "exe" },
find: { type: "exe" }
}
},
tmp: {
type: "dir",
children: {}
}
};
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const printLine = (text: string, type?: string) => {
if (!outputRef.current) return;
const d = document.createElement('div');
d.innerHTML = text;
if (type === 'log-warn') d.style.color = '#ffb000';
if (type === 'log-err' || type === 'alert') d.style.color = '#ff3333';
if (type === 'log-dim') d.style.opacity = '0.6';
if (type === 'log-sys') d.style.color = 'cyan';
if (type === 'log-k') d.style.color = '#fff';
if (type === 'pulse-red') {
d.classList.add('pulse-red');
d.style.color = '#ff3333';
d.style.textShadow = '0 0 10px red';
}
outputRef.current.appendChild(d);
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
};
const playSynth = (type: string) => {
try {
if (!audioCtxRef.current || systemFrozen) return;
const t = audioCtxRef.current.currentTime;
const osc = audioCtxRef.current.createOscillator();
const gain = audioCtxRef.current.createGain();
osc.connect(gain);
gain.connect(audioCtxRef.current.destination);
if (type === 'key') {
osc.type = 'square';
osc.frequency.setValueAtTime(600, t);
gain.gain.setValueAtTime(0.02, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
osc.start();
osc.stop(t + 0.05);
} else if (type === 'beep') {
osc.type = 'sine';
osc.frequency.setValueAtTime(800, t);
gain.gain.setValueAtTime(0.1, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
osc.start();
osc.stop(t + 0.2);
}
} catch {
// Ignore audio errors
}
};
const getCurrentDir = (): FileSystemNode => {
if (pathStr === "~" || pathStr.startsWith("~/")) {
return fileSystem.home.children!.guest;
} else if (pathStr.startsWith("/var/log")) {
// Return a wrapper node for /var/log directory
return {
type: 'dir',
children: fileSystem.var.children!.log.children
};
} else if (pathStr.startsWith("/var")) {
return fileSystem.var;
} else if (pathStr.startsWith("/etc")) {
return fileSystem.etc;
} else if (pathStr.startsWith("/bin")) {
return fileSystem.bin;
}
return currentPath || fileSystem.home.children!.guest;
};
const runCmd = async (cmdRaw: string) => {
if (systemFrozen || !outputRef.current) return;
printLine(`guest@404:${pathStr}$ ${cmdRaw}`, 'log-dim');
const args = cmdRaw.split(/\s+/);
const cmd = args[0].toLowerCase();
const newHistory = [...commandHistory, cmdRaw];
setCommandHistory(newHistory);
setHistoryIndex(newHistory.length);
await sleep(100);
const dir = getCurrentDir();
switch (cmd) {
case 'help':
printLine("--- SYSTEM UTILS ---", "log-sys");
printLine(" ls, cd, cat, grep, find, pwd, clear");
printLine(" whoami, uname, history, date, uptime");
printLine(" head, tail, wc, sort, uniq");
printLine("--- NETWORK ---", "log-sys");
printLine(" ping, hostname");
printLine("--- PROCESSES ---", "log-sys");
printLine(" ps, top, kill");
printLine("(Hints are hidden in the file system - try ls -la)");
break;
case 'ls':
const showHidden = args.includes('-a') || args.includes('-la') || args.includes('-l');
const longFormat = args.includes('-l') || args.includes('-la');
const items = Object.keys(dir.children || {})
.filter(n => !n.startsWith('.') || showHidden);
if (showHidden) {
items.unshift('..');
items.unshift('.');
}
items.sort((a, b) => {
if (a === '.') return -1;
if (b === '.') return 1;
if (a === '..') return -1;
if (b === '..') return 1;
const itemA = dir.children?.[a] as FileSystemNode | undefined;
const itemB = dir.children?.[b] as FileSystemNode | undefined;
if (!itemA || !itemB) return 0;
if (itemA.type === 'dir' && itemB.type !== 'dir') return -1;
if (itemA.type !== 'dir' && itemB.type === 'dir') return 1;
return a.localeCompare(b);
});
if (longFormat) {
printLine(`total ${items.length}`);
items.forEach(n => {
const item = dir.children?.[n];
if (!item && n !== '.' && n !== '..') return;
const isDir = item?.type === 'dir' || n === '.' || n === '..';
const perms = isDir ? 'drwxr-xr-x' : '-rw-r--r--';
const links = isDir ? '2' : '1';
const size = isDir ? '4096'.padStart(8) : (item?.content?.length || '0').toString().padStart(8);
const date = new Date();
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = monthNames[date.getMonth()];
const day = date.getDate().toString().padStart(2);
const time = date.toTimeString().substring(0, 5);
const dateStr = `${month} ${day} ${time}`;
const filename = isDir ? `<span class='is-dir'>${n}/</span>` : n;
printLine(`${perms} ${links} guest guest ${size} ${dateStr} ${filename}`);
});
} else {
const formatted = items.map(n => {
const item = dir.children?.[n];
if (n === '.' || n === '..') return `<span class='is-dir'>${n}/</span>`;
if (item?.type === 'dir') return `<span class='is-dir'>${n}/</span>`;
if (item?.type === 'exe') return `<span class='is-exe'>${n}*</span>`;
return n;
});
printLine(formatted.join(' '));
}
break;
case 'cat':
const file = dir.children?.[args[1]] as FileSystemNode | undefined;
if (file && file.type === 'file') {
printLine(file.content || '');
} else {
printLine(`cat: ${args[1] || ''}: No such file`, 'log-err');
}
break;
case 'cd':
if (!args[1]) {
setCurrentPath(fileSystem.home.children!.guest);
setPathStr("~");
} else if (args[1] === '..') {
if (pathStr === "/var/log") {
setCurrentPath(fileSystem.var);
setPathStr("/var");
} else if (pathStr === "/var") {
setPathStr("/");
} else {
setCurrentPath(fileSystem.home.children!.guest);
setPathStr("~");
}
} else if (args[1] === '~' || args[1] === '/home/guest') {
setCurrentPath(fileSystem.home.children!.guest);
setPathStr("~");
} else if (args[1].startsWith('/var/log')) {
setCurrentPath({
type: 'dir',
children: fileSystem.var.children!.log.children
});
setPathStr("/var/log");
} else if (args[1].startsWith('/var')) {
setCurrentPath(fileSystem.var);
setPathStr("/var");
} else if (args[1].startsWith('/etc')) {
setCurrentPath(fileSystem.etc);
setPathStr("/etc");
} else {
const subdir = dir.children?.[args[1]] as FileSystemNode | undefined;
if (subdir && subdir.type === 'dir') {
setCurrentPath(subdir);
setPathStr(pathStr === "~" ? `~/${args[1]}` : `${pathStr}/${args[1]}`);
} else {
printLine(`cd: ${args[1]}: No such file or directory`, 'log-err');
}
}
break;
case 'pwd':
printLine(pathStr === "~" ? "/home/guest" : pathStr);
break;
case 'grep':
if (args.length < 3) {
printLine("Usage: grep [pattern] [file]");
} else {
const grepFile = dir.children?.[args[2]] as FileSystemNode | undefined;
if (grepFile && grepFile.type === 'file' && grepFile.content) {
const lines = grepFile.content.split('\n');
lines.forEach(l => {
if (l.toLowerCase().includes(args[1].toLowerCase())) {
printLine(l);
}
});
} else {
printLine(`grep: ${args[2]}: No such file`);
}
}
break;
case 'whoami':
printLine("guest");
break;
case 'uname':
if (args.includes('-a')) {
printLine("Linux 404-void 4.0.4-void #1 SMP PREEMPT Fri Jan 09 2025 x86_64 GNU/Linux");
} else {
printLine("Linux");
}
break;
case 'date':
printLine(new Date().toString());
break;
case 'uptime':
const uptime = Math.floor((Date.now() - performance.timing.navigationStart) / 1000);
const hours = Math.floor(uptime / 3600);
const mins = Math.floor((uptime % 3600) / 60);
const secs = uptime % 60;
printLine(`up ${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}, 1 user, load average: 0.00, 0.00, 0.00`);
break;
case 'clear':
if (outputRef.current) outputRef.current.innerHTML = "";
break;
case 'history':
commandHistory.forEach((c, i) => printLine(` ${i + 1} ${c}`));
break;
case 'exit':
window.location.href = '/';
break;
// Easter eggs
case 'hawkins':
case '011':
case 'eleven':
case 'upsidedown':
if (hawkinsActive) {
printLine("Closing dimensional gate...", 'log-warn');
setHawkinsActive(false);
if (currentMusic) {
currentMusic.stop?.();
setCurrentMusic(null);
}
document.body.classList.remove('hawkins');
} else {
setHawkinsActive(true);
document.body.classList.add('hawkins');
printLine("Entering the Upside Down...", 'log-err');
}
break;
case 'fsociety':
case 'elliot':
case 'bonsoir':
if (fsocietyActive) {
setFsocietyActive(false);
setSystemFrozen(false);
if (inputContainerRef.current) inputContainerRef.current.style.display = 'flex';
if (currentMusic) {
currentMusic.stop?.();
setCurrentMusic(null);
}
document.body.classList.remove('fsociety-boot');
} else {
setFsocietyActive(true);
setSystemFrozen(true);
if (inputContainerRef.current) inputContainerRef.current.style.display = 'none';
if (outputRef.current) outputRef.current.innerHTML = "";
document.body.classList.add('fsociety-boot');
printLine("$ ./fsociety.sh", 'log-k');
await sleep(250);
printLine("[*] Initializing breach protocol...", 'log-warn');
await sleep(500);
printLine("Hello friend.", 'log-k');
await sleep(1000);
location.reload();
}
break;
case '42':
case 'answer':
printLine("Initializing Deep Thought...", 'log-sys');
await sleep(600);
printLine("Deep Thought: The Answer to the Ultimate Question of Life, the Universe, and Everything is...", 'log-k');
await sleep(1500);
printLine("42", 'log-k');
break;
case 'rm':
if (args[1] === '-rf' && args[2] === '/') {
setSystemFrozen(true);
if (inputContainerRef.current) inputContainerRef.current.style.display = 'none';
printLine("CRITICAL: Attempting to delete root filesystem...", 'log-err');
await sleep(500);
printLine("KERNEL PANIC: Unable to handle kernel paging request", 'pulse-red');
await sleep(2000);
location.reload();
} else if (args[1]) {
printLine("rm: cannot remove: Read-only file system", 'log-err');
} else {
printLine("Usage: rm [file]");
}
break;
default:
printLine(`bash: ${cmd}: command not found`, 'log-err');
}
if (inputRef.current && !systemFrozen) {
setTimeout(() => inputRef.current?.focus(), 50);
}
};
useEffect(() => {
setCurrentPath(fileSystem.home.children!.guest);
const initAudio = () => {
if (!audioCtxRef.current) {
try {
audioCtxRef.current = new (window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)();
} catch {
// Ignore
}
}
};
window.addEventListener('keydown', initAudio, { once: true });
const bootMessages = [
{ t: "[ 0.000000] Linux version 4.0.4-void (torvalds@kernel.org) (gcc version 9.4.0)", d: 200 },
{ t: "[ 0.050000] Command line: BOOT_IMAGE=/boot/vmlinuz-4.0.4 root=UUID=dead-beef ro quiet splash", d: 150 },
{ t: "[ 0.100000] x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers'", d: 100 },
{ t: "[ 0.150000] x86/fpu: Supporting XSAVE feature 0x002: 'SSE registers'", d: 100 },
{ t: "[ 0.200000] BIOS-provided physical RAM map:", d: 200 },
{ t: "[ 0.250000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable", d: 150 },
{ t: "[ 0.300000] BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved", d: 150 },
{ t: "[ 0.400000] Memory: 64K/1048576K available (404K kernel code, 404K rwdata, 404K rodata)", d: 300 },
{ t: "[ 0.600000] Calibrating delay loop... 800.00 BogoMIPS (lpj=400000)", d: 200 },
{ t: "[ 0.800000] Security Framework initialized", d: 200 },
{ t: "[ 1.000000] Tainted kernel: M (Module has bad license) P (Proprietary module loaded)", type: 'log-warn', d: 400 },
{ t: "[ 1.200000] Linus Torvalds: 'I'm not angry, I'm just disappointed in this boot process.'", d: 600 },
{ t: "[ 1.500000] Torvalds: 'This code is garbage. Who wrote this?'", d: 500 },
{ t: "[ 2.000000] [ OK ] Started udev Kernel Device Manager.", d: 200 },
{ t: "[ 2.200000] [ OK ] Mounted /dev/sda1 (Root Filesystem).", d: 200 },
{ t: "[ 2.400000] [TIME] Timed out waiting for device /dev/reality.", type: 'log-warn', d: 600 },
{ t: "[ 2.800000] [DEPEND] Dependency failed for Local File Systems.", type: 'log-err', d: 300 },
{ t: "[ 3.000000] [FAILED] Failed to start The Internet.", type: 'log-err', d: 400 },
{ t: "[ 3.200000] [FAILED] Failed to start Meaning of Life service.", type: 'log-err', d: 400 },
{ t: "[ 3.500000] Welcome to emergency mode. Type 'help' for available commands.", d: 200 },
];
const runBoot = async () => {
for (const msg of bootMessages) {
printLine(msg.t, msg.type);
await sleep(msg.d);
}
if (inputContainerRef.current) {
inputContainerRef.current.style.display = 'flex';
}
if (inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 100);
}
};
if (outputRef.current) {
runBoot();
}
return () => {
if (currentMusic) {
currentMusic.stop?.();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<style dangerouslySetInnerHTML={{ __html: `
:root {
--bg-color: #020202;
--phosphor: #33ff00;
--phosphor-sec: #008f11;
--alert: #ff3333;
--font: 'Courier New', Courier, monospace;
}
body {
background-color: var(--bg-color) !important;
margin: 0;
height: 100vh;
overflow: hidden;
font-family: var(--font) !important;
color: var(--phosphor) !important;
user-select: none;
cursor: default;
transition: filter 0.5s, transform 0.5s;
}
.crt-wrap {
width: 100%;
height: 100vh;
padding: 30px;
box-sizing: border-box;
background: radial-gradient(circle at center, #111 0%, #000 100%);
position: relative;
}
.crt-wrap::before {
content: " ";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
background-size: 100% 4px, 3px 100%;
pointer-events: none;
z-index: 90;
}
.glow {
text-shadow: 0 0 2px var(--phosphor-sec), 0 0 8px var(--phosphor);
}
#terminal {
height: 100%;
display: flex;
flex-direction: column;
max-width: 1400px;
margin: auto;
position: relative;
z-index: 10;
}
#output {
flex-grow: 1;
overflow-y: auto;
white-space: pre-wrap;
margin-bottom: 20px;
font-size: 1.1rem;
line-height: 1.4;
color: var(--phosphor);
}
#output::-webkit-scrollbar {
display: none;
}
.input-line {
display: none;
align-items: center;
font-size: 1.2rem;
border-top: 1px solid #333;
padding-top: 10px;
min-height: 30px;
}
.prompt {
margin-right: 10px;
font-weight: bold;
white-space: nowrap;
color: var(--phosphor);
}
#cmd-input {
background: transparent;
border: none;
color: inherit;
font-family: inherit;
font-size: inherit;
flex-grow: 1;
outline: none;
text-transform: lowercase;
caret-color: var(--phosphor);
}
body.hawkins {
--phosphor: #ff3333;
--phosphor-sec: #800000;
filter: contrast(1.6) sepia(1) hue-rotate(-30deg) saturate(4) brightness(0.6);
transform: rotate(180deg);
}
body.fsociety-boot {
background: #000;
filter: contrast(1.3) brightness(0.9);
}
body.meltdown {
animation: violent-shake 0.15s infinite, color-shift 0.8s infinite alternate;
}
@keyframes violent-shake {
0% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(-8px, 5px) rotate(-1deg); }
50% { transform: translate(8px, -5px) rotate(1deg) skew(3deg); }
75% { transform: translate(-8px, -5px) rotate(-1deg); }
100% { transform: translate(8px, 5px) rotate(1deg); }
}
@keyframes color-shift {
0% { filter: hue-rotate(0deg) saturate(1); }
25% { filter: hue-rotate(90deg) saturate(2) brightness(1.2); }
50% { filter: hue-rotate(180deg) saturate(3) brightness(0.8); }
75% { filter: hue-rotate(270deg) saturate(2) brightness(1.1); }
100% { filter: hue-rotate(360deg) saturate(1); }
}
.pulse-red {
animation: pulse-red 1s infinite;
}
@keyframes pulse-red {
0%, 100% { color: var(--alert); text-shadow: 0 0 10px red; }
50% { color: #ff6666; text-shadow: 0 0 20px red, 0 0 30px red; }
}
.is-dir { color: #5e91ff; font-weight: bold; }
.is-exe { color: var(--phosphor); font-weight: bold; }
`}} />
<div className="crt-wrap glow">
<div id="terminal">
<div id="output" ref={outputRef}></div>
<div className="input-line" id="input-container" ref={inputContainerRef} style={{ display: 'none' }}>
<span className="prompt">guest@404:~$</span>
<input
type="text"
id="cmd-input"
ref={inputRef}
autoComplete="off"
spellCheck={false}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const cmd = (e.target as HTMLInputElement).value.trim();
(e.target as HTMLInputElement).value = '';
runCmd(cmd);
} else if (e.key === 'ArrowUp' && historyIndex > 0) {
e.preventDefault();
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
(e.target as HTMLInputElement).value = commandHistory[newIndex] || '';
} else if (e.key === 'ArrowDown' && historyIndex < commandHistory.length - 1) {
e.preventDefault();
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
(e.target as HTMLInputElement).value = commandHistory[newIndex] || '';
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHistoryIndex(commandHistory.length);
(e.target as HTMLInputElement).value = '';
} else {
try {
playSynth('key');
} catch {
// Ignore audio errors
}
}
}}
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -7,12 +7,12 @@ import Link from "next/link";
import Image from "next/image";
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
duration: 0.8,
ease: [0.25, 0.1, 0.25, 1],
},
},
@@ -44,9 +44,11 @@ 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(
@@ -56,7 +58,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);
}
@@ -65,6 +67,8 @@ const Projects = () => {
loadProjects();
}, []);
if (!mounted) return null;
return (
<section
id="projects"
@@ -74,7 +78,7 @@ const Projects = () => {
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
viewport={{ once: true, margin: "-100px" }}
variants={fadeInUp}
className="text-center mb-20"
>
@@ -90,7 +94,7 @@ const Projects = () => {
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
viewport={{ once: true, margin: "-100px" }}
variants={staggerContainer}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>

View File

@@ -251,13 +251,19 @@ function EditorPageContent() {
};
// Markdown components for react-markdown with security
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markdownComponents: any = {
a: ({ node: _node, ...props }: { node?: unknown; href?: string; children?: React.ReactNode }) => {
const markdownComponents = {
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 && typeof href === 'string' && !href.startsWith("javascript:") && !href.startsWith("data:");
href && !href.startsWith("javascript:") && !href.startsWith("data:");
return (
<a
{...props}
@@ -271,11 +277,18 @@ 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 && typeof src === 'string' && !src.startsWith("javascript:") && !src.startsWith("data:");
src && !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

@@ -1,5 +1,22 @@
import KernelPanic404 from './components/KernelPanic404';
import Link from "next/link";
export default function NotFound() {
return <KernelPanic404 />;
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>
);
}

733
components/GhostEditor.tsx Normal file
View File

@@ -0,0 +1,733 @@
'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

@@ -1,31 +0,0 @@
'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

@@ -0,0 +1,750 @@
'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

@@ -1,5 +1,7 @@
# Production Docker Compose configuration for dk0.dev
# Optimized for production deployment with zero-downtime support
# Optimized for production deployment
version: '3.8'
services:
portfolio:
@@ -19,9 +21,6 @@ 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

@@ -13,7 +13,7 @@ services:
- 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://dev.dk0.dev}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev}
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
- MY_PASSWORD=${MY_PASSWORD}
@@ -26,7 +26,6 @@ services:
- portfolio_staging_data:/app/.next/cache
networks:
- portfolio_staging_net
- proxy
depends_on:
postgres-staging:
condition: service_healthy
@@ -113,5 +112,3 @@ volumes:
networks:
portfolio_staging_net:
driver: bridge
proxy:
external: true

View File

@@ -1,314 +0,0 @@
# 🚀 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

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

@@ -1,312 +0,0 @@
# 📝 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

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

View File

@@ -1,69 +0,0 @@
/**
* 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.
*/

View File

@@ -1,58 +0,0 @@
/**
* 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,18 +5,8 @@ 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");

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": "npm run test && npm run test:e2e",
"test:all": "./scripts/test-all.sh",
"buildAnalyze": "cross-env ANALYZE=true next build",
"test": "jest",
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
@@ -25,6 +25,7 @@
"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",
@@ -36,8 +37,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.production.yml up -d",
"docker:down": "docker compose -f docker-compose.production.yml down",
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
"docker:down": "docker compose -f docker-compose.prod.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",
@@ -55,8 +56,8 @@
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.1",
"dotenv": "^16.6.1",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
"framer-motion": "^12.24.10",
"gray-matter": "^4.0.3",
"lucide-react": "^0.542.0",
@@ -64,13 +65,13 @@
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-responsive-masonry": "^2.7.1",
"redis": "^5.8.2",
"tailwind-merge": "^2.6.0"
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@@ -816,135 +816,6 @@ 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) {

View File

@@ -1,158 +0,0 @@
#!/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

View File

@@ -1,118 +0,0 @@
#!/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.production.yml"
COMPOSE_FILE="docker-compose.prod.yml"
# Colors for output
RED='\033[0;31m'

View File

@@ -1,121 +0,0 @@
#!/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"