Compare commits
1 Commits
1e950823e1
...
dev_n8n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a18676e5 |
@@ -1,64 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs
|
||||
!README.md
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
.gitea
|
||||
.github
|
||||
|
||||
# Scripts (keep only essential ones)
|
||||
scripts
|
||||
!scripts/init-db.sql
|
||||
|
||||
# Misc
|
||||
.cache
|
||||
.temp
|
||||
tmp
|
||||
232
.gitea/workflows/ci-cd-woodpecker.yml
Normal file
232
.gitea/workflows/ci-cd-woodpecker.yml
Normal file
@@ -0,0 +1,232 @@
|
||||
name: CI/CD Pipeline (Woodpecker)
|
||||
|
||||
when:
|
||||
event: push
|
||||
branch: production
|
||||
|
||||
steps:
|
||||
build:
|
||||
image: node:20-alpine
|
||||
commands:
|
||||
- echo "🚀 Starting CI/CD Pipeline"
|
||||
- echo "📋 Step 1: Installing dependencies..."
|
||||
- npm ci --prefer-offline --no-audit
|
||||
- echo "🔍 Step 2: Running linting..."
|
||||
- npm run lint
|
||||
- echo "🧪 Step 3: Running tests..."
|
||||
- npm run test
|
||||
- echo "🏗️ Step 4: Building application..."
|
||||
- npm run build
|
||||
- echo "🔒 Step 5: Running security scan..."
|
||||
- npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
|
||||
volumes:
|
||||
- node_modules:/app/node_modules
|
||||
|
||||
docker-build:
|
||||
image: docker:latest
|
||||
commands:
|
||||
- echo "🐳 Building Docker image..."
|
||||
- docker build -t portfolio-app:latest .
|
||||
- docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
deploy:
|
||||
image: docker:latest
|
||||
commands:
|
||||
- echo "🚀 Deploying application..."
|
||||
|
||||
# Verify secrets and variables
|
||||
- echo "🔍 Verifying secrets and variables..."
|
||||
- |
|
||||
if [ -z "$NEXT_PUBLIC_BASE_URL" ]; then
|
||||
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$MY_EMAIL" ]; then
|
||||
echo "❌ MY_EMAIL variable is missing!"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$MY_INFO_EMAIL" ]; then
|
||||
echo "❌ MY_INFO_EMAIL variable is missing!"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$MY_PASSWORD" ]; then
|
||||
echo "❌ MY_PASSWORD secret is missing!"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$MY_INFO_PASSWORD" ]; then
|
||||
echo "❌ MY_INFO_PASSWORD secret is missing!"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ADMIN_BASIC_AUTH" ]; then
|
||||
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All required secrets and variables are present"
|
||||
|
||||
# Check if current container is running
|
||||
- |
|
||||
if docker ps -q -f name=portfolio-app | grep -q .; then
|
||||
echo "📊 Current container is running, proceeding with zero-downtime update"
|
||||
CURRENT_CONTAINER_RUNNING=true
|
||||
else
|
||||
echo "📊 No current container running, doing fresh deployment"
|
||||
CURRENT_CONTAINER_RUNNING=false
|
||||
fi
|
||||
|
||||
# Ensure database and redis are running
|
||||
- echo "🔧 Ensuring database and redis are running..."
|
||||
- docker compose up -d postgres redis
|
||||
- sleep 10
|
||||
|
||||
# Deploy with zero downtime
|
||||
- |
|
||||
if [ "$CURRENT_CONTAINER_RUNNING" = "true" ]; then
|
||||
echo "🔄 Performing rolling update..."
|
||||
|
||||
# Generate unique container name
|
||||
TIMESTAMP=$(date +%s)
|
||||
TEMP_CONTAINER_NAME="portfolio-app-temp-$TIMESTAMP"
|
||||
echo "🔧 Using temporary container name: $TEMP_CONTAINER_NAME"
|
||||
|
||||
# Clean up any existing temporary containers
|
||||
echo "🧹 Cleaning up any existing temporary containers..."
|
||||
docker rm -f portfolio-app-new portfolio-app-temp-* portfolio-app-backup || true
|
||||
|
||||
# Find and remove any containers with portfolio-app in the name (except the main one)
|
||||
EXISTING_CONTAINERS=$(docker ps -a --format "table {{.Names}}" | grep "portfolio-app" | grep -v "^portfolio-app$" || true)
|
||||
if [ -n "$EXISTING_CONTAINERS" ]; then
|
||||
echo "🗑️ Removing existing portfolio-app containers:"
|
||||
echo "$EXISTING_CONTAINERS"
|
||||
echo "$EXISTING_CONTAINERS" | xargs -r docker rm -f || true
|
||||
fi
|
||||
|
||||
# Also clean up any stopped containers
|
||||
docker container prune -f || true
|
||||
|
||||
# Start new container with unique temporary name
|
||||
docker run -d \
|
||||
--name $TEMP_CONTAINER_NAME \
|
||||
--restart unless-stopped \
|
||||
--network portfolio_net \
|
||||
-e NODE_ENV=$NODE_ENV \
|
||||
-e LOG_LEVEL=$LOG_LEVEL \
|
||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
||||
-e REDIS_URL=redis://redis:6379 \
|
||||
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
|
||||
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
||||
-e MY_EMAIL="$MY_EMAIL" \
|
||||
-e MY_INFO_EMAIL="$MY_INFO_EMAIL" \
|
||||
-e MY_PASSWORD="$MY_PASSWORD" \
|
||||
-e MY_INFO_PASSWORD="$MY_INFO_PASSWORD" \
|
||||
-e ADMIN_BASIC_AUTH="$ADMIN_BASIC_AUTH" \
|
||||
portfolio-app:latest
|
||||
|
||||
# Wait for new container to be ready
|
||||
echo "⏳ Waiting for new container to be ready..."
|
||||
sleep 15
|
||||
|
||||
# Health check new container
|
||||
for i in {1..20}; do
|
||||
if docker exec $TEMP_CONTAINER_NAME curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
echo "✅ New container is healthy!"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Health check attempt $i/20..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Stop old container
|
||||
echo "🛑 Stopping old container..."
|
||||
docker stop portfolio-app || true
|
||||
docker rm portfolio-app || true
|
||||
|
||||
# Rename new container
|
||||
docker rename $TEMP_CONTAINER_NAME portfolio-app
|
||||
|
||||
# Update port mapping
|
||||
docker stop portfolio-app
|
||||
docker rm portfolio-app
|
||||
|
||||
# Start with correct port
|
||||
docker run -d \
|
||||
--name portfolio-app \
|
||||
--restart unless-stopped \
|
||||
--network portfolio_net \
|
||||
-p 3000:3000 \
|
||||
-e NODE_ENV=$NODE_ENV \
|
||||
-e LOG_LEVEL=$LOG_LEVEL \
|
||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
||||
-e REDIS_URL=redis://redis:6379 \
|
||||
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
|
||||
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
||||
-e MY_EMAIL="$MY_EMAIL" \
|
||||
-e MY_INFO_EMAIL="$MY_INFO_EMAIL" \
|
||||
-e MY_PASSWORD="$MY_PASSWORD" \
|
||||
-e MY_INFO_PASSWORD="$MY_INFO_PASSWORD" \
|
||||
-e ADMIN_BASIC_AUTH="$ADMIN_BASIC_AUTH" \
|
||||
portfolio-app:latest
|
||||
|
||||
echo "✅ Rolling update completed!"
|
||||
else
|
||||
echo "🆕 Fresh deployment..."
|
||||
docker compose up -d
|
||||
fi
|
||||
|
||||
# Wait for container to be ready
|
||||
- echo "⏳ Waiting for container to be ready..."
|
||||
- sleep 15
|
||||
|
||||
# Health check
|
||||
- |
|
||||
echo "🏥 Performing health check..."
|
||||
for i in {1..40}; do
|
||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Application is healthy!"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Health check attempt $i/40..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Final verification
|
||||
- echo "🔍 Final health verification..."
|
||||
- docker ps --filter "name=portfolio-app" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
- |
|
||||
if curl -f http://localhost:3000/api/health; then
|
||||
echo "✅ Health endpoint accessible"
|
||||
else
|
||||
echo "❌ Health endpoint not accessible"
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
if curl -f http://localhost:3000/ > /dev/null; then
|
||||
echo "✅ Main page is accessible"
|
||||
else
|
||||
echo "❌ Main page is not accessible"
|
||||
exit 1
|
||||
fi
|
||||
- echo "✅ Deployment successful!"
|
||||
|
||||
# Cleanup
|
||||
- docker image prune -f
|
||||
- docker system prune -f
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- NODE_ENV
|
||||
- LOG_LEVEL
|
||||
- NEXT_PUBLIC_BASE_URL
|
||||
- NEXT_PUBLIC_UMAMI_URL
|
||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
- MY_EMAIL
|
||||
- MY_INFO_EMAIL
|
||||
- MY_PASSWORD
|
||||
- MY_INFO_PASSWORD
|
||||
- ADMIN_BASIC_AUTH
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
123
.gitea/workflows/debug-secrets.yml
Normal file
123
.gitea/workflows/debug-secrets.yml
Normal file
@@ -0,0 +1,123 @@
|
||||
name: Debug Secrets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
debug-secrets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Debug Environment Variables
|
||||
run: |
|
||||
echo "🔍 Checking if secrets are available..."
|
||||
echo ""
|
||||
|
||||
echo "📊 VARIABLES:"
|
||||
echo "✅ NODE_ENV: ${{ vars.NODE_ENV }}"
|
||||
echo "✅ LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
|
||||
echo "✅ NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
echo "✅ NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
|
||||
echo "✅ NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
|
||||
echo "✅ MY_EMAIL: ${{ vars.MY_EMAIL }}"
|
||||
echo "✅ MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}"
|
||||
|
||||
echo ""
|
||||
echo "🔐 SECRETS:"
|
||||
if [ -n "${{ secrets.MY_PASSWORD }}" ]; then
|
||||
echo "✅ MY_PASSWORD: Set (length: ${#MY_PASSWORD})"
|
||||
else
|
||||
echo "❌ MY_PASSWORD: Not set"
|
||||
fi
|
||||
|
||||
if [ -n "${{ secrets.MY_INFO_PASSWORD }}" ]; then
|
||||
echo "✅ MY_INFO_PASSWORD: Set (length: ${#MY_INFO_PASSWORD})"
|
||||
else
|
||||
echo "❌ MY_INFO_PASSWORD: Not set"
|
||||
fi
|
||||
|
||||
if [ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
|
||||
echo "✅ ADMIN_BASIC_AUTH: Set (length: ${#ADMIN_BASIC_AUTH})"
|
||||
else
|
||||
echo "❌ ADMIN_BASIC_AUTH: Not set"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📋 Summary:"
|
||||
echo "Variables: 7 configured"
|
||||
echo "Secrets: 3 configured"
|
||||
echo "Total environment variables: 10"
|
||||
env:
|
||||
NODE_ENV: ${{ vars.NODE_ENV }}
|
||||
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
|
||||
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||
|
||||
- name: Test Docker Environment
|
||||
run: |
|
||||
echo "🐳 Testing Docker environment with secrets..."
|
||||
|
||||
# Create a test container to verify environment variables
|
||||
docker run --rm \
|
||||
-e NODE_ENV=production \
|
||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
||||
-e REDIS_URL=redis://redis:6379 \
|
||||
-e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
||||
-e MY_EMAIL="${{ secrets.MY_EMAIL }}" \
|
||||
-e MY_INFO_EMAIL="${{ secrets.MY_INFO_EMAIL }}" \
|
||||
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
|
||||
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
|
||||
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
|
||||
alpine:latest sh -c '
|
||||
echo "Environment variables in container:"
|
||||
echo "NODE_ENV: $NODE_ENV"
|
||||
echo "DATABASE_URL: $DATABASE_URL"
|
||||
echo "REDIS_URL: $REDIS_URL"
|
||||
echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL"
|
||||
echo "MY_EMAIL: $MY_EMAIL"
|
||||
echo "MY_INFO_EMAIL: $MY_INFO_EMAIL"
|
||||
echo "MY_PASSWORD: [HIDDEN - length: ${#MY_PASSWORD}]"
|
||||
echo "MY_INFO_PASSWORD: [HIDDEN - length: ${#MY_INFO_PASSWORD}]"
|
||||
echo "ADMIN_BASIC_AUTH: [HIDDEN - length: ${#ADMIN_BASIC_AUTH}]"
|
||||
'
|
||||
|
||||
- name: Validate Secret Formats
|
||||
run: |
|
||||
echo "🔐 Validating secret formats..."
|
||||
|
||||
# Check NEXT_PUBLIC_BASE_URL format
|
||||
if [[ "${{ secrets.NEXT_PUBLIC_BASE_URL }}" =~ ^https?:// ]]; then
|
||||
echo "✅ NEXT_PUBLIC_BASE_URL: Valid URL format"
|
||||
else
|
||||
echo "❌ NEXT_PUBLIC_BASE_URL: Invalid URL format (should start with http:// or https://)"
|
||||
fi
|
||||
|
||||
# Check email formats
|
||||
if [[ "${{ secrets.MY_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||||
echo "✅ MY_EMAIL: Valid email format"
|
||||
else
|
||||
echo "❌ MY_EMAIL: Invalid email format"
|
||||
fi
|
||||
|
||||
if [[ "${{ secrets.MY_INFO_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||||
echo "✅ MY_INFO_EMAIL: Valid email format"
|
||||
else
|
||||
echo "❌ MY_INFO_EMAIL: Invalid email format"
|
||||
fi
|
||||
|
||||
# Check ADMIN_BASIC_AUTH format (should be username:password)
|
||||
if [[ "${{ secrets.ADMIN_BASIC_AUTH }}" =~ ^[^:]+:.+$ ]]; then
|
||||
echo "✅ ADMIN_BASIC_AUTH: Valid format (username:password)"
|
||||
else
|
||||
echo "❌ ADMIN_BASIC_AUTH: Invalid format (should be username:password)"
|
||||
fi
|
||||
@@ -1,132 +0,0 @@
|
||||
name: Dev Deployment (Zero Downtime)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ dev ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
DOCKER_IMAGE: portfolio-app
|
||||
IMAGE_TAG: staging
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
continue-on-error: true # Don't block dev deployments on lint errors
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
continue-on-error: true # Don't block dev deployments on test failures
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "🏗️ Building dev Docker image with BuildKit cache..."
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
||||
.
|
||||
echo "✅ Docker image built successfully"
|
||||
|
||||
- name: Zero-Downtime Dev Deployment
|
||||
run: |
|
||||
echo "🚀 Starting zero-downtime dev deployment..."
|
||||
|
||||
COMPOSE_FILE="docker-compose.staging.yml"
|
||||
CONTAINER_NAME="portfolio-app-staging"
|
||||
HEALTH_PORT="3002"
|
||||
|
||||
# Backup current container ID if running
|
||||
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
|
||||
|
||||
# Start new container with updated image
|
||||
echo "🆕 Starting new dev container..."
|
||||
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
|
||||
|
||||
# Wait for new container to be healthy
|
||||
echo "⏳ Waiting for new container to be healthy..."
|
||||
for i in {1..60}; do
|
||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
||||
# Check health status
|
||||
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||
if [ "$HEALTH" == "healthy" ]; then
|
||||
echo "✅ New container is healthy!"
|
||||
break
|
||||
fi
|
||||
# Also check HTTP health endpoint
|
||||
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||
echo "✅ New container is responding!"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
echo "⏳ Waiting... ($i/60)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify new container is working
|
||||
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging
|
||||
fi
|
||||
|
||||
# Remove old container if it exists and is different
|
||||
if [ ! -z "$OLD_CONTAINER" ]; then
|
||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
||||
echo "🧹 Removing old container..."
|
||||
docker stop $OLD_CONTAINER 2>/dev/null || true
|
||||
docker rm $OLD_CONTAINER 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Dev deployment completed!"
|
||||
env:
|
||||
NODE_ENV: staging
|
||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
|
||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||
|
||||
- name: Dev Health Check
|
||||
run: |
|
||||
echo "🔍 Running dev health checks..."
|
||||
for i in {1..20}; do
|
||||
if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then
|
||||
echo "✅ Dev is fully operational!"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ Waiting for dev... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f docker-compose.staging.yml logs --tail=50
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
echo "🧹 Cleaning up old images..."
|
||||
docker image prune -f
|
||||
echo "✅ Cleanup completed"
|
||||
@@ -1,135 +0,0 @@
|
||||
name: Production Deployment (Zero Downtime)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ production ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
DOCKER_IMAGE: portfolio-app
|
||||
IMAGE_TAG: production
|
||||
|
||||
jobs:
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run linting and tests in parallel
|
||||
run: |
|
||||
npm run lint &
|
||||
LINT_PID=$!
|
||||
npm run test:production &
|
||||
TEST_PID=$!
|
||||
wait $LINT_PID $TEST_PID
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "🏗️ Building production Docker image with BuildKit cache..."
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
||||
-t ${{ env.DOCKER_IMAGE }}:latest \
|
||||
.
|
||||
echo "✅ Docker image built successfully"
|
||||
|
||||
- name: Zero-Downtime Production Deployment
|
||||
run: |
|
||||
echo "🚀 Starting zero-downtime production deployment..."
|
||||
|
||||
COMPOSE_FILE="docker-compose.production.yml"
|
||||
CONTAINER_NAME="portfolio-app"
|
||||
HEALTH_PORT="3000"
|
||||
|
||||
# Backup current container ID if running
|
||||
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"
|
||||
41
.gitea/workflows/test-and-build.yml
Normal file
41
.gitea/workflows/test-and-build.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Test and Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
- name: Run security scan
|
||||
run: |
|
||||
echo "🔍 Running npm audit..."
|
||||
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
|
||||
105
.gitea/workflows/test-gitea-variables.yml
Normal file
105
.gitea/workflows/test-gitea-variables.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Test Gitea Variables and Secrets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ production ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-variables:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Test Variables and Secrets Access
|
||||
run: |
|
||||
echo "🔍 Testing Gitea Variables and Secrets access..."
|
||||
|
||||
# Test Variables
|
||||
echo "📝 Testing Variables:"
|
||||
echo "NEXT_PUBLIC_BASE_URL: '${{ vars.NEXT_PUBLIC_BASE_URL }}'"
|
||||
echo "MY_EMAIL: '${{ vars.MY_EMAIL }}'"
|
||||
echo "MY_INFO_EMAIL: '${{ vars.MY_INFO_EMAIL }}'"
|
||||
echo "NODE_ENV: '${{ vars.NODE_ENV }}'"
|
||||
echo "LOG_LEVEL: '${{ vars.LOG_LEVEL }}'"
|
||||
echo "NEXT_PUBLIC_UMAMI_URL: '${{ vars.NEXT_PUBLIC_UMAMI_URL }}'"
|
||||
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID: '${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}'"
|
||||
|
||||
# Test Secrets (without revealing values)
|
||||
echo ""
|
||||
echo "🔐 Testing Secrets:"
|
||||
echo "MY_PASSWORD: '$([ -n "${{ secrets.MY_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
||||
echo "MY_INFO_PASSWORD: '$([ -n "${{ secrets.MY_INFO_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
||||
echo "ADMIN_BASIC_AUTH: '$([ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
||||
|
||||
# Check if variables are empty
|
||||
echo ""
|
||||
echo "🔍 Checking for empty variables:"
|
||||
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
|
||||
echo "❌ NEXT_PUBLIC_BASE_URL is empty or not set"
|
||||
else
|
||||
echo "✅ NEXT_PUBLIC_BASE_URL is set"
|
||||
fi
|
||||
|
||||
if [ -z "${{ vars.MY_EMAIL }}" ]; then
|
||||
echo "❌ MY_EMAIL is empty or not set"
|
||||
else
|
||||
echo "✅ MY_EMAIL is set"
|
||||
fi
|
||||
|
||||
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
|
||||
echo "❌ MY_INFO_EMAIL is empty or not set"
|
||||
else
|
||||
echo "✅ MY_INFO_EMAIL is set"
|
||||
fi
|
||||
|
||||
# Check secrets
|
||||
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
|
||||
echo "❌ MY_PASSWORD secret is empty or not set"
|
||||
else
|
||||
echo "✅ MY_PASSWORD secret is set"
|
||||
fi
|
||||
|
||||
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
|
||||
echo "❌ MY_INFO_PASSWORD secret is empty or not set"
|
||||
else
|
||||
echo "✅ MY_INFO_PASSWORD secret is set"
|
||||
fi
|
||||
|
||||
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
|
||||
echo "❌ ADMIN_BASIC_AUTH secret is empty or not set"
|
||||
else
|
||||
echo "✅ ADMIN_BASIC_AUTH secret is set"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 Summary:"
|
||||
echo "Variables set: $(echo '${{ vars.NEXT_PUBLIC_BASE_URL }}' | wc -c)"
|
||||
echo "Secrets set: $(echo '${{ secrets.MY_PASSWORD }}' | wc -c)"
|
||||
|
||||
- name: Test Environment Variable Export
|
||||
run: |
|
||||
echo "🧪 Testing environment variable export..."
|
||||
|
||||
# Export variables as environment variables
|
||||
export NODE_ENV="${{ vars.NODE_ENV }}"
|
||||
export LOG_LEVEL="${{ vars.LOG_LEVEL }}"
|
||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
export NEXT_PUBLIC_UMAMI_URL="${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
|
||||
export NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
|
||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||
|
||||
echo "📝 Exported environment variables:"
|
||||
echo "NODE_ENV: ${NODE_ENV:-[NOT SET]}"
|
||||
echo "LOG_LEVEL: ${LOG_LEVEL:-[NOT SET]}"
|
||||
echo "NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-[NOT SET]}"
|
||||
echo "MY_EMAIL: ${MY_EMAIL:-[NOT SET]}"
|
||||
echo "MY_INFO_EMAIL: ${MY_INFO_EMAIL:-[NOT SET]}"
|
||||
echo "MY_PASSWORD: $([ -n "${MY_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
|
||||
echo "MY_INFO_PASSWORD: $([ -n "${MY_INFO_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
|
||||
echo "ADMIN_BASIC_AUTH: $([ -n "${ADMIN_BASIC_AUTH}" ] && echo "[SET]" || echo "[NOT SET]")"
|
||||
85
AUTO_DEPLOYMENT_STATUS.md
Normal file
85
AUTO_DEPLOYMENT_STATUS.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 🚀 Auto-Deployment Status
|
||||
|
||||
## Current Setup
|
||||
|
||||
### GitHub Actions Workflow (`.github/workflows/ci-cd.yml`)
|
||||
|
||||
**Triggers on**: Push to `main` OR `production` branches
|
||||
|
||||
**What happens on `main` branch**:
|
||||
- ✅ Runs tests
|
||||
- ✅ Runs linting
|
||||
- ✅ Builds Docker image
|
||||
- ✅ Pushes image to registry
|
||||
- ❌ **Does NOT deploy to server**
|
||||
|
||||
**What happens on `production` branch**:
|
||||
- ✅ Runs tests
|
||||
- ✅ Runs linting
|
||||
- ✅ Builds Docker image
|
||||
- ✅ Pushes image to registry
|
||||
- ✅ **Deploys to server automatically**
|
||||
|
||||
### Key Line in Workflow
|
||||
|
||||
```yaml
|
||||
# Line 159 in .github/workflows/ci-cd.yml
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
||||
```
|
||||
|
||||
This means deployment **only** happens on `production` branch.
|
||||
|
||||
## Answer: Can you merge to main and auto-deploy?
|
||||
|
||||
**❌ NO** - Merging to `main` will:
|
||||
- Build and test everything
|
||||
- Create Docker image
|
||||
- **But NOT deploy to your server**
|
||||
|
||||
**✅ YES** - Merging to `production` will:
|
||||
- Build and test everything
|
||||
- Create Docker image
|
||||
- **AND deploy to your server automatically**
|
||||
|
||||
## Options
|
||||
|
||||
### Option 1: Use Production Branch (Current Setup)
|
||||
```bash
|
||||
# Merge dev → main (tests/build only)
|
||||
git checkout main
|
||||
git merge dev
|
||||
git push origin main
|
||||
|
||||
# Then merge main → production (auto-deploys)
|
||||
git checkout production
|
||||
git merge main
|
||||
git push origin production # ← This triggers deployment
|
||||
```
|
||||
|
||||
### Option 2: Enable Auto-Deploy on Main
|
||||
If you want `main` to auto-deploy, I can update the workflow to deploy on `main` as well.
|
||||
|
||||
### Option 3: Manual Deployment
|
||||
After merging to `main`, manually run:
|
||||
```bash
|
||||
./scripts/gitea-deploy.sh
|
||||
# or
|
||||
./scripts/auto-deploy.sh
|
||||
```
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep current setup** (deploy only on `production`):
|
||||
- ✅ Safer: `main` is for testing builds
|
||||
- ✅ `production` is explicitly for deployments
|
||||
- ✅ Can test on `main` without deploying
|
||||
- ✅ Clear separation of concerns
|
||||
|
||||
**Workflow**:
|
||||
1. Merge `dev` → `main` (validates build works)
|
||||
2. Test the built image if needed
|
||||
3. Merge `main` → `production` (auto-deploys)
|
||||
|
||||
---
|
||||
|
||||
**Current Status**: Auto-deployment is configured, but only for `production` branch.
|
||||
66
CLEANUP_PLAN.md
Normal file
66
CLEANUP_PLAN.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 🧹 Codebase Cleanup Plan
|
||||
|
||||
## MD Files Analysis
|
||||
|
||||
### ✅ KEEP (Essential Documentation)
|
||||
1. **README.md** - Main project documentation
|
||||
2. **docs/ai-image-generation/README.md** - AI feature docs
|
||||
3. **docs/ai-image-generation/SETUP.md** - Setup guide
|
||||
4. **docs/ai-image-generation/QUICKSTART.md** - Quick start
|
||||
5. **docs/ai-image-generation/WEBHOOK_SETUP.md** - Webhook setup (just created)
|
||||
6. **TESTING_GUIDE.md** - Testing documentation
|
||||
7. **SAFE_PUSH_TO_MAIN.md** - Deployment guide
|
||||
8. **AUTO_DEPLOYMENT_STATUS.md** - Deployment status (just created)
|
||||
|
||||
### ❌ REMOVE (Old/Duplicate/Outdated)
|
||||
1. **CHANGELOG_DEV.md** - Old changelog, can be in git history
|
||||
2. **PUSH_READY.md** - One-time status file
|
||||
3. **COMMIT_MESSAGE.txt** - One-time commit message
|
||||
4. **DEPLOYMENT-FIXES.md** - Old fixes, should be in git
|
||||
5. **DEPLOYMENT-IMPROVEMENTS.md** - Old improvements
|
||||
6. **DEPLOYMENT.md** - Duplicate of PRODUCTION-DEPLOYMENT.md
|
||||
7. **AFTER_PUSH_SETUP.md** - One-time setup guide
|
||||
8. **PRE_PUSH_CHECKLIST.md** - Can merge into SAFE_PUSH_TO_MAIN.md
|
||||
9. **TEST_FIXES.md** - One-time fix notes
|
||||
10. **AUTOMATED_TESTING_SETUP.md** - Info now in TESTING_GUIDE.md
|
||||
11. **SECURITY-UPDATE.md** - Old update notes
|
||||
12. **SECURITY-CHECKLIST.md** - Can merge into SECURITY.md
|
||||
13. **ANALYTICS.md** - If not actively used
|
||||
14. **PRODUCTION-DEPLOYMENT.md** - If DEPLOYMENT.md covers it
|
||||
|
||||
### 📁 CONSOLIDATE (Merge into main docs)
|
||||
- **docs/IMPROVEMENTS_SUMMARY.md** → Merge into README or remove
|
||||
- **docs/CODING_DETECTION_DEBUG.md** → Remove if not needed
|
||||
- **docs/DYNAMIC_ACTIVITY_MANAGEMENT.md** → Keep if actively used
|
||||
- **docs/ACTIVITY_FEATURES.md** → Keep if actively used
|
||||
- **docs/N8N_CHAT_SETUP.md** → Keep if using n8n chat
|
||||
- **docs/N8N_INTEGRATION.md** → Keep if using n8n
|
||||
|
||||
## Old/Unused Files to Remove
|
||||
|
||||
### Scripts (Many duplicates)
|
||||
- `scripts/test-fix.sh` - One-time fix
|
||||
- `scripts/test-deployment.sh` - One-time test
|
||||
- `scripts/quick-health-fix.sh` - One-time fix
|
||||
- `scripts/fix-connection.sh` - One-time fix
|
||||
- `scripts/debug-gitea-actions.sh` - Debug script, not needed
|
||||
- Multiple docker-compose files (keep only needed ones)
|
||||
|
||||
### Disabled Workflows
|
||||
- `.gitea/workflows/*.disabled` - Remove all disabled workflows
|
||||
|
||||
### Old Test Results
|
||||
- `test-results/` - Can be regenerated
|
||||
- `playwright-report/` - Can be regenerated
|
||||
|
||||
### Logs
|
||||
- `logs/*.log` - Should be in .gitignore
|
||||
|
||||
## Git Remote Issue
|
||||
Current: `https://git.dk0.dev/denshooter/portfolio`
|
||||
Issue: Can't connect to git.dk0.dev:443
|
||||
|
||||
Options:
|
||||
1. Check if server is up
|
||||
2. Use SSH instead: `git@git.dk0.dev:denshooter/portfolio.git`
|
||||
3. Check if URL changed
|
||||
95
CLEANUP_SUMMARY.md
Normal file
95
CLEANUP_SUMMARY.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 🧹 Cleanup Summary
|
||||
|
||||
## Files Removed
|
||||
|
||||
### Documentation (15 files)
|
||||
- ✅ CHANGELOG_DEV.md - Old changelog
|
||||
- ✅ PUSH_READY.md - One-time status
|
||||
- ✅ COMMIT_MESSAGE.txt - One-time commit message
|
||||
- ✅ DEPLOYMENT-FIXES.md - Old fixes
|
||||
- ✅ DEPLOYMENT-IMPROVEMENTS.md - Old improvements
|
||||
- ✅ DEPLOYMENT.md - Duplicate
|
||||
- ✅ AFTER_PUSH_SETUP.md - One-time setup
|
||||
- ✅ PRE_PUSH_CHECKLIST.md - Merged into SAFE_PUSH_TO_MAIN.md
|
||||
- ✅ TEST_FIXES.md - One-time fixes
|
||||
- ✅ AUTOMATED_TESTING_SETUP.md - Info in TESTING_GUIDE.md
|
||||
- ✅ SECURITY-UPDATE.md - Old update
|
||||
- ✅ SECURITY-CHECKLIST.md - Merged into SECURITY.md
|
||||
- ✅ PRODUCTION-DEPLOYMENT.md - Duplicate
|
||||
- ✅ ANALYTICS.md - Not actively used
|
||||
- ✅ docs/IMPROVEMENTS_SUMMARY.md - Old summary
|
||||
- ✅ docs/CODING_DETECTION_DEBUG.md - Debug notes
|
||||
|
||||
### Scripts (4 files)
|
||||
- ✅ scripts/quick-health-fix.sh - One-time fix
|
||||
- ✅ scripts/fix-connection.sh - One-time fix
|
||||
- ✅ scripts/debug-gitea-actions.sh - Debug script
|
||||
|
||||
### Workflows (7 files)
|
||||
- ✅ .gitea/workflows/*.disabled - All disabled workflows removed
|
||||
|
||||
### Docker Configs (2 files)
|
||||
- ✅ docker-compose.zero-downtime.yml - Old version
|
||||
- ✅ docker-compose.zero-downtime-fixed.yml - Old version
|
||||
- ✅ nginx-zero-downtime.conf - Unused
|
||||
|
||||
## Files Kept (Essential)
|
||||
|
||||
### Documentation
|
||||
- ✅ README.md - Main docs
|
||||
- ✅ DEV-SETUP.md - Setup guide
|
||||
- ✅ SECURITY.md - Security info
|
||||
- ✅ TESTING_GUIDE.md - Testing docs
|
||||
- ✅ SAFE_PUSH_TO_MAIN.md - Deployment guide
|
||||
- ✅ AUTO_DEPLOYMENT_STATUS.md - Deployment status
|
||||
- ✅ docs/ai-image-generation/* - AI feature docs
|
||||
- ✅ docs/ACTIVITY_FEATURES.md - Activity features
|
||||
- ✅ docs/DYNAMIC_ACTIVITY_MANAGEMENT.md - Activity management
|
||||
- ✅ docs/N8N_CHAT_SETUP.md - n8n chat setup
|
||||
- ✅ docs/N8N_INTEGRATION.md - n8n integration
|
||||
|
||||
### Docker Configs
|
||||
- ✅ docker-compose.yml - Main config
|
||||
- ✅ docker-compose.production.yml - Production
|
||||
- ✅ docker-compose.dev.minimal.yml - Dev minimal
|
||||
|
||||
## Git Remote Fixed
|
||||
|
||||
**Before**: `https://git.dk0.dev/denshooter/portfolio` (HTTPS - connection issues)
|
||||
**After**: `git@git.dk0.dev:denshooter/portfolio.git` (SSH - more reliable)
|
||||
|
||||
## .gitignore Updated
|
||||
|
||||
Added:
|
||||
- `logs/*.log` - Log files
|
||||
- `test-results/` - Test results
|
||||
- `playwright-report/` - Playwright reports
|
||||
- `coverage/` - Coverage reports
|
||||
- `.idea/` - IDE files
|
||||
- `.vscode/` - IDE files
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test Git connection**:
|
||||
```bash
|
||||
git fetch
|
||||
```
|
||||
|
||||
2. **If SSH doesn't work**, switch back to HTTPS:
|
||||
```bash
|
||||
git remote set-url origin https://git.dk0.dev/denshooter/portfolio.git
|
||||
```
|
||||
|
||||
3. **Commit cleanup**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: Clean up old documentation and unused files"
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
- **Removed**: ~30 files
|
||||
- **Kept**: Essential documentation and configs
|
||||
- **Fixed**: Git remote connection
|
||||
- **Updated**: .gitignore for better file management
|
||||
89
DEPLOYMENT_FIX.md
Normal file
89
DEPLOYMENT_FIX.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# 🔧 Deployment Fixes Applied
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Port 3001 Already Allocated ❌ → ✅
|
||||
**Problem**: Port 3001 was already in use, causing staging deployment to fail.
|
||||
|
||||
**Fix**:
|
||||
- Changed staging port from `3001` to `3002`
|
||||
- Changed PostgreSQL staging port from `5433` to `5434`
|
||||
- Changed Redis staging port from `6380` to `6381`
|
||||
|
||||
### 2. Docker Compose Version Warning ❌ → ✅
|
||||
**Problem**: `version: '3.8'` is obsolete in newer Docker Compose.
|
||||
|
||||
**Fix**: Removed `version` line from `docker-compose.staging.yml`
|
||||
|
||||
### 3. Missing N8N Environment Variables ❌ → ✅
|
||||
**Problem**: `N8N_SECRET_TOKEN` warning appeared.
|
||||
|
||||
**Fix**: Added `N8N_WEBHOOK_URL` and `N8N_SECRET_TOKEN` to staging compose file
|
||||
|
||||
### 4. Wrong Compose File Used ❌ → ✅
|
||||
**Problem**: Gitea workflow was using wrong compose file (stopping production containers).
|
||||
|
||||
**Fix**:
|
||||
- Updated `ci-cd-with-gitea-vars.yml` to detect branch and use correct compose file
|
||||
- Created dedicated `staging-deploy.yml` workflow
|
||||
- Staging now uses `docker-compose.staging.yml`
|
||||
- Production uses `docker-compose.production.yml`
|
||||
|
||||
## Updated Ports
|
||||
|
||||
| Service | Staging | Production |
|
||||
|---------|---------|------------|
|
||||
| App | **3002** ✅ | **3000** |
|
||||
| PostgreSQL | **5434** ✅ | **5432** |
|
||||
| Redis | **6381** ✅ | **6379** |
|
||||
|
||||
## How It Works Now
|
||||
|
||||
### Staging (dev/main branch)
|
||||
```bash
|
||||
git push origin dev
|
||||
# → Uses docker-compose.staging.yml
|
||||
# → Deploys to port 3002
|
||||
# → Does NOT touch production containers
|
||||
```
|
||||
|
||||
### Production (production branch)
|
||||
```bash
|
||||
git push origin production
|
||||
# → Uses docker-compose.production.yml
|
||||
# → Deploys to port 3000
|
||||
# → Zero-downtime deployment
|
||||
# → Does NOT touch staging containers
|
||||
```
|
||||
|
||||
## Files Updated
|
||||
|
||||
- ✅ `docker-compose.staging.yml` - Fixed ports, removed version, added N8N vars
|
||||
- ✅ `.gitea/workflows/ci-cd-with-gitea-vars.yml` - Branch detection, correct compose files
|
||||
- ✅ `.gitea/workflows/staging-deploy.yml` - New dedicated staging workflow
|
||||
- ✅ `STAGING_SETUP.md` - Updated port references
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test staging deployment**:
|
||||
```bash
|
||||
git push origin dev
|
||||
# Should deploy to port 3002 without errors
|
||||
```
|
||||
|
||||
2. **Verify staging**:
|
||||
```bash
|
||||
curl http://localhost:3002/api/health
|
||||
```
|
||||
|
||||
3. **When ready for production**:
|
||||
```bash
|
||||
git checkout production
|
||||
git merge main
|
||||
git push origin production
|
||||
# Deploys safely to port 3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**All fixes applied!** Staging and production are now completely isolated. 🚀
|
||||
@@ -1,200 +0,0 @@
|
||||
# 🚀 Deployment Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses a **dual-branch deployment strategy** with zero-downtime deployments:
|
||||
|
||||
- **Production Branch** (`production`) → Serves `https://dk0.dev` on port 3000
|
||||
- **Dev Branch** (`dev`) → Serves `https://dev.dk0.dev` on port 3002
|
||||
|
||||
Both environments are completely isolated with separate:
|
||||
- Docker containers
|
||||
- Databases (PostgreSQL)
|
||||
- Redis instances
|
||||
- Networks
|
||||
- Volumes
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
### Production Branch
|
||||
- **Branch**: `production`
|
||||
- **Domain**: `https://dk0.dev`
|
||||
- **Port**: `3000`
|
||||
- **Container**: `portfolio-app`
|
||||
- **Database**: `portfolio_db` (port 5432)
|
||||
- **Redis**: `portfolio-redis` (port 6379)
|
||||
- **Image Tag**: `portfolio-app:production` / `portfolio-app:latest`
|
||||
|
||||
### Dev Branch
|
||||
- **Branch**: `dev`
|
||||
- **Domain**: `https://dev.dk0.dev`
|
||||
- **Port**: `3002`
|
||||
- **Container**: `portfolio-app-staging`
|
||||
- **Database**: `portfolio_staging_db` (port 5434)
|
||||
- **Redis**: `portfolio-redis-staging` (port 6381)
|
||||
- **Image Tag**: `portfolio-app:staging`
|
||||
|
||||
## Automatic Deployment
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Push to `production` branch**:
|
||||
- Triggers `.gitea/workflows/production-deploy.yml`
|
||||
- Runs tests, builds, and deploys to production
|
||||
- Zero-downtime deployment (starts new container, waits for health, removes old)
|
||||
|
||||
2. **Push to `dev` branch**:
|
||||
- Triggers `.gitea/workflows/dev-deploy.yml`
|
||||
- Runs tests, builds, and deploys to dev/staging
|
||||
- Zero-downtime deployment
|
||||
|
||||
### Zero-Downtime Process
|
||||
|
||||
1. Build new Docker image
|
||||
2. Start new container with updated image
|
||||
3. Wait for new container to be healthy (health checks)
|
||||
4. Verify HTTP endpoints respond correctly
|
||||
5. Remove old container (if different)
|
||||
6. Cleanup old images
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Build and deploy production
|
||||
docker build -t portfolio-app:latest .
|
||||
docker compose -f docker-compose.production.yml up -d --build
|
||||
```
|
||||
|
||||
### Dev/Staging
|
||||
```bash
|
||||
# Build and deploy dev
|
||||
docker build -t portfolio-app:staging .
|
||||
docker compose -f docker-compose.staging.yml up -d --build
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required Gitea Variables
|
||||
- `NEXT_PUBLIC_BASE_URL` - Base URL for the application
|
||||
- `MY_EMAIL` - Email address for contact
|
||||
- `MY_INFO_EMAIL` - Info email address
|
||||
- `LOG_LEVEL` - Logging level (info/debug)
|
||||
|
||||
### Required Gitea Secrets
|
||||
- `MY_PASSWORD` - Email password
|
||||
- `MY_INFO_PASSWORD` - Info email password
|
||||
- `ADMIN_BASIC_AUTH` - Admin basic auth credentials
|
||||
- `N8N_SECRET_TOKEN` - Optional: n8n webhook secret
|
||||
|
||||
### Optional Variables
|
||||
- `N8N_WEBHOOK_URL` - n8n webhook URL for automation
|
||||
|
||||
## Health Checks
|
||||
|
||||
Both environments have health check endpoints:
|
||||
- Production: `http://localhost:3000/api/health`
|
||||
- Dev: `http://localhost:3002/api/health`
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Container Status
|
||||
```bash
|
||||
# Production
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
|
||||
# Dev
|
||||
docker compose -f docker-compose.staging.yml ps
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# Production
|
||||
docker logs portfolio-app --tail=100 -f
|
||||
|
||||
# Dev
|
||||
docker logs portfolio-app-staging --tail=100 -f
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
# Production
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Dev
|
||||
curl http://localhost:3002/api/health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
1. Check logs: `docker logs <container-name>`
|
||||
2. Verify environment variables are set
|
||||
3. Check database/redis connectivity
|
||||
4. Verify ports aren't already in use
|
||||
|
||||
### Deployment Fails
|
||||
1. Check Gitea Actions logs
|
||||
2. Verify all required secrets/variables are set
|
||||
3. Check if old containers are blocking ports
|
||||
4. Verify Docker image builds successfully
|
||||
|
||||
### Zero-Downtime Issues
|
||||
- Old container might still be running - check with `docker ps`
|
||||
- Health checks might be failing - check container logs
|
||||
- Port conflicts - verify ports 3000 and 3002 are available
|
||||
|
||||
## Rollback
|
||||
|
||||
If a deployment fails or causes issues:
|
||||
|
||||
```bash
|
||||
# Production rollback
|
||||
docker compose -f docker-compose.production.yml down
|
||||
docker tag portfolio-app:previous portfolio-app:latest
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Dev rollback
|
||||
docker compose -f docker-compose.staging.yml down
|
||||
docker tag portfolio-app:staging-previous portfolio-app:staging
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always test on dev branch first** before pushing to production
|
||||
2. **Monitor health checks** after deployment
|
||||
3. **Keep old images** for quick rollback (last 3 versions)
|
||||
4. **Use feature flags** for new features
|
||||
5. **Document breaking changes** before deploying
|
||||
6. **Run tests locally** before pushing
|
||||
|
||||
## Network Configuration
|
||||
|
||||
- **Production Network**: `portfolio_net` + `proxy` (external)
|
||||
- **Dev Network**: `portfolio_staging_net`
|
||||
- **Isolation**: Complete separation ensures no interference
|
||||
|
||||
## Database Management
|
||||
|
||||
### Production Database
|
||||
- **Container**: `portfolio-postgres`
|
||||
- **Port**: `5432` (internal only)
|
||||
- **Database**: `portfolio_db`
|
||||
- **User**: `portfolio_user`
|
||||
|
||||
### Dev Database
|
||||
- **Container**: `portfolio-postgres-staging`
|
||||
- **Port**: `5434` (external), `5432` (internal)
|
||||
- **Database**: `portfolio_staging_db`
|
||||
- **User**: `portfolio_user`
|
||||
|
||||
## Redis Configuration
|
||||
|
||||
### Production Redis
|
||||
- **Container**: `portfolio-redis`
|
||||
- **Port**: `6379` (internal only)
|
||||
|
||||
### Dev Redis
|
||||
- **Container**: `portfolio-redis-staging`
|
||||
- **Port**: `6381` (external), `6379` (internal)
|
||||
40
Dockerfile
40
Dockerfile
@@ -3,10 +3,11 @@ FROM node:20 AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
@@ -18,38 +19,22 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install all dependencies (including dev dependencies for build)
|
||||
# Use npm ci with cache mount for faster builds
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
RUN npm ci
|
||||
|
||||
# Copy Prisma schema first (for better caching)
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Generate Prisma client (cached if schema unchanged)
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source code (this invalidates cache when code changes)
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Install type definitions for react-responsive-masonry and node-fetch
|
||||
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# Verify standalone output was created and show structure for debugging
|
||||
RUN if [ ! -d .next/standalone ]; then \
|
||||
echo "ERROR: .next/standalone directory not found!"; \
|
||||
echo "Contents of .next directory:"; \
|
||||
ls -la .next/ || true; \
|
||||
echo "Checking if standalone exists in different location:"; \
|
||||
find .next -name "standalone" -type d || true; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
echo "✅ Standalone output found" && \
|
||||
ls -la .next/standalone/ && \
|
||||
echo "Standalone structure:" && \
|
||||
find .next/standalone -type f -name "server.js" || echo "server.js not found in standalone"
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -70,10 +55,7 @@ RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
# Copy standalone output (contains server.js and all dependencies)
|
||||
# The standalone output structure is: .next/standalone/ (not .next/standalone/app/)
|
||||
# Next.js creates: .next/standalone/server.js, .next/standalone/.next/, .next/standalone/node_modules/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/app ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copy Prisma files
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# 🔧 Gitea Variables & Secrets Setup Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt) für dein Repository setzen. Diese werden in den CI/CD Workflows verwendet.
|
||||
|
||||
## 📍 Wo findest du die Einstellungen?
|
||||
|
||||
1. Gehe zu deinem Repository auf Gitea
|
||||
2. Klicke auf **Settings** (Einstellungen)
|
||||
3. Klicke auf **Variables** oder **Secrets** im linken Menü
|
||||
|
||||
## 🔑 Variablen für Production Branch
|
||||
|
||||
Für den `production` Branch brauchst du:
|
||||
|
||||
### Variables (öffentlich sichtbar):
|
||||
- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev`
|
||||
- `MY_EMAIL` = `contact@dk0.dev` (oder deine Email)
|
||||
- `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email)
|
||||
- `LOG_LEVEL` = `info`
|
||||
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional)
|
||||
|
||||
### Secrets (verschlüsselt):
|
||||
- `MY_PASSWORD` = Dein Email-Passwort
|
||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
||||
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort`
|
||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
||||
|
||||
## 🧪 Variablen für Dev Branch
|
||||
|
||||
Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
|
||||
|
||||
### Variables:
|
||||
- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
|
||||
- `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein)
|
||||
- `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein)
|
||||
- `LOG_LEVEL` = `debug` (für Dev mehr Logging)
|
||||
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev` (optional)
|
||||
|
||||
### Secrets:
|
||||
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
|
||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
|
||||
- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein)
|
||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
||||
|
||||
## ✅ Lösung: Automatische Branch-Erkennung
|
||||
|
||||
**Gitea unterstützt keine branch-spezifischen Variablen, aber die Workflows erkennen automatisch den Branch!**
|
||||
|
||||
### Wie es funktioniert:
|
||||
|
||||
Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch die richtigen Defaults:
|
||||
|
||||
**Production Workflow** (`.gitea/workflows/production-deploy.yml`):
|
||||
- Triggert nur auf `production` Branch
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev`
|
||||
|
||||
**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`):
|
||||
- Triggert nur auf `dev` Branch
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev`
|
||||
|
||||
**Das bedeutet:**
|
||||
- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea
|
||||
- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`)
|
||||
- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`)
|
||||
|
||||
### ⚠️ WICHTIG:
|
||||
|
||||
Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben:
|
||||
|
||||
**Option 1: Variable NICHT setzen (Empfohlen)**
|
||||
- Production verwendet automatisch: `https://dk0.dev`
|
||||
- Dev verwendet automatisch: `https://dev.dk0.dev`
|
||||
- ✅ Funktioniert perfekt ohne Konfiguration!
|
||||
|
||||
**Option 2: Variable setzen**
|
||||
- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt
|
||||
- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev)
|
||||
- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet
|
||||
|
||||
## ✅ Empfohlene Konfiguration
|
||||
|
||||
### ⭐ Einfachste Lösung: NICHTS setzen!
|
||||
|
||||
Die Workflows haben bereits die richtigen Defaults:
|
||||
- **Production Branch** → automatisch `https://dk0.dev`
|
||||
- **Dev Branch** → automatisch `https://dev.dk0.dev`
|
||||
|
||||
Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
|
||||
|
||||
### Wenn du Variablen setzen willst:
|
||||
|
||||
**Nur diese Variablen setzen (für beide Branches):**
|
||||
- `MY_EMAIL` = `contact@dk0.dev`
|
||||
- `MY_INFO_EMAIL` = `info@dk0.dev`
|
||||
- `LOG_LEVEL` = `info` (wird für Production verwendet, Dev überschreibt mit `debug`)
|
||||
|
||||
**Secrets (für beide Branches):**
|
||||
- `MY_PASSWORD` = Dein Email-Passwort
|
||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
||||
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
|
||||
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
|
||||
|
||||
**⚠️ NICHT setzen:**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet!
|
||||
|
||||
## 📝 Schritt-für-Schritt Anleitung
|
||||
|
||||
### 1. Gehe zu Repository Settings
|
||||
```
|
||||
https://git.dk0.dev/denshooter/portfolio/settings
|
||||
```
|
||||
|
||||
### 2. Klicke auf "Variables" oder "Secrets"
|
||||
|
||||
### 3. Für Variables (öffentlich):
|
||||
- Klicke auf **"New Variable"**
|
||||
- **Name:** `NEXT_PUBLIC_BASE_URL`
|
||||
- **Value:** `https://dk0.dev` (für Production)
|
||||
- **Protect:** ✅ (optional, schützt vor Änderungen)
|
||||
- Klicke **"Add Variable"**
|
||||
|
||||
### 4. Für Secrets (verschlüsselt):
|
||||
- Klicke auf **"New Secret"**
|
||||
- **Name:** `MY_PASSWORD`
|
||||
- **Value:** Dein Passwort
|
||||
- Klicke **"Add Secret"**
|
||||
|
||||
## 🔄 Aktuelle Workflow-Logik
|
||||
|
||||
Die Workflows verwenden diese einfache Logik:
|
||||
|
||||
```yaml
|
||||
# Production Workflow (triggert nur auf production branch)
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
|
||||
|
||||
# Dev Workflow (triggert nur auf dev branch)
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
|
||||
```
|
||||
|
||||
**Das bedeutet:**
|
||||
- Jeder Workflow hat seinen **eigenen Default**
|
||||
- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet
|
||||
- Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default
|
||||
|
||||
**⭐ Beste Lösung:**
|
||||
- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen
|
||||
- Dann verwendet Production automatisch `https://dk0.dev`
|
||||
- Und Dev verwendet automatisch `https://dev.dk0.dev`
|
||||
- ✅ Perfekt getrennt, ohne Konfiguration!
|
||||
|
||||
## 🎯 Best Practice
|
||||
|
||||
1. **Production:** Setze alle Variablen explizit in Gitea
|
||||
2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen)
|
||||
3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code!
|
||||
|
||||
## 🔍 Prüfen ob Variablen gesetzt sind
|
||||
|
||||
In den Workflow-Logs siehst du:
|
||||
```
|
||||
📝 Using Gitea Variables and Secrets:
|
||||
- NEXT_PUBLIC_BASE_URL: https://dk0.dev
|
||||
```
|
||||
|
||||
Wenn eine Variable fehlt, wird der Default verwendet.
|
||||
|
||||
## ⚙️ Alternative: Environment-spezifische Variablen
|
||||
|
||||
Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen:
|
||||
|
||||
```yaml
|
||||
# Production
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
||||
|
||||
# Dev
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
|
||||
```
|
||||
|
||||
Dann könntest du setzen:
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
|
||||
- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev`
|
||||
|
||||
Soll ich die Workflows entsprechend anpassen?
|
||||
53
GIT_CONNECTION_FIX.md
Normal file
53
GIT_CONNECTION_FIX.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 🔧 Git Connection Fix
|
||||
|
||||
## Issue
|
||||
```
|
||||
fatal: unable to access 'https://git.dk0.dev/denshooter/portfolio/':
|
||||
Failed to connect to git.dk0.dev port 443 after 75002 ms: Couldn't connect to server
|
||||
```
|
||||
|
||||
## Solutions
|
||||
|
||||
### Option 1: Check Server Status
|
||||
The server is reachable via HTTP (tested), but Git might need authentication.
|
||||
|
||||
### Option 2: Configure Git Credentials
|
||||
```bash
|
||||
# Store credentials
|
||||
git config --global credential.helper store
|
||||
|
||||
# Or use keychain (macOS)
|
||||
git config --global credential.helper osxkeychain
|
||||
```
|
||||
|
||||
### Option 3: Use Personal Access Token
|
||||
1. Go to: https://git.dk0.dev/user/settings/applications
|
||||
2. Generate a new token
|
||||
3. Use it when pushing:
|
||||
```bash
|
||||
git push https://YOUR_TOKEN@git.dk0.dev/denshooter/portfolio.git
|
||||
```
|
||||
|
||||
### Option 4: Check Firewall/Network
|
||||
- Port 443 might be blocked
|
||||
- Try from different network
|
||||
- Check if VPN is needed
|
||||
|
||||
### Option 5: Use SSH (if port 22 opens)
|
||||
```bash
|
||||
git remote set-url origin git@git.dk0.dev:denshooter/portfolio.git
|
||||
```
|
||||
|
||||
## Current Status
|
||||
- Remote URL: `https://git.dk0.dev/denshooter/portfolio.git`
|
||||
- Server reachable: ✅ (HTTP works)
|
||||
- Git connection: ⚠️ (May need credentials)
|
||||
|
||||
## Quick Test
|
||||
```bash
|
||||
# Test connection
|
||||
curl -I https://git.dk0.dev
|
||||
|
||||
# Test Git
|
||||
git ls-remote https://git.dk0.dev/denshooter/portfolio.git
|
||||
```
|
||||
@@ -1,198 +0,0 @@
|
||||
# 🔧 Nginx Proxy Manager Setup Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses Projekt nutzt **Nginx Proxy Manager** als Reverse Proxy. Die Container sind im `proxy` Netzwerk, damit Nginx Proxy Manager auf sie zugreifen kann.
|
||||
|
||||
## 🐳 Docker Netzwerk-Konfiguration
|
||||
|
||||
Die Container sind bereits im `proxy` Netzwerk konfiguriert:
|
||||
|
||||
**Production:**
|
||||
```yaml
|
||||
networks:
|
||||
- portfolio_net
|
||||
- proxy # ✅ Bereits konfiguriert
|
||||
```
|
||||
|
||||
**Staging:**
|
||||
```yaml
|
||||
networks:
|
||||
- portfolio_staging_net
|
||||
- proxy # ✅ Bereits konfiguriert
|
||||
```
|
||||
|
||||
## 📋 Nginx Proxy Manager Konfiguration
|
||||
|
||||
### Production (dk0.dev)
|
||||
|
||||
1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host
|
||||
|
||||
2. **Details Tab:**
|
||||
- **Domain Names:** `dk0.dev`, `www.dk0.dev`
|
||||
- **Scheme:** `http`
|
||||
- **Forward Hostname/IP:** `portfolio-app` (Container-Name)
|
||||
- **Forward Port:** `3000`
|
||||
- **Cache Assets:** ✅ (optional)
|
||||
- **Block Common Exploits:** ✅
|
||||
- **Websockets Support:** ✅ (für Chat/Activity)
|
||||
|
||||
3. **SSL Tab:**
|
||||
- **SSL Certificate:** Request a new SSL Certificate
|
||||
- **Force SSL:** ✅
|
||||
- **HTTP/2 Support:** ✅
|
||||
- **HSTS Enabled:** ✅
|
||||
|
||||
4. **Advanced Tab:**
|
||||
```
|
||||
# Custom Nginx Configuration
|
||||
# Fix for 421 Misdirected Request
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Fix HTTP/2 connection reuse issues
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
```
|
||||
|
||||
### Staging (dev.dk0.dev)
|
||||
|
||||
1. **Gehe zu Nginx Proxy Manager** → Hosts → Proxy Hosts → Add Proxy Host
|
||||
|
||||
2. **Details Tab:**
|
||||
- **Domain Names:** `dev.dk0.dev`
|
||||
- **Scheme:** `http`
|
||||
- **Forward Hostname/IP:** `portfolio-app-staging` (Container-Name)
|
||||
- **Forward Port:** `3000` (interner Port im Container)
|
||||
- **Cache Assets:** ❌ (für Dev besser deaktiviert)
|
||||
- **Block Common Exploits:** ✅
|
||||
- **Websockets Support:** ✅
|
||||
|
||||
3. **SSL Tab:**
|
||||
- **SSL Certificate:** Request a new SSL Certificate
|
||||
- **Force SSL:** ✅
|
||||
- **HTTP/2 Support:** ✅
|
||||
- **HSTS Enabled:** ✅
|
||||
|
||||
4. **Advanced Tab:**
|
||||
```
|
||||
# Custom Nginx Configuration
|
||||
# Fix for 421 Misdirected Request
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Fix HTTP/2 connection reuse issues
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
```
|
||||
|
||||
## 🔍 421 Misdirected Request - Lösung
|
||||
|
||||
Der **421 Misdirected Request** Fehler tritt auf, wenn:
|
||||
|
||||
1. **HTTP/2 Connection Reuse:** Nginx Proxy Manager versucht, eine HTTP/2-Verbindung wiederzuverwenden, aber der Host-Header stimmt nicht überein
|
||||
2. **Host-Header nicht richtig weitergegeben:** Der Container erhält den falschen Host-Header
|
||||
|
||||
### Lösung 1: Advanced Tab Konfiguration (Wichtig!)
|
||||
|
||||
Füge diese Zeilen im **Advanced Tab** von Nginx Proxy Manager hinzu:
|
||||
|
||||
```nginx
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
```
|
||||
|
||||
### Lösung 2: Container-Namen verwenden
|
||||
|
||||
Stelle sicher, dass du den **Container-Namen** (nicht IP) verwendest:
|
||||
- Production: `portfolio-app`
|
||||
- Staging: `portfolio-app-staging`
|
||||
|
||||
### Lösung 3: Netzwerk prüfen
|
||||
|
||||
Stelle sicher, dass beide Container im `proxy` Netzwerk sind:
|
||||
|
||||
```bash
|
||||
# Prüfen
|
||||
docker network inspect proxy
|
||||
|
||||
# Sollte enthalten:
|
||||
# - portfolio-app
|
||||
# - portfolio-app-staging
|
||||
```
|
||||
|
||||
## ✅ Checkliste
|
||||
|
||||
- [ ] Container sind im `proxy` Netzwerk
|
||||
- [ ] Nginx Proxy Manager nutzt Container-Namen (nicht IP)
|
||||
- [ ] Advanced Tab Konfiguration ist gesetzt
|
||||
- [ ] `proxy_http_version 1.1` ist gesetzt
|
||||
- [ ] `proxy_set_header Host $host` ist gesetzt
|
||||
- [ ] SSL-Zertifikat ist konfiguriert
|
||||
- [ ] Websockets Support ist aktiviert
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### 421 Fehler weiterhin vorhanden?
|
||||
|
||||
1. **Prüfe Container-Namen:**
|
||||
```bash
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
2. **Prüfe Netzwerk:**
|
||||
```bash
|
||||
docker network inspect proxy | grep -A 5 portfolio
|
||||
```
|
||||
|
||||
3. **Prüfe Nginx Proxy Manager Logs:**
|
||||
- Gehe zu Nginx Proxy Manager → System Logs
|
||||
- Suche nach "421" oder "misdirected"
|
||||
|
||||
4. **Teste direkt:**
|
||||
```bash
|
||||
# Vom Host aus
|
||||
curl -H "Host: dk0.dev" http://portfolio-app:3000
|
||||
|
||||
# Sollte funktionieren
|
||||
```
|
||||
|
||||
5. **Deaktiviere HTTP/2 temporär:**
|
||||
- In Nginx Proxy Manager → SSL Tab
|
||||
- **HTTP/2 Support:** ❌
|
||||
- Teste ob es funktioniert
|
||||
|
||||
## 📝 Wichtige Hinweise
|
||||
|
||||
- **Container-Namen sind wichtig:** Nutze `portfolio-app` nicht `localhost` oder IP
|
||||
- **Port:** Immer Port `3000` (interner Container-Port), nicht `3000:3000`
|
||||
- **Netzwerk:** Beide Container müssen im `proxy` Netzwerk sein
|
||||
- **HTTP/2:** Kann Probleme verursachen, wenn Advanced Config fehlt
|
||||
|
||||
## 🔄 Nach Deployment
|
||||
|
||||
Nach jedem Deployment:
|
||||
1. Prüfe ob Container läuft: `docker ps | grep portfolio`
|
||||
2. Prüfe ob Container im proxy-Netzwerk ist
|
||||
3. Teste die URL im Browser
|
||||
4. Prüfe Nginx Proxy Manager Logs bei Problemen
|
||||
@@ -1,120 +0,0 @@
|
||||
# 🔒 Security Improvements
|
||||
|
||||
## Implemented Security Features
|
||||
|
||||
### 1. n8n API Endpoint Protection
|
||||
|
||||
All n8n endpoints are now protected with:
|
||||
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
|
||||
- **Rate Limiting**:
|
||||
- `/api/n8n/generate-image`: 10 requests/minute
|
||||
- `/api/n8n/chat`: 20 requests/minute
|
||||
- `/api/n8n/status`: 30 requests/minute
|
||||
|
||||
### 2. Email Obfuscation
|
||||
|
||||
Email addresses can now be obfuscated to prevent automated scraping:
|
||||
|
||||
```typescript
|
||||
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
|
||||
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
|
||||
|
||||
// React component
|
||||
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
|
||||
|
||||
// HTML string
|
||||
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Emails are base64 encoded in the HTML
|
||||
- JavaScript decodes them on click
|
||||
- Prevents simple regex-based email scrapers
|
||||
- Still functional for real users
|
||||
|
||||
### 3. URL Obfuscation
|
||||
|
||||
Sensitive URLs can be obfuscated:
|
||||
|
||||
```typescript
|
||||
import { createObfuscatedLink } from '@/lib/email-obfuscate';
|
||||
|
||||
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
|
||||
```
|
||||
|
||||
### 4. Rate Limiting
|
||||
|
||||
All API endpoints have rate limiting:
|
||||
- Prevents brute force attacks
|
||||
- Protects against DDoS
|
||||
- Configurable per endpoint
|
||||
|
||||
## Code Obfuscation
|
||||
|
||||
**Note**: Full code obfuscation for Next.js is **not recommended** because:
|
||||
|
||||
1. **Next.js already minifies code** in production builds
|
||||
2. **Obfuscation breaks source maps** (harder to debug)
|
||||
3. **Performance impact** (slower execution)
|
||||
4. **Not effective** - determined attackers can still reverse engineer
|
||||
5. **Maintenance burden** - harder to debug issues
|
||||
|
||||
**Better alternatives:**
|
||||
- ✅ Minification (already enabled in Next.js)
|
||||
- ✅ Environment variables for secrets
|
||||
- ✅ Server-side rendering (code not exposed)
|
||||
- ✅ API authentication
|
||||
- ✅ Rate limiting
|
||||
- ✅ Security headers
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Email Protection:
|
||||
1. Use obfuscated emails in public HTML
|
||||
2. Use contact forms instead of direct mailto links
|
||||
3. Monitor for spam patterns
|
||||
|
||||
### For API Protection:
|
||||
1. Always require authentication for sensitive endpoints
|
||||
2. Use rate limiting
|
||||
3. Log suspicious activity
|
||||
4. Use HTTPS only
|
||||
5. Validate all inputs
|
||||
|
||||
### For Webhook Protection:
|
||||
1. Use secret tokens (`N8N_SECRET_TOKEN`)
|
||||
2. Verify webhook signatures
|
||||
3. Rate limit webhook endpoints
|
||||
4. Monitor webhook usage
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- ✅ n8n endpoints protected with auth + rate limiting
|
||||
- ✅ Email obfuscation utility created
|
||||
- ✅ URL obfuscation utility created
|
||||
- ✅ Rate limiting on all n8n endpoints
|
||||
- ⚠️ Email obfuscation not yet applied to pages (manual step)
|
||||
- ⚠️ Code obfuscation not implemented (not recommended)
|
||||
|
||||
## Next Steps
|
||||
|
||||
To apply email obfuscation to your pages:
|
||||
|
||||
1. Import the utility:
|
||||
```typescript
|
||||
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
|
||||
```
|
||||
|
||||
2. Replace email links:
|
||||
```tsx
|
||||
// Before
|
||||
<a href="mailto:contact@dk0.dev">Contact</a>
|
||||
|
||||
// After
|
||||
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
|
||||
```
|
||||
|
||||
3. For static HTML, use the string function:
|
||||
```typescript
|
||||
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
||||
```
|
||||
@@ -1,21 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
export async function POST(request: Request) {
|
||||
let userMessage = "";
|
||||
|
||||
try {
|
||||
// Rate limiting for n8n chat endpoint
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const { checkRateLimit } = await import('@/lib/auth');
|
||||
|
||||
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const json = await request.json();
|
||||
userMessage = json.message;
|
||||
const history = json.history || [];
|
||||
@@ -37,169 +25,58 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const webhookUrl = `${n8nWebhookUrl}/webhook/chat`;
|
||||
console.log(`Sending to n8n: ${webhookUrl}`);
|
||||
console.log(`Sending to n8n: ${n8nWebhookUrl}/webhook/chat`);
|
||||
|
||||
// Add timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(process.env.N8N_SECRET_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
|
||||
}),
|
||||
...(process.env.N8N_API_KEY && {
|
||||
"X-API-Key": process.env.N8N_API_KEY,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: history,
|
||||
const response = await fetch(`${n8nWebhookUrl}/webhook/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(process.env.N8N_API_KEY && {
|
||||
Authorization: `Bearer ${process.env.N8N_API_KEY}`,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
console.error(`n8n webhook failed with status: ${response.status}`, errorText);
|
||||
throw new Error(`n8n webhook failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log("n8n response data (full):", JSON.stringify(data, null, 2));
|
||||
console.log("n8n response data type:", typeof data);
|
||||
console.log("n8n response is array:", Array.isArray(data));
|
||||
|
||||
// Try multiple ways to extract the reply
|
||||
let reply: string | undefined = undefined;
|
||||
|
||||
// Direct fields
|
||||
if (data.reply) reply = data.reply;
|
||||
else if (data.message) reply = data.message;
|
||||
else if (data.response) reply = data.response;
|
||||
else if (data.text) reply = data.text;
|
||||
else if (data.content) reply = data.content;
|
||||
else if (data.answer) reply = data.answer;
|
||||
else if (data.output) reply = data.output;
|
||||
else if (data.result) reply = data.result;
|
||||
|
||||
// Array handling
|
||||
else if (Array.isArray(data) && data.length > 0) {
|
||||
const firstItem = data[0];
|
||||
if (typeof firstItem === 'string') {
|
||||
reply = firstItem;
|
||||
} else if (typeof firstItem === 'object') {
|
||||
reply = firstItem.reply || firstItem.message || firstItem.response ||
|
||||
firstItem.text || firstItem.content || firstItem.answer ||
|
||||
firstItem.output || firstItem.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested structures (common in n8n)
|
||||
else if (data && typeof data === "object") {
|
||||
// Check nested data field
|
||||
if (data.data) {
|
||||
if (typeof data.data === 'string') {
|
||||
reply = data.data;
|
||||
} else if (typeof data.data === 'object') {
|
||||
reply = data.data.reply || data.data.message || data.data.response ||
|
||||
data.data.text || data.data.content || data.data.answer;
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested json field
|
||||
if (!reply && data.json) {
|
||||
if (typeof data.json === 'string') {
|
||||
reply = data.json;
|
||||
} else if (typeof data.json === 'object') {
|
||||
reply = data.json.reply || data.json.message || data.json.response ||
|
||||
data.json.text || data.json.content || data.json.answer;
|
||||
}
|
||||
}
|
||||
|
||||
// Check items array (n8n often wraps in items)
|
||||
if (!reply && Array.isArray(data.items) && data.items.length > 0) {
|
||||
const firstItem = data.items[0];
|
||||
if (typeof firstItem === 'string') {
|
||||
reply = firstItem;
|
||||
} else if (typeof firstItem === 'object') {
|
||||
reply = firstItem.reply || firstItem.message || firstItem.response ||
|
||||
firstItem.text || firstItem.content || firstItem.answer ||
|
||||
firstItem.json?.reply || firstItem.json?.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: if it's a single string value object, try to extract
|
||||
if (!reply && Object.keys(data).length === 1) {
|
||||
const value = Object.values(data)[0];
|
||||
if (typeof value === 'string') {
|
||||
reply = value;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no reply but data exists, stringify it (for debugging)
|
||||
if (!reply && Object.keys(data).length > 0) {
|
||||
console.warn("n8n response structure not recognized, attempting to extract any string value");
|
||||
// Try to find any string value in the object
|
||||
const findStringValue = (obj: unknown): string | undefined => {
|
||||
if (typeof obj === 'string' && obj.length > 0) return obj;
|
||||
if (Array.isArray(obj) && obj.length > 0) {
|
||||
return findStringValue(obj[0]);
|
||||
}
|
||||
if (obj && typeof obj === 'object' && obj !== null) {
|
||||
const objRecord = obj as Record<string, unknown>;
|
||||
for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) {
|
||||
if (objRecord[key] && typeof objRecord[key] === 'string') {
|
||||
return objRecord[key] as string;
|
||||
}
|
||||
}
|
||||
// Recursively search
|
||||
for (const value of Object.values(objRecord)) {
|
||||
const found = findStringValue(value);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
reply = findStringValue(data);
|
||||
}
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: history,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`n8n webhook failed with status: ${response.status}`);
|
||||
throw new Error(`n8n webhook failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log("n8n response data:", data);
|
||||
|
||||
const reply =
|
||||
data.reply ||
|
||||
data.message ||
|
||||
data.response ||
|
||||
data.text ||
|
||||
data.content ||
|
||||
(Array.isArray(data) && data[0]?.reply);
|
||||
|
||||
if (!reply) {
|
||||
console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2));
|
||||
throw new Error("Invalid response format from n8n - no reply field found");
|
||||
}
|
||||
|
||||
// Decode HTML entities in the reply
|
||||
const decodedReply = decodeHtmlEntitiesServer(String(reply));
|
||||
|
||||
return NextResponse.json({
|
||||
reply: decodedReply,
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
console.error("n8n webhook request timed out");
|
||||
} else {
|
||||
console.error("n8n webhook fetch error:", fetchError);
|
||||
console.warn("n8n response missing reply field:", data);
|
||||
// If n8n returns successfully but without a clear reply field,
|
||||
// we might want to show the fallback or a generic error,
|
||||
// but strictly speaking we shouldn't show "Couldn't process".
|
||||
// Let's try to stringify the whole data if it's small, or use fallback.
|
||||
if (data && typeof data === "object" && Object.keys(data).length > 0) {
|
||||
// It returned something, but we don't know what field to use.
|
||||
// Check for common n8n structure
|
||||
if (data.output) return NextResponse.json({ reply: data.output });
|
||||
if (data.data) return NextResponse.json({ reply: data.data });
|
||||
}
|
||||
throw fetchError;
|
||||
throw new Error("Invalid response format from n8n");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Chat API error:", error);
|
||||
console.error("Error details:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Chat API error:", error);
|
||||
|
||||
// Fallback to mock responses
|
||||
// Now using the variable captured at the start
|
||||
|
||||
@@ -13,24 +13,6 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting for n8n endpoints
|
||||
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
|
||||
const { checkRateLimit } = await import('@/lib/auth');
|
||||
|
||||
if (!checkRateLimit(ip, 10, 60000)) { // 10 requests per minute
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Require admin authentication for n8n endpoints
|
||||
const { requireAdminAuth } = await import('@/lib/auth');
|
||||
const authError = requireAdminAuth(req);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { projectId, regenerate = false } = body;
|
||||
|
||||
|
||||
@@ -1,101 +1,49 @@
|
||||
// app/api/n8n/status/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Cache für 30 Sekunden, damit wir n8n nicht zuspammen
|
||||
export const revalidate = 30;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting for n8n status endpoint
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const { checkRateLimit } = await import('@/lib/auth');
|
||||
|
||||
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for status
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if n8n webhook URL is configured
|
||||
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
|
||||
|
||||
if (!n8nWebhookUrl) {
|
||||
console.warn("N8N_WEBHOOK_URL not configured for status endpoint");
|
||||
// Return fallback if n8n is not configured
|
||||
return NextResponse.json({
|
||||
status: { text: "offline", color: "gray" },
|
||||
music: null,
|
||||
gaming: null,
|
||||
coding: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Rufe den n8n Webhook auf
|
||||
// Add timestamp to query to bypass Cloudflare cache
|
||||
const statusUrl = `${n8nWebhookUrl}/webhook/denshooter-71242/status?t=${Date.now()}`;
|
||||
console.log(`Fetching status from: ${statusUrl}`);
|
||||
|
||||
// Add timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
try {
|
||||
const res = await fetch(statusUrl, {
|
||||
const res = await fetch(
|
||||
`${process.env.N8N_WEBHOOK_URL}/webhook/denshooter-71242/status?t=${Date.now()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(process.env.N8N_SECRET_TOKEN && {
|
||||
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
|
||||
}),
|
||||
},
|
||||
next: { revalidate: 30 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => 'Unknown error');
|
||||
console.error(`n8n status webhook failed: ${res.status}`, errorText);
|
||||
throw new Error(`n8n error: ${res.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
|
||||
const statusData = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
|
||||
if (!statusData) {
|
||||
throw new Error("Empty data received from n8n");
|
||||
}
|
||||
|
||||
// Ensure coding object has proper structure
|
||||
if (statusData.coding && typeof statusData.coding === "object") {
|
||||
// Already properly formatted from n8n
|
||||
} else if (statusData.coding === null || statusData.coding === undefined) {
|
||||
// No coding data - keep as null
|
||||
statusData.coding = null;
|
||||
}
|
||||
|
||||
return NextResponse.json(statusData);
|
||||
} catch (fetchError: unknown) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
console.error("n8n status webhook request timed out");
|
||||
} else {
|
||||
console.error("n8n status webhook fetch error:", fetchError);
|
||||
}
|
||||
throw fetchError;
|
||||
if (!res.ok) {
|
||||
throw new Error(`n8n error: ${res.status}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
|
||||
const statusData = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
|
||||
if (!statusData) {
|
||||
throw new Error("Empty data received from n8n");
|
||||
}
|
||||
|
||||
// Ensure coding object has proper structure
|
||||
if (statusData.coding && typeof statusData.coding === "object") {
|
||||
// Already properly formatted from n8n
|
||||
} else if (statusData.coding === null || statusData.coding === undefined) {
|
||||
// No coding data - keep as null
|
||||
statusData.coding = null;
|
||||
}
|
||||
|
||||
return NextResponse.json(statusData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching n8n status:", error);
|
||||
console.error("Error details:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
|
||||
});
|
||||
// Leeres Fallback-Objekt, damit die Seite nicht abstürzt
|
||||
return NextResponse.json({
|
||||
status: { text: "offline", color: "gray" },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,14 +51,6 @@ export default function ChatWidget() {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Helper function to decode HTML entities
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
};
|
||||
|
||||
// Load messages from localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -69,7 +61,6 @@ export default function ChatWidget() {
|
||||
setMessages(
|
||||
parsed.map((m: Message) => ({
|
||||
...m,
|
||||
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
|
||||
timestamp: new Date(m.timestamp),
|
||||
})),
|
||||
);
|
||||
@@ -81,7 +72,7 @@ export default function ChatWidget() {
|
||||
setMessages([
|
||||
{
|
||||
id: "welcome",
|
||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
|
||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
|
||||
sender: "bot",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
@@ -134,15 +125,9 @@ export default function ChatWidget() {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Decode HTML entities in the reply
|
||||
let replyText = data.reply || "Sorry, I couldn't process that. Please try again.";
|
||||
|
||||
// Decode HTML entities client-side (double safety)
|
||||
replyText = decodeHtmlEntities(replyText);
|
||||
|
||||
const botMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: replyText,
|
||||
text: data.reply || "Sorry, I couldn't process that. Please try again.",
|
||||
sender: "bot",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -242,7 +227,7 @@ export default function ChatWidget() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm">
|
||||
Dennis{'\''}s AI Assistant
|
||||
Dennis's AI Assistant
|
||||
</h3>
|
||||
<p className="text-xs text-white/80">Always online</p>
|
||||
</div>
|
||||
@@ -375,7 +360,7 @@ export default function ChatWidget() {
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2 mt-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{[
|
||||
"What are Dennis's skills?",
|
||||
"What are Dennis's skills?",
|
||||
"Tell me about his projects",
|
||||
"How can I contact him?",
|
||||
].map((suggestion, index) => (
|
||||
|
||||
@@ -251,13 +251,19 @@ function EditorPageContent() {
|
||||
};
|
||||
|
||||
// Markdown components for react-markdown with security
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markdownComponents: any = {
|
||||
a: ({ node: _node, ...props }: { node?: unknown; href?: string; children?: React.ReactNode }) => {
|
||||
const markdownComponents = {
|
||||
a: ({
|
||||
node: _node,
|
||||
...props
|
||||
}: {
|
||||
node?: unknown;
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
// Validate URLs to prevent javascript: and data: protocols
|
||||
const href = props.href || "";
|
||||
const isSafe =
|
||||
href && typeof href === 'string' && !href.startsWith("javascript:") && !href.startsWith("data:");
|
||||
href && !href.startsWith("javascript:") && !href.startsWith("data:");
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
@@ -271,11 +277,18 @@ function EditorPageContent() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ node: _node, ...props }: { node?: unknown; src?: string; alt?: string }) => {
|
||||
img: ({
|
||||
node: _node,
|
||||
...props
|
||||
}: {
|
||||
node?: unknown;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
}) => {
|
||||
// Validate image URLs
|
||||
const src = props.src;
|
||||
const src = props.src || "";
|
||||
const isSafe =
|
||||
src && typeof src === 'string' && !src.startsWith("javascript:") && !src.startsWith("data:");
|
||||
src && !src.startsWith("javascript:") && !src.startsWith("data:");
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return isSafe ? <img {...props} src={src} alt={props.alt || ""} /> : null;
|
||||
},
|
||||
|
||||
733
components/GhostEditor.tsx
Normal file
733
components/GhostEditor.tsx
Normal file
@@ -0,0 +1,733 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Save,
|
||||
X,
|
||||
Eye,
|
||||
Settings,
|
||||
Globe,
|
||||
Github,
|
||||
Image as ImageIcon,
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Link2,
|
||||
ListOrdered,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Type,
|
||||
Columns
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
category: string;
|
||||
difficulty?: string;
|
||||
tags?: string[];
|
||||
featured: boolean;
|
||||
published: boolean;
|
||||
github?: string;
|
||||
live?: string;
|
||||
image?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface GhostEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project?: Project | null;
|
||||
onSave: (projectData: Partial<Project>) => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export const GhostEditor: React.FC<GhostEditorProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onSave,
|
||||
isCreating
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [category, setCategory] = useState('Web Development');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [github, setGithub] = useState('');
|
||||
const [live, setLive] = useState('');
|
||||
const [featured, setFeatured] = useState(false);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [difficulty, setDifficulty] = useState('Intermediate');
|
||||
|
||||
// Editor UI state
|
||||
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [readingTime, setReadingTime] = useState(0);
|
||||
|
||||
const titleRef = useRef<HTMLTextAreaElement>(null);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
|
||||
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
||||
|
||||
useEffect(() => {
|
||||
if (project && !isCreating) {
|
||||
setTitle(project.title);
|
||||
setDescription(project.description);
|
||||
setContent(project.content || '');
|
||||
setCategory(project.category);
|
||||
setTags(project.tags || []);
|
||||
setGithub(project.github || '');
|
||||
setLive(project.live || '');
|
||||
setFeatured(project.featured);
|
||||
setPublished(project.published);
|
||||
setDifficulty(project.difficulty || 'Intermediate');
|
||||
} else {
|
||||
// Reset for new project
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setContent('');
|
||||
setCategory('Web Development');
|
||||
setTags([]);
|
||||
setGithub('');
|
||||
setLive('');
|
||||
setFeatured(false);
|
||||
setPublished(false);
|
||||
setDifficulty('Intermediate');
|
||||
}
|
||||
}, [project, isCreating, isOpen]);
|
||||
|
||||
// Calculate word count and reading time
|
||||
useEffect(() => {
|
||||
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
|
||||
setWordCount(words);
|
||||
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
|
||||
}, [content]);
|
||||
|
||||
const handleSave = () => {
|
||||
const projectData = {
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
category,
|
||||
tags,
|
||||
github,
|
||||
live,
|
||||
featured,
|
||||
published,
|
||||
difficulty
|
||||
};
|
||||
onSave(projectData);
|
||||
};
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (tag.trim() && !tags.includes(tag.trim())) {
|
||||
setTags([...tags, tag.trim()]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const textarea = contentRef.current;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selection = selectedText || content.substring(start, end);
|
||||
|
||||
let newText = '';
|
||||
let cursorOffset = 0;
|
||||
|
||||
switch (syntax) {
|
||||
case 'bold':
|
||||
newText = `**${selection || 'bold text'}**`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'italic':
|
||||
newText = `*${selection || 'italic text'}*`;
|
||||
cursorOffset = selection ? newText.length : 1;
|
||||
break;
|
||||
case 'underline':
|
||||
newText = `<u>${selection || 'underlined text'}</u>`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'strikethrough':
|
||||
newText = `~~${selection || 'strikethrough text'}~~`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'heading1':
|
||||
newText = `# ${selection || 'Heading 1'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'heading2':
|
||||
newText = `## ${selection || 'Heading 2'}`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'heading3':
|
||||
newText = `### ${selection || 'Heading 3'}`;
|
||||
cursorOffset = selection ? newText.length : 4;
|
||||
break;
|
||||
case 'list':
|
||||
newText = `- ${selection || 'List item'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'list-ordered':
|
||||
newText = `1. ${selection || 'List item'}`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'quote':
|
||||
newText = `> ${selection || 'Quote'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'code':
|
||||
if (selection.includes('\n')) {
|
||||
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
|
||||
cursorOffset = selection ? newText.length : 4;
|
||||
} else {
|
||||
newText = `\`${selection || 'code'}\``;
|
||||
cursorOffset = selection ? newText.length : 1;
|
||||
}
|
||||
break;
|
||||
case 'link':
|
||||
newText = `[${selection || 'link text'}](url)`;
|
||||
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
|
||||
break;
|
||||
case 'image':
|
||||
newText = ``;
|
||||
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)
|
||||
"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { obfuscateEmail, deobfuscateEmail } from '@/lib/email-obfuscate';
|
||||
|
||||
interface ObfuscatedEmailProps {
|
||||
email: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ObfuscatedEmail({ email, children, className }: ObfuscatedEmailProps) {
|
||||
const obfuscated = obfuscateEmail(email);
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
data-email={obfuscated}
|
||||
className={className || "obfuscated-email"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const link = e.currentTarget;
|
||||
const decoded = deobfuscateEmail(obfuscated);
|
||||
link.href = `mailto:${decoded}`;
|
||||
window.location.href = link.href;
|
||||
}}
|
||||
>
|
||||
{children || email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
750
components/ResizableGhostEditor.tsx
Normal file
750
components/ResizableGhostEditor.tsx
Normal file
@@ -0,0 +1,750 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Save,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Settings,
|
||||
Globe,
|
||||
Github,
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Link2,
|
||||
ListOrdered,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
GripVertical,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
category: string;
|
||||
difficulty?: string;
|
||||
tags?: string[];
|
||||
featured: boolean;
|
||||
published: boolean;
|
||||
github?: string;
|
||||
live?: string;
|
||||
image?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ResizableGhostEditorProps {
|
||||
project?: Project | null;
|
||||
onSave: (projectData: Partial<Project>) => void;
|
||||
onClose: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export const ResizableGhostEditor: React.FC<ResizableGhostEditorProps> = ({
|
||||
project,
|
||||
onSave,
|
||||
onClose,
|
||||
isCreating
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [category, setCategory] = useState('Web Development');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [github, setGithub] = useState('');
|
||||
const [live, setLive] = useState('');
|
||||
const [featured, setFeatured] = useState(false);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [difficulty, setDifficulty] = useState('Intermediate');
|
||||
|
||||
// Editor UI state
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [previewWidth, setPreviewWidth] = useState(50); // Percentage
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [readingTime, setReadingTime] = useState(0);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const titleRef = useRef<HTMLTextAreaElement>(null);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const resizeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
|
||||
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
||||
|
||||
useEffect(() => {
|
||||
if (project && !isCreating) {
|
||||
setTitle(project.title);
|
||||
setDescription(project.description);
|
||||
setContent(project.content || '');
|
||||
setCategory(project.category);
|
||||
setTags(project.tags || []);
|
||||
setGithub(project.github || '');
|
||||
setLive(project.live || '');
|
||||
setFeatured(project.featured);
|
||||
setPublished(project.published);
|
||||
setDifficulty(project.difficulty || 'Intermediate');
|
||||
} else {
|
||||
// Reset for new project
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setContent('');
|
||||
setCategory('Web Development');
|
||||
setTags([]);
|
||||
setGithub('');
|
||||
setLive('');
|
||||
setFeatured(false);
|
||||
setPublished(false);
|
||||
setDifficulty('Intermediate');
|
||||
}
|
||||
}, [project, isCreating]);
|
||||
|
||||
// Calculate word count and reading time
|
||||
useEffect(() => {
|
||||
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
|
||||
setWordCount(words);
|
||||
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
|
||||
}, [content]);
|
||||
|
||||
// Handle resizing
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const containerWidth = window.innerWidth - (showSettings ? 320 : 0); // Account for settings sidebar
|
||||
const newWidth = Math.max(20, Math.min(80, (e.clientX / containerWidth) * 100));
|
||||
setPreviewWidth(100 - newWidth); // Invert since we're setting editor width
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing, showSettings]);
|
||||
|
||||
const handleSave = () => {
|
||||
const projectData = {
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
category,
|
||||
tags,
|
||||
github,
|
||||
live,
|
||||
featured,
|
||||
published,
|
||||
difficulty
|
||||
};
|
||||
onSave(projectData);
|
||||
};
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (tag.trim() && !tags.includes(tag.trim())) {
|
||||
setTags([...tags, tag.trim()]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const textarea = contentRef.current;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selection = selectedText || content.substring(start, end);
|
||||
|
||||
let newText = '';
|
||||
let cursorOffset = 0;
|
||||
|
||||
switch (syntax) {
|
||||
case 'bold':
|
||||
newText = `**${selection || 'bold text'}**`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'italic':
|
||||
newText = `*${selection || 'italic text'}*`;
|
||||
cursorOffset = selection ? newText.length : 1;
|
||||
break;
|
||||
case 'underline':
|
||||
newText = `<u>${selection || 'underlined text'}</u>`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'strikethrough':
|
||||
newText = `~~${selection || 'strikethrough text'}~~`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'heading1':
|
||||
newText = `# ${selection || 'Heading 1'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'heading2':
|
||||
newText = `## ${selection || 'Heading 2'}`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'heading3':
|
||||
newText = `### ${selection || 'Heading 3'}`;
|
||||
cursorOffset = selection ? newText.length : 4;
|
||||
break;
|
||||
case 'list':
|
||||
newText = `- ${selection || 'List item'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'list-ordered':
|
||||
newText = `1. ${selection || 'List item'}`;
|
||||
cursorOffset = selection ? newText.length : 3;
|
||||
break;
|
||||
case 'quote':
|
||||
newText = `> ${selection || 'Quote'}`;
|
||||
cursorOffset = selection ? newText.length : 2;
|
||||
break;
|
||||
case 'code':
|
||||
if (selection.includes('\n')) {
|
||||
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
|
||||
cursorOffset = selection ? newText.length : 4;
|
||||
} else {
|
||||
newText = `\`${selection || 'code'}\``;
|
||||
cursorOffset = selection ? newText.length : 1;
|
||||
}
|
||||
break;
|
||||
case 'link':
|
||||
newText = `[${selection || 'link text'}](url)`;
|
||||
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
|
||||
break;
|
||||
case 'image':
|
||||
newText = ``;
|
||||
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)
|
||||
"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
# Production Docker Compose configuration for dk0.dev
|
||||
# Optimized for production deployment with zero-downtime support
|
||||
# Optimized for production deployment
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- NODE_ENV=staging
|
||||
- DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public
|
||||
- REDIS_URL=redis://redis-staging:6379
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dev.dk0.dev}
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev}
|
||||
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
||||
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
||||
- MY_PASSWORD=${MY_PASSWORD}
|
||||
@@ -26,7 +26,6 @@ services:
|
||||
- portfolio_staging_data:/app/.next/cache
|
||||
networks:
|
||||
- portfolio_staging_net
|
||||
- proxy
|
||||
depends_on:
|
||||
postgres-staging:
|
||||
condition: service_healthy
|
||||
@@ -113,5 +112,3 @@ volumes:
|
||||
networks:
|
||||
portfolio_staging_net:
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# 📝 n8n Status-Text ändern - Anleitung
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der Status-Text (z.B. "dnd", "online", "offline", "away") wird von deinem n8n Workflow zurückgegeben und auf der Website angezeigt.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Wo kommt der Status-Text her?
|
||||
|
||||
Der Status-Text kommt von deinem n8n Webhook:
|
||||
- **Webhook URL**: `/webhook/denshooter-71242/status`
|
||||
- **Methode**: GET
|
||||
- **Antwort-Format**: JSON mit `status: { text: string, color: string }`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Option 1: Status-Text direkt im n8n Workflow ändern
|
||||
|
||||
### Schritt 1: Workflow finden
|
||||
|
||||
1. Öffne dein n8n Dashboard
|
||||
2. Suche nach dem Workflow, der den Status zurückgibt
|
||||
3. Der Workflow sollte einen **Webhook** oder **HTTP Response** Node haben
|
||||
|
||||
### Schritt 2: Status-Text im Workflow anpassen
|
||||
|
||||
**Beispiel: Function Node oder Set Node**
|
||||
|
||||
```javascript
|
||||
// In einem Function Node oder Set Node
|
||||
return [{
|
||||
json: {
|
||||
status: {
|
||||
text: "dnd", // ← Hier kannst du den Text ändern
|
||||
color: "red" // ← Und hier die Farbe (green, yellow, red, gray)
|
||||
},
|
||||
music: { /* ... */ },
|
||||
gaming: { /* ... */ },
|
||||
coding: { /* ... */ }
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
**Mögliche Status-Texte:**
|
||||
- `"online"` → Wird als "Online" angezeigt
|
||||
- `"offline"` → Wird als "Offline" angezeigt
|
||||
- `"away"` → Wird als "Abwesend" angezeigt
|
||||
- `"dnd"` → Wird als "Nicht stören" angezeigt
|
||||
- `"custom"` → Wird als "Custom" angezeigt (oder beliebiger Text)
|
||||
|
||||
**Mögliche Farben:**
|
||||
- `"green"` → Grüner Punkt
|
||||
- `"yellow"` → Gelber Punkt
|
||||
- `"red"` → Roter Punkt
|
||||
- `"gray"` → Grauer Punkt
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Option 2: Status über Datenbank setzen
|
||||
|
||||
Falls dein n8n Workflow die Datenbank liest, kannst du den Status dort setzen:
|
||||
|
||||
### Schritt 1: Datenbank-Update
|
||||
|
||||
```sql
|
||||
-- Status über status_mood und status_message setzen
|
||||
UPDATE activity_status
|
||||
SET
|
||||
status_mood = '🔴', -- Emoji für den Status
|
||||
status_message = 'Do Not Disturb - In Deep Work'
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
### Schritt 2: n8n Workflow anpassen
|
||||
|
||||
Dein n8n Workflow muss dann die Datenbank-Daten in das richtige Format umwandeln:
|
||||
|
||||
```javascript
|
||||
// Function Node: Convert Database to API Format
|
||||
const dbData = items[0].json;
|
||||
|
||||
// Bestimme Status-Text basierend auf status_mood oder status_message
|
||||
let statusText = "online";
|
||||
let statusColor = "green";
|
||||
|
||||
if (dbData.status_message?.toLowerCase().includes("dnd") ||
|
||||
dbData.status_message?.toLowerCase().includes("do not disturb")) {
|
||||
statusText = "dnd";
|
||||
statusColor = "red";
|
||||
} else if (dbData.status_message?.toLowerCase().includes("away") ||
|
||||
dbData.status_message?.toLowerCase().includes("abwesend")) {
|
||||
statusText = "away";
|
||||
statusColor = "yellow";
|
||||
} else if (dbData.status_message?.toLowerCase().includes("offline")) {
|
||||
statusText = "offline";
|
||||
statusColor = "gray";
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
status: {
|
||||
text: statusText,
|
||||
color: statusColor
|
||||
},
|
||||
// ... rest of data
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Option 3: Status über Webhook setzen
|
||||
|
||||
Erstelle einen separaten n8n Workflow, um den Status manuell zu ändern:
|
||||
|
||||
### Workflow: "Set Status"
|
||||
|
||||
**Node 1: Webhook (POST)**
|
||||
- Path: `set-status`
|
||||
- Method: POST
|
||||
|
||||
**Node 2: Function Node**
|
||||
```javascript
|
||||
// Parse incoming data
|
||||
const { statusText, statusColor } = items[0].json.body;
|
||||
|
||||
// Update database
|
||||
return [{
|
||||
json: {
|
||||
query: "UPDATE activity_status SET status_message = $1 WHERE id = 1",
|
||||
params: [statusText]
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
**Node 3: PostgreSQL Node**
|
||||
- Operation: Execute Query
|
||||
- Query: `={{$json.query}}`
|
||||
- Parameters: `={{$json.params}}`
|
||||
|
||||
**Node 4: Respond to Webhook**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Status updated"
|
||||
}
|
||||
```
|
||||
|
||||
**Verwendung:**
|
||||
```bash
|
||||
curl -X POST https://your-n8n.com/webhook/set-status \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"statusText": "dnd", "statusColor": "red"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Status-Text Übersetzungen in der Website
|
||||
|
||||
Die Website übersetzt folgende Status-Texte automatisch:
|
||||
|
||||
| n8n Status-Text | Website-Anzeige |
|
||||
|----------------|-----------------|
|
||||
| `"dnd"` | "Nicht stören" |
|
||||
| `"online"` | "Online" |
|
||||
| `"offline"` | "Offline" |
|
||||
| `"away"` | "Abwesend" |
|
||||
| Andere | Wird 1:1 angezeigt |
|
||||
|
||||
**Wo wird übersetzt?**
|
||||
- Datei: `app/components/ActivityFeed.tsx`
|
||||
- Zeile: ~1559-1567
|
||||
|
||||
Falls du einen neuen Status-Text hinzufügen willst, musst du die Übersetzung dort hinzufügen.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Praktische Beispiele
|
||||
|
||||
### Beispiel 1: "Focus Mode" Status
|
||||
|
||||
**In n8n Function Node:**
|
||||
```javascript
|
||||
return [{
|
||||
json: {
|
||||
status: {
|
||||
text: "focus", // Neuer Status
|
||||
color: "red"
|
||||
},
|
||||
// ... rest
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
**In ActivityFeed.tsx hinzufügen:**
|
||||
```typescript
|
||||
{data.status.text === "dnd"
|
||||
? "Nicht stören"
|
||||
: data.status.text === "focus" // ← Neue Übersetzung
|
||||
? "Fokus-Modus"
|
||||
: data.status.text === "online"
|
||||
? "Online"
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
|
||||
### Beispiel 2: Status basierend auf Uhrzeit
|
||||
|
||||
**In n8n Function Node:**
|
||||
```javascript
|
||||
const hour = new Date().getHours();
|
||||
let statusText = "online";
|
||||
let statusColor = "green";
|
||||
|
||||
if (hour >= 22 || hour < 7) {
|
||||
statusText = "dnd";
|
||||
statusColor = "red";
|
||||
} else if (hour >= 9 && hour < 17) {
|
||||
statusText = "online";
|
||||
statusColor = "green";
|
||||
} else {
|
||||
statusText = "away";
|
||||
statusColor = "yellow";
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
status: { text: statusText, color: statusColor },
|
||||
// ... rest
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Beispiel 3: Status über Discord Bot
|
||||
|
||||
**Discord Command:**
|
||||
```
|
||||
!status dnd
|
||||
!status online
|
||||
!status away
|
||||
```
|
||||
|
||||
**n8n Workflow:**
|
||||
```javascript
|
||||
// Parse Discord command
|
||||
const command = items[0].json.content.split(' ')[1]; // "dnd", "online", etc.
|
||||
|
||||
return [{
|
||||
json: {
|
||||
status: {
|
||||
text: command,
|
||||
color: command === "dnd" ? "red" : command === "away" ? "yellow" : "green"
|
||||
}
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Problem: Status-Text ändert sich nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob der n8n Workflow aktiviert ist
|
||||
2. Prüfe die Webhook-URL in `app/api/n8n/status/route.ts`
|
||||
3. Prüfe die Browser-Konsole auf Fehler
|
||||
4. Prüfe n8n Execution Logs
|
||||
|
||||
### Problem: Status wird nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob das `status` Objekt im JSON vorhanden ist
|
||||
2. Prüfe, ob `status.text` und `status.color` gesetzt sind
|
||||
3. Prüfe die Browser-Konsole: `console.log("ActivityFeed data:", json)`
|
||||
|
||||
### Problem: Übersetzung funktioniert nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob der Status-Text exakt übereinstimmt (case-sensitive)
|
||||
2. Füge die Übersetzung in `ActivityFeed.tsx` hinzu
|
||||
3. Baue die Website neu: `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## 📚 Weitere Ressourcen
|
||||
|
||||
- [n8n Documentation](https://docs.n8n.io/)
|
||||
- [N8N_INTEGRATION.md](./N8N_INTEGRATION.md) - Vollständige n8n Integration
|
||||
- [DYNAMIC_ACTIVITY_MANAGEMENT.md](./DYNAMIC_ACTIVITY_MANAGEMENT.md) - Activity Management
|
||||
|
||||
---
|
||||
|
||||
## 💡 Quick Reference
|
||||
|
||||
**Status-Text ändern:**
|
||||
1. Öffne n8n Dashboard
|
||||
2. Finde den Status-Workflow
|
||||
3. Ändere `status.text` im Function/Set Node
|
||||
4. Aktiviere den Workflow
|
||||
5. Warte 30 Sekunden (Cache-Intervall)
|
||||
|
||||
**Neue Übersetzung hinzufügen:**
|
||||
1. Öffne `app/components/ActivityFeed.tsx`
|
||||
2. Füge neue Bedingung hinzu (Zeile ~1559)
|
||||
3. Baue neu: `npm run build`
|
||||
4. Deploy
|
||||
|
||||
---
|
||||
|
||||
Happy automating! 🎉
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Email and URL obfuscation utilities
|
||||
* Prevents automated scraping while keeping functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Obfuscates an email address by encoding it
|
||||
* @param email - The email address to obfuscate
|
||||
* @returns Obfuscated email string that can be decoded by JavaScript
|
||||
*/
|
||||
export function obfuscateEmail(email: string): string {
|
||||
// Simple base64 encoding (can be decoded by bots, but adds a layer)
|
||||
// For better protection, use a custom encoding scheme
|
||||
return Buffer.from(email).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deobfuscates an email address
|
||||
* @param obfuscated - The obfuscated email string
|
||||
* @returns Original email address
|
||||
*/
|
||||
export function deobfuscateEmail(obfuscated: string): string {
|
||||
try {
|
||||
return Buffer.from(obfuscated, 'base64').toString('utf-8');
|
||||
} catch {
|
||||
return obfuscated; // Return as-is if decoding fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an obfuscated mailto link component
|
||||
* @param email - The email address
|
||||
* @param displayText - Text to display (optional, defaults to email)
|
||||
* @returns HTML string with obfuscated email
|
||||
*/
|
||||
export function createObfuscatedMailto(email: string, displayText?: string): string {
|
||||
const obfuscated = obfuscateEmail(email);
|
||||
const text = displayText || email;
|
||||
|
||||
// Use data attributes and JavaScript to decode
|
||||
return `<a href="#" data-email="${obfuscated}" class="obfuscated-email" onclick="this.href='mailto:'+atob(this.dataset.email); return true;">${text}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obfuscates a URL by encoding parts of it
|
||||
* @param url - The URL to obfuscate
|
||||
* @returns Obfuscated URL string
|
||||
*/
|
||||
export function obfuscateUrl(url: string): string {
|
||||
// Encode the URL
|
||||
return Buffer.from(url).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an obfuscated link
|
||||
* @param url - The URL
|
||||
* @param displayText - Text to display
|
||||
* @returns HTML string with obfuscated URL
|
||||
*/
|
||||
export function createObfuscatedLink(url: string, displayText: string): string {
|
||||
const obfuscated = obfuscateUrl(url);
|
||||
return `<a href="#" data-url="${obfuscated}" class="obfuscated-link" onclick="this.href=atob(this.dataset.url); return true;">${displayText}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* React component helper for obfuscated emails
|
||||
* Note: This is a TypeScript utility file. For React components, create a separate .tsx file
|
||||
* or use the HTML string functions instead.
|
||||
*/
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Decode HTML entities in strings
|
||||
* Converts ' " & < > etc. to their actual characters
|
||||
*/
|
||||
export function decodeHtmlEntities(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Create a temporary element to decode HTML entities
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side HTML entity decoding (for Node.js/Next.js API routes)
|
||||
*/
|
||||
export function decodeHtmlEntitiesServer(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Map of common HTML entities (including all variations of apostrophe)
|
||||
const entityMap: Record<string, string> = {
|
||||
''': "'",
|
||||
'"': '"',
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
''': "'",
|
||||
''': "'",
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '=',
|
||||
'’': "'",
|
||||
'‘': "'",
|
||||
'”': '"',
|
||||
'“': '"',
|
||||
};
|
||||
|
||||
// First replace known entities
|
||||
let decoded = text;
|
||||
for (const [entity, replacement] of Object.entries(entityMap)) {
|
||||
decoded = decoded.replace(new RegExp(entity, 'gi'), replacement);
|
||||
}
|
||||
|
||||
// Then handle numeric entities (' ' etc.)
|
||||
decoded = decoded.replace(/&#(\d+);/g, (match, num) => {
|
||||
return String.fromCharCode(parseInt(num, 10));
|
||||
});
|
||||
|
||||
decoded = decoded.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
|
||||
return decoded;
|
||||
}
|
||||
@@ -5,18 +5,8 @@ export function middleware(request: NextRequest) {
|
||||
// For /manage and /editor routes, the pages handle their own authentication
|
||||
// No middleware redirect needed - let the pages show login forms
|
||||
|
||||
// Fix for 421 Misdirected Request with Nginx Proxy Manager
|
||||
// Ensure proper host header handling for reverse proxy
|
||||
const hostname = request.headers.get('host') || request.headers.get('x-forwarded-host') || '';
|
||||
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Set proper headers for Nginx Proxy Manager
|
||||
if (hostname) {
|
||||
response.headers.set('X-Forwarded-Host', hostname);
|
||||
response.headers.set('X-Real-IP', request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for') || '');
|
||||
}
|
||||
|
||||
// Security headers (complementing next.config.ts headers)
|
||||
response.headers.set("X-DNS-Prefetch-Control", "on");
|
||||
|
||||
2085
package-lock.json
generated
2085
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -15,7 +15,7 @@
|
||||
"pre-push": "./scripts/pre-push.sh",
|
||||
"pre-push:full": "./scripts/pre-push-full.sh",
|
||||
"pre-push:quick": "./scripts/pre-push-quick.sh",
|
||||
"test:all": "npm run test && npm run test:e2e",
|
||||
"test:all": "./scripts/test-all.sh",
|
||||
"buildAnalyze": "cross-env ANALYZE=true next build",
|
||||
"test": "jest",
|
||||
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
|
||||
@@ -25,6 +25,7 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:all": "npm run test && npm run test:e2e",
|
||||
"test:critical": "playwright test e2e/critical-paths.spec.ts",
|
||||
"test:hydration": "playwright test e2e/hydration.spec.ts",
|
||||
"test:email": "playwright test e2e/email.spec.ts",
|
||||
@@ -36,8 +37,8 @@
|
||||
"db:reset": "prisma db push --force-reset",
|
||||
"docker:build": "docker build -t portfolio-app .",
|
||||
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
||||
"docker:compose": "docker compose -f docker-compose.production.yml up -d",
|
||||
"docker:down": "docker compose -f docker-compose.production.yml down",
|
||||
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
|
||||
"docker:down": "docker compose -f docker-compose.prod.yml down",
|
||||
"docker:dev": "docker compose -f docker-compose.dev.minimal.yml up -d",
|
||||
"docker:dev:down": "docker compose -f docker-compose.dev.minimal.yml down",
|
||||
"deploy": "./scripts/deploy.sh",
|
||||
@@ -55,8 +56,8 @@
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"clsx": "^2.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"framer-motion": "^12.24.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
@@ -64,13 +65,13 @@
|
||||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"redis": "^5.8.2",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -7,7 +7,7 @@ set -e
|
||||
|
||||
# Configuration
|
||||
CONTAINER_NAME="portfolio-app"
|
||||
COMPOSE_FILE="docker-compose.production.yml"
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Rollback Script for Portfolio Deployment
|
||||
# Restores previous version of the application
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if environment is specified
|
||||
ENV=${1:-production}
|
||||
COMPOSE_FILE="docker-compose.production.yml"
|
||||
CONTAINER_NAME="portfolio-app"
|
||||
IMAGE_TAG="production"
|
||||
|
||||
if [ "$ENV" == "dev" ] || [ "$ENV" == "staging" ]; then
|
||||
COMPOSE_FILE="docker-compose.staging.yml"
|
||||
CONTAINER_NAME="portfolio-app-staging"
|
||||
IMAGE_TAG="staging"
|
||||
HEALTH_PORT="3002"
|
||||
else
|
||||
HEALTH_PORT="3000"
|
||||
fi
|
||||
|
||||
log "🔄 Starting rollback for $ENV environment..."
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
error "Docker is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# List available image tags
|
||||
log "📋 Available image versions:"
|
||||
docker images portfolio-app --format "table {{.Tag}}\t{{.ID}}\t{{.CreatedAt}}" | head -10
|
||||
|
||||
# Get current container image
|
||||
CURRENT_IMAGE=$(docker inspect $CONTAINER_NAME --format='{{.Config.Image}}' 2>/dev/null || echo "")
|
||||
if [ ! -z "$CURRENT_IMAGE" ]; then
|
||||
log "Current image: $CURRENT_IMAGE"
|
||||
fi
|
||||
|
||||
# Find previous image tags
|
||||
PREVIOUS_TAGS=$(docker images portfolio-app --format "{{.Tag}}" | grep -E "^(production|staging|latest|previous|backup)" | grep -v "^$IMAGE_TAG$" | head -5)
|
||||
|
||||
if [ -z "$PREVIOUS_TAGS" ]; then
|
||||
error "No previous images found for rollback!"
|
||||
log "Available images:"
|
||||
docker images portfolio-app
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use the first previous tag (most recent)
|
||||
PREVIOUS_TAG=$(echo "$PREVIOUS_TAGS" | head -1)
|
||||
log "Selected previous image: portfolio-app:$PREVIOUS_TAG"
|
||||
|
||||
# Confirm rollback
|
||||
read -p "Do you want to rollback to portfolio-app:$PREVIOUS_TAG? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log "Rollback cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Tag the previous image as current
|
||||
log "🔄 Tagging previous image as current..."
|
||||
docker tag "portfolio-app:$PREVIOUS_TAG" "portfolio-app:$IMAGE_TAG" || {
|
||||
error "Failed to tag previous image"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Stop current container
|
||||
log "🛑 Stopping current container..."
|
||||
docker compose -f $COMPOSE_FILE down || true
|
||||
|
||||
# Start with previous image
|
||||
log "🚀 Starting previous version..."
|
||||
docker compose -f $COMPOSE_FILE up -d
|
||||
|
||||
# Wait for health check
|
||||
log "⏳ Waiting for health check..."
|
||||
for i in {1..40}; do
|
||||
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||
success "✅ Rollback successful! Application is healthy."
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||
error "❌ Health check failed after rollback!"
|
||||
log "Container logs:"
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "🎉 Rollback completed successfully!"
|
||||
log "Application is available at: http://localhost:$HEALTH_PORT"
|
||||
log "To rollback further, run: ./scripts/rollback.sh $ENV"
|
||||
Reference in New Issue
Block a user