feat: Setup zero-downtime deployments for production and dev branches
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled

- Created separate workflows for production and dev deployments
- Production branch → dk0.dev (port 3000)
- Dev branch → dev.dk0.dev (port 3002)
- Zero-downtime deployment pattern (start new, wait for health, remove old)
- Complete isolation between environments (separate containers, databases, networks)
- Cleaned up unused code and files:
  - Removed unused GhostEditor and ResizableGhostEditor components
  - Removed old/unused workflows and markdown files
  - Fixed docker-compose references
- Upgraded dependencies to latest compatible versions
- Fixed TypeScript errors in editor page
- Updated staging to use dev.dk0.dev domain
This commit is contained in:
2026-01-09 14:19:48 +01:00
parent 5dcc6ae0a6
commit 8c223db2a8
25 changed files with 2206 additions and 3353 deletions

View File

@@ -1,209 +0,0 @@
name: CI/CD Pipeline (Dev/Staging)
on:
push:
branches: [dev, main]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app-staging
jobs:
staging:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test:production
- 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..."
- name: Verify Gitea Variables and Secrets
run: |
echo "🔍 Verifying Gitea Variables and Secrets..."
# Check Variables
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
if [ -z "${{ vars.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
echo "❌ MY_INFO_EMAIL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
# Check Secrets
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
echo "❌ MY_PASSWORD secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
echo "❌ MY_INFO_PASSWORD secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
echo "✅ All required Gitea variables and secrets are present"
echo "📝 Variables found:"
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: staging"
echo " - LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
- name: Build Docker image
run: |
echo "🏗️ Building Docker image..."
docker build -t ${{ env.DOCKER_IMAGE }}:staging .
docker tag ${{ env.DOCKER_IMAGE }}:staging ${{ env.DOCKER_IMAGE }}:staging-$(date +%Y%m%d-%H%M%S)
echo "✅ Docker image built successfully"
- name: Deploy Staging using Gitea Variables and Secrets
run: |
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
echo "📝 Using Gitea Variables and Secrets:"
echo " - NODE_ENV: staging"
echo " - LOG_LEVEL: ${LOG_LEVEL}"
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
echo " - MY_EMAIL: ${MY_EMAIL}"
echo " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}"
# Stop old staging containers
echo "🛑 Stopping old staging containers..."
docker compose -f docker-compose.staging.yml down || true
# Clean up orphaned containers
echo "🧹 Cleaning up orphaned containers..."
docker compose -f docker-compose.staging.yml down --remove-orphans || true
# Start new staging containers
echo "🚀 Starting new staging containers..."
docker compose -f docker-compose.staging.yml up -d
# Wait a moment for containers to start
echo "⏳ Waiting for containers to start..."
sleep 10
# Check container logs for debugging
echo "📋 Container logs (first 20 lines):"
docker compose -f docker-compose.staging.yml logs --tail=20
echo "✅ Staging deployment completed!"
env:
NODE_ENV: staging
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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL }}
N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
- name: Wait for containers to be ready
run: |
echo "⏳ Waiting for containers to be ready..."
sleep 45
# Check if all containers are running
echo "📊 Checking container status..."
docker compose -f docker-compose.staging.yml ps
# Wait for application container to be healthy
echo "🏥 Waiting for application container to be healthy..."
for i in {1..60}; do
if docker exec portfolio-app-staging curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Application container is healthy!"
break
fi
echo "⏳ Waiting for application container... ($i/60)"
sleep 5
done
# Additional wait for main page to be accessible
echo "🌐 Waiting for main page to be accessible..."
for i in {1..30}; do
if curl -f http://localhost:3001/ > /dev/null 2>&1; then
echo "✅ Main page is accessible!"
break
fi
echo "⏳ Waiting for main page... ($i/30)"
sleep 3
done
- name: Health check
run: |
echo "🔍 Running comprehensive health checks..."
# Check container status
echo "📊 Container status:"
docker compose -f docker-compose.staging.yml ps
# Check application container
echo "🏥 Checking application container..."
if docker exec portfolio-app-staging curl -f http://localhost:3000/api/health; then
echo "✅ Application health check passed!"
else
echo "❌ Application health check failed!"
docker logs portfolio-app-staging --tail=50
exit 1
fi
# Check main page
if curl -f http://localhost:3001/ > /dev/null; then
echo "✅ Main page is accessible!"
else
echo "❌ Main page is not accessible!"
exit 1
fi
echo "✅ All health checks passed! Staging deployment successful!"
- name: Cleanup old images
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
docker system prune -f
echo "✅ Cleanup completed"

View File

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

View File

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

View File

@@ -0,0 +1,126 @@
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
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building dev Docker image..."
docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} .
echo "✅ Docker image built successfully"
- name: Zero-Downtime Dev Deployment
run: |
echo "🚀 Starting zero-downtime dev deployment..."
COMPOSE_FILE="docker-compose.staging.yml"
CONTAINER_NAME="portfolio-app-staging"
HEALTH_PORT="3002"
# Backup current container ID if running
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
# Start new container with updated image
echo "🆕 Starting new dev container..."
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
for i in {1..60}; do
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ ! -z "$NEW_CONTAINER" ]; then
# Check health status
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ New container is healthy!"
break
fi
# Also check HTTP health endpoint
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ New container is responding!"
break
fi
fi
echo "⏳ Waiting... ($i/60)"
sleep 2
done
# Verify new container is working
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging
fi
# Remove old container if it exists and is different
if [ ! -z "$OLD_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Dev deployment completed!"
env:
NODE_ENV: staging
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Dev Health Check
run: |
echo "🔍 Running dev health checks..."
for i in {1..20}; do
if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then
echo "✅ Dev is fully operational!"
exit 0
fi
echo "⏳ Waiting for dev... ($i/20)"
sleep 3
done
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
docker compose -f docker-compose.staging.yml logs --tail=50
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

View File

@@ -0,0 +1,129 @@
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
run: npm run lint
- name: Run tests
run: npm run test:production
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building production Docker image..."
docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} .
docker tag ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} ${{ 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
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
# Start new container with updated image (docker-compose will handle this)
echo "🆕 Starting new production container..."
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..."
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 container failed health check!"
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio
exit 1
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 "✅ 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 || '' }}
- name: Production Health Check
run: |
echo "🔍 Running production health checks..."
for i in {1..20}; do
if curl -f http://localhost:3000/api/health && curl -f http://localhost:3000/ > /dev/null; then
echo "✅ Production is fully operational!"
exit 0
fi
echo "⏳ Waiting for production... ($i/20)"
sleep 3
done
echo "❌ Production health check failed!"
docker compose -f docker-compose.production.yml logs --tail=50
exit 1
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

200
DEPLOYMENT_SETUP.md Normal file
View File

@@ -0,0 +1,200 @@
# 🚀 Deployment Setup Guide
## Overview
This project uses a **dual-branch deployment strategy** with zero-downtime deployments:
- **Production Branch** (`production`) → Serves `https://dk0.dev` on port 3000
- **Dev Branch** (`dev`) → Serves `https://dev.dk0.dev` on port 3002
Both environments are completely isolated with separate:
- Docker containers
- Databases (PostgreSQL)
- Redis instances
- Networks
- Volumes
## Branch Strategy
### Production Branch
- **Branch**: `production`
- **Domain**: `https://dk0.dev`
- **Port**: `3000`
- **Container**: `portfolio-app`
- **Database**: `portfolio_db` (port 5432)
- **Redis**: `portfolio-redis` (port 6379)
- **Image Tag**: `portfolio-app:production` / `portfolio-app:latest`
### Dev Branch
- **Branch**: `dev`
- **Domain**: `https://dev.dk0.dev`
- **Port**: `3002`
- **Container**: `portfolio-app-staging`
- **Database**: `portfolio_staging_db` (port 5434)
- **Redis**: `portfolio-redis-staging` (port 6381)
- **Image Tag**: `portfolio-app:staging`
## Automatic Deployment
### How It Works
1. **Push to `production` branch**:
- Triggers `.gitea/workflows/production-deploy.yml`
- Runs tests, builds, and deploys to production
- Zero-downtime deployment (starts new container, waits for health, removes old)
2. **Push to `dev` branch**:
- Triggers `.gitea/workflows/dev-deploy.yml`
- Runs tests, builds, and deploys to dev/staging
- Zero-downtime deployment
### Zero-Downtime Process
1. Build new Docker image
2. Start new container with updated image
3. Wait for new container to be healthy (health checks)
4. Verify HTTP endpoints respond correctly
5. Remove old container (if different)
6. Cleanup old images
## Manual Deployment
### Production
```bash
# Build and deploy production
docker build -t portfolio-app:latest .
docker compose -f docker-compose.production.yml up -d --build
```
### Dev/Staging
```bash
# Build and deploy dev
docker build -t portfolio-app:staging .
docker compose -f docker-compose.staging.yml up -d --build
```
## Environment Variables
### Required Gitea Variables
- `NEXT_PUBLIC_BASE_URL` - Base URL for the application
- `MY_EMAIL` - Email address for contact
- `MY_INFO_EMAIL` - Info email address
- `LOG_LEVEL` - Logging level (info/debug)
### Required Gitea Secrets
- `MY_PASSWORD` - Email password
- `MY_INFO_PASSWORD` - Info email password
- `ADMIN_BASIC_AUTH` - Admin basic auth credentials
- `N8N_SECRET_TOKEN` - Optional: n8n webhook secret
### Optional Variables
- `N8N_WEBHOOK_URL` - n8n webhook URL for automation
## Health Checks
Both environments have health check endpoints:
- Production: `http://localhost:3000/api/health`
- Dev: `http://localhost:3002/api/health`
## Monitoring
### Check Container Status
```bash
# Production
docker compose -f docker-compose.production.yml ps
# Dev
docker compose -f docker-compose.staging.yml ps
```
### View Logs
```bash
# Production
docker logs portfolio-app --tail=100 -f
# Dev
docker logs portfolio-app-staging --tail=100 -f
```
### Health Check
```bash
# Production
curl http://localhost:3000/api/health
# Dev
curl http://localhost:3002/api/health
```
## Troubleshooting
### Container Won't Start
1. Check logs: `docker logs <container-name>`
2. Verify environment variables are set
3. Check database/redis connectivity
4. Verify ports aren't already in use
### Deployment Fails
1. Check Gitea Actions logs
2. Verify all required secrets/variables are set
3. Check if old containers are blocking ports
4. Verify Docker image builds successfully
### Zero-Downtime Issues
- Old container might still be running - check with `docker ps`
- Health checks might be failing - check container logs
- Port conflicts - verify ports 3000 and 3002 are available
## Rollback
If a deployment fails or causes issues:
```bash
# Production rollback
docker compose -f docker-compose.production.yml down
docker tag portfolio-app:previous portfolio-app:latest
docker compose -f docker-compose.production.yml up -d
# Dev rollback
docker compose -f docker-compose.staging.yml down
docker tag portfolio-app:staging-previous portfolio-app:staging
docker compose -f docker-compose.staging.yml up -d
```
## Best Practices
1. **Always test on dev branch first** before pushing to production
2. **Monitor health checks** after deployment
3. **Keep old images** for quick rollback (last 3 versions)
4. **Use feature flags** for new features
5. **Document breaking changes** before deploying
6. **Run tests locally** before pushing
## Network Configuration
- **Production Network**: `portfolio_net` + `proxy` (external)
- **Dev Network**: `portfolio_staging_net`
- **Isolation**: Complete separation ensures no interference
## Database Management
### Production Database
- **Container**: `portfolio-postgres`
- **Port**: `5432` (internal only)
- **Database**: `portfolio_db`
- **User**: `portfolio_user`
### Dev Database
- **Container**: `portfolio-postgres-staging`
- **Port**: `5434` (external), `5432` (internal)
- **Database**: `portfolio_staging_db`
- **User**: `portfolio_user`
## Redis Configuration
### Production Redis
- **Container**: `portfolio-redis`
- **Port**: `6379` (internal only)
### Dev Redis
- **Container**: `portfolio-redis-staging`
- **Port**: `6381` (external), `6379` (internal)

View File

@@ -1,236 +0,0 @@
# 🧪 Dev Branch Testing Guide
## Übersicht
Dieses Dokument erklärt, wie du dein Portfolio-Projekt auf dem `dev` Branch testen kannst, bevor du es in Production deployst.
## Voraussetzungen
1. ✅ n8n läuft bereits auf `n8n.dk0.dev`
2. ✅ Gitea Repository ist eingerichtet
3. ✅ Docker und Docker Compose sind installiert
## Setup für lokales Testen mit n8n
### 1. Environment Variables konfigurieren
Erstelle eine `.env.local` Datei (oder aktualisiere deine bestehende `.env`):
```bash
# n8n Integration
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_API_KEY=dein-n8n-api-key
N8N_SECRET_TOKEN=dein-n8n-secret-token
# Application
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Database (wird automatisch von docker-compose.dev.minimal.yml gesetzt)
# DATABASE_URL=postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public
# Redis (wird automatisch von docker-compose.dev.minimal.yml gesetzt)
# REDIS_URL=redis://localhost:6379
# Email Configuration
MY_EMAIL=contact@dk0.dev
MY_INFO_EMAIL=info@dk0.dev
MY_PASSWORD=dein-email-passwort
MY_INFO_PASSWORD=dein-info-email-passwort
# Analytics
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
# Security
ADMIN_BASIC_AUTH=admin:dein-sicheres-passwort
LOG_LEVEL=debug
```
### 2. Lokal testen
```bash
# 1. Starte Datenbank und Redis
npm run dev:minimal
# 2. In einem neuen Terminal: Starte die Next.js App
npm run dev
# 3. Öffne http://localhost:3000
```
### 3. n8n Webhook testen
Teste die Verbindung zu deinem n8n Server:
```bash
# Teste den Status Endpoint
curl https://n8n.dk0.dev/webhook/denshooter-71242/status
# Teste den Chat Endpoint (wenn konfiguriert)
curl -X POST https://n8n.dk0.dev/webhook/chat \
-H "Content-Type: application/json" \
-H "Authorization: Bearer DEIN_N8N_SECRET_TOKEN" \
-d '{"message": "Hallo", "history": []}'
```
## Staging Deployment auf dem Server
### 1. Gitea Variables und Secrets konfigurieren
Gehe zu deinem Gitea Repository → Settings → Secrets/Variables und füge hinzu:
**Variables:**
- `NEXT_PUBLIC_BASE_URL` = `https://staging.dk0.dev` (oder deine Staging URL)
- `MY_EMAIL` = `contact@dk0.dev`
- `MY_INFO_EMAIL` = `info@dk0.dev`
- `NEXT_PUBLIC_UMAMI_URL` = `https://analytics.dk0.dev`
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` = `b3665829-927a-4ada-b9bb-fcf24171061e`
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev`
- `LOG_LEVEL` = `debug`
**Secrets:**
- `MY_PASSWORD` = Dein Email Passwort
- `MY_INFO_PASSWORD` = Dein Info Email Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein-sicheres-passwort`
- `N8N_API_KEY` = Dein n8n API Key (optional)
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
### 2. Push zum dev Branch
```bash
# Stelle sicher, dass du auf dem dev Branch bist
git checkout dev
# Committe deine Änderungen
git add .
git commit -m "Test: Dev deployment"
# Push zum dev Branch (triggert automatisch Staging Deployment)
git push origin dev
```
### 3. Deployment überwachen
Nach dem Push:
1. Gehe zu deinem Gitea Repository → Actions
2. Überwache den Workflow `CI/CD Pipeline (Dev/Staging)`
3. Der Workflow wird:
- Tests ausführen
- Docker Image bauen
- Staging Container auf Port 3001 deployen
### 4. Staging testen
```bash
# Auf deinem Server: Prüfe Container Status
docker ps | grep staging
# Prüfe Health Endpoint
curl http://localhost:3001/api/health
# Prüfe n8n Status Endpoint
curl http://localhost:3001/api/n8n/status
# Logs ansehen
docker logs portfolio-app-staging -f
```
### 5. Staging URL konfigurieren
Falls du eine Subdomain für Staging hast (z.B. `staging.dk0.dev`):
- Konfiguriere deinen Reverse Proxy (Nginx/Traefik) um auf Port 3001 zu zeigen
- Oder verwende direkt `http://dein-server-ip:3001`
## Troubleshooting
### Dev Container wird nicht erstellt
1. **Prüfe Gitea Workflow:**
- Gehe zu Repository → Actions
- Prüfe ob der Workflow `ci-cd-dev-staging.yml` existiert
- Prüfe ob der Workflow auf `dev` Branch Push reagiert
2. **Prüfe Gitea Variables:**
- Stelle sicher, dass alle erforderlichen Variables und Secrets gesetzt sind
- Prüfe die Workflow Logs für fehlende Variablen
3. **Prüfe Docker:**
```bash
# Auf deinem Server
docker ps -a
docker images | grep portfolio-app
```
### n8n Verbindungsfehler
1. **Prüfe n8n URL:**
```bash
# Teste ob n8n erreichbar ist
curl https://n8n.dk0.dev/webhook/denshooter-71242/status
```
2. **Prüfe Environment Variables:**
```bash
# Im Container
docker exec portfolio-app-staging env | grep N8N
```
3. **Prüfe n8n Webhook Konfiguration:**
- Stelle sicher, dass der Webhook in n8n aktiviert ist
- Prüfe ob der Webhook-Pfad korrekt ist (`/webhook/denshooter-71242/status`)
### Datenbank Fehler
```bash
# Prüfe ob die Datenbank läuft
docker ps | grep postgres-staging
# Prüfe Datenbank Logs
docker logs portfolio-postgres-staging
# Prüfe Verbindung
docker exec portfolio-postgres-staging pg_isready -U portfolio_user -d portfolio_staging_db
```
## Workflow Übersicht
```
┌─────────────────┐
│ Push to dev │
└────────┬────────┘
┌─────────────────┐
│ Run Tests │
└────────┬────────┘
┌─────────────────┐
│ Build Docker │
│ Image (staging) │
└────────┬────────┘
┌─────────────────┐
│ Deploy Staging │
│ (Port 3001) │
└────────┬────────┘
┌─────────────────┐
│ Health Check │
└─────────────────┘
```
## Nächste Schritte
1. ✅ Teste lokal mit `npm run dev`
2. ✅ Konfiguriere Gitea Variables und Secrets
3. ✅ Push zum `dev` Branch
4. ✅ Teste Staging auf Port 3001
5. ✅ Wenn alles funktioniert: Merge zu `production` Branch
---
**Tipp:** Verwende `LOG_LEVEL=debug` in Staging um mehr Informationen zu sehen!

View File

@@ -1,150 +0,0 @@
# Docker Build Fix - Standalone Output Issue
## Problem
Der Docker Build schlägt fehl mit:
```
ERROR: failed to calculate checksum of ref ... "/app/.next/standalone/app": not found
```
## Ursache
Next.js erstellt das `standalone` Output nur, wenn:
1. `output: "standalone"` in `next.config.ts` gesetzt ist ✅ (bereits konfiguriert)
2. Der Build erfolgreich abgeschlossen wird
3. Alle Abhängigkeiten korrekt aufgelöst werden
## Lösung
### 1. n8n Status Route Fix
Die Route wurde angepasst, um während des Builds nicht zu fehlschlagen, wenn `N8N_WEBHOOK_URL` nicht gesetzt ist:
```typescript
// Prüft jetzt, ob N8N_WEBHOOK_URL gesetzt ist
if (!n8nWebhookUrl) {
return NextResponse.json({ /* fallback */ });
}
```
### 2. Dockerfile Verbesserungen
- **Verification Step**: Prüft, ob das standalone Verzeichnis existiert
- **Debug Output**: Zeigt die Verzeichnisstruktur, falls Probleme auftreten
- **Robustere Fehlerbehandlung**: Bessere Fehlermeldungen
### 3. Mögliche Ursachen und Lösungen
#### Problem: Standalone Output wird nicht erstellt
**Lösung 1: Prüfe next.config.ts**
```typescript
// Stelle sicher, dass dies gesetzt ist:
output: "standalone",
outputFileTracingRoot: path.join(process.cwd()),
```
**Lösung 2: Prüfe Build-Logs**
```bash
# Schaue in die Build-Logs, ob es Fehler gibt
docker build . 2>&1 | grep -i "standalone\|error"
```
**Lösung 3: Lokaler Test**
```bash
# Teste lokal, ob standalone erstellt wird
npm run build
ls -la .next/standalone/
```
#### Problem: Falsche Verzeichnisstruktur
**✅ GELÖST**: Die Debug-Ausgabe zeigt, dass Next.js 15 die Struktur `.next/standalone/` direkt verwendet:
- `.next/standalone/server.js`
- `.next/standalone/.next/`
- `.next/standalone/node_modules/`
- `.next/standalone/package.json`
**NICHT**: `.next/standalone/app/server.js`
Das Dockerfile wurde korrigiert, um `.next/standalone/` direkt zu kopieren.
## Debugging
### 1. Lokaler Build Test
```bash
# Baue lokal
npm run build
# Prüfe ob standalone existiert
test -d .next/standalone && echo "✅ Standalone exists" || echo "❌ Standalone missing"
# Zeige Struktur
ls -la .next/standalone/
find .next/standalone -name "server.js"
```
### 2. Docker Build mit Debug
```bash
# Baue mit mehr Output
docker build --progress=plain -t portfolio-app:test .
# Oder baue nur bis zum Builder Stage
docker build --target builder -t portfolio-builder:test .
docker run --rm portfolio-builder:test ls -la .next/standalone/
```
### 3. Prüfe Build-Logs
Der aktualisierte Dockerfile gibt jetzt Debug-Informationen aus:
- Zeigt `.next/` Verzeichnisstruktur
- Sucht nach `standalone` Verzeichnis
- Zeigt `server.js` Location
## Alternative: Fallback ohne Standalone
Falls das standalone Output weiterhin Probleme macht, kann man auf ein vollständiges Image zurückgreifen:
```dockerfile
# Statt standalone zu kopieren, kopiere alles
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
CMD ["npm", "start"]
```
**Nachteil**: Größeres Image, aber funktioniert immer.
## Nächste Schritte
1. ✅ n8n Status Route Fix (bereits gemacht)
2. ✅ Dockerfile Debug-Verbesserungen (bereits gemacht)
3. 🔄 Push zum dev Branch und Build testen
4. 📊 Build-Logs analysieren
5. 🔧 Falls nötig: Dockerfile weiter anpassen
## Workflow Test
```bash
# 1. Committe Änderungen
git add .
git commit -m "Fix: Docker build standalone output issue"
# 2. Push zum dev Branch
git push origin dev
# 3. Überwache Gitea Actions
# Gehe zu Repository → Actions → CI/CD Pipeline (Dev/Staging)
# 4. Prüfe Build-Logs
# Schaue nach den Debug-Ausgaben im Build-Step
```
---
**Hinweis**: Falls das Problem weiterhin besteht, schaue in die Build-Logs nach den Debug-Ausgaben, die der aktualisierte Dockerfile jetzt ausgibt. Diese zeigen genau, wo das Problem liegt.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
# Production Docker Compose configuration for dk0.dev # Production Docker Compose configuration for dk0.dev
# Optimized for production deployment # Optimized for production deployment with zero-downtime support
version: '3.8'
services: services:
portfolio: portfolio:

View File

@@ -13,7 +13,7 @@ services:
- NODE_ENV=staging - NODE_ENV=staging
- DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public - DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public
- REDIS_URL=redis://redis-staging:6379 - REDIS_URL=redis://redis-staging:6379
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev} - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dev.dk0.dev}
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev} - MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev} - MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
- MY_PASSWORD=${MY_PASSWORD} - MY_PASSWORD=${MY_PASSWORD}

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

View File

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