feat: Setup zero-downtime deployments for production and dev branches
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Created separate workflows for production and dev deployments - Production branch → dk0.dev (port 3000) - Dev branch → dev.dk0.dev (port 3002) - Zero-downtime deployment pattern (start new, wait for health, remove old) - Complete isolation between environments (separate containers, databases, networks) - Cleaned up unused code and files: - Removed unused GhostEditor and ResizableGhostEditor components - Removed old/unused workflows and markdown files - Fixed docker-compose references - Upgraded dependencies to latest compatible versions - Fixed TypeScript errors in editor page - Updated staging to use dev.dk0.dev domain
This commit is contained in:
@@ -1,209 +0,0 @@
|
|||||||
name: CI/CD Pipeline (Dev/Staging)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, main]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '20'
|
|
||||||
DOCKER_IMAGE: portfolio-app
|
|
||||||
CONTAINER_NAME: portfolio-app-staging
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
staging:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test:production
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run security scan
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running npm audit..."
|
|
||||||
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
|
|
||||||
|
|
||||||
- name: Verify Gitea Variables and Secrets
|
|
||||||
run: |
|
|
||||||
echo "🔍 Verifying Gitea Variables and Secrets..."
|
|
||||||
|
|
||||||
# Check Variables
|
|
||||||
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
|
|
||||||
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
|
|
||||||
echo "Please set this variable in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${{ vars.MY_EMAIL }}" ]; then
|
|
||||||
echo "❌ MY_EMAIL variable is missing!"
|
|
||||||
echo "Please set this variable in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
|
|
||||||
echo "❌ MY_INFO_EMAIL variable is missing!"
|
|
||||||
echo "Please set this variable in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Secrets
|
|
||||||
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
|
|
||||||
echo "❌ MY_PASSWORD secret is missing!"
|
|
||||||
echo "Please set this secret in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
|
|
||||||
echo "❌ MY_INFO_PASSWORD secret is missing!"
|
|
||||||
echo "Please set this secret in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
|
|
||||||
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
|
|
||||||
echo "Please set this secret in Gitea repository settings"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All required Gitea variables and secrets are present"
|
|
||||||
echo "📝 Variables found:"
|
|
||||||
echo " - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
|
||||||
echo " - MY_EMAIL: ${{ vars.MY_EMAIL }}"
|
|
||||||
echo " - MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}"
|
|
||||||
echo " - NODE_ENV: staging"
|
|
||||||
echo " - LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
echo "🏗️ Building Docker image..."
|
|
||||||
docker build -t ${{ env.DOCKER_IMAGE }}:staging .
|
|
||||||
docker tag ${{ env.DOCKER_IMAGE }}:staging ${{ env.DOCKER_IMAGE }}:staging-$(date +%Y%m%d-%H%M%S)
|
|
||||||
echo "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
- name: Deploy Staging using Gitea Variables and Secrets
|
|
||||||
run: |
|
|
||||||
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
|
|
||||||
|
|
||||||
echo "📝 Using Gitea Variables and Secrets:"
|
|
||||||
echo " - NODE_ENV: staging"
|
|
||||||
echo " - LOG_LEVEL: ${LOG_LEVEL}"
|
|
||||||
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
|
|
||||||
echo " - MY_EMAIL: ${MY_EMAIL}"
|
|
||||||
echo " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
|
|
||||||
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
|
|
||||||
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
|
|
||||||
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
|
|
||||||
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}"
|
|
||||||
|
|
||||||
# Stop old staging containers
|
|
||||||
echo "🛑 Stopping old staging containers..."
|
|
||||||
docker compose -f docker-compose.staging.yml down || true
|
|
||||||
|
|
||||||
# Clean up orphaned containers
|
|
||||||
echo "🧹 Cleaning up orphaned containers..."
|
|
||||||
docker compose -f docker-compose.staging.yml down --remove-orphans || true
|
|
||||||
|
|
||||||
# Start new staging containers
|
|
||||||
echo "🚀 Starting new staging containers..."
|
|
||||||
docker compose -f docker-compose.staging.yml up -d
|
|
||||||
|
|
||||||
# Wait a moment for containers to start
|
|
||||||
echo "⏳ Waiting for containers to start..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Check container logs for debugging
|
|
||||||
echo "📋 Container logs (first 20 lines):"
|
|
||||||
docker compose -f docker-compose.staging.yml logs --tail=20
|
|
||||||
|
|
||||||
echo "✅ Staging deployment completed!"
|
|
||||||
env:
|
|
||||||
NODE_ENV: staging
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL }}
|
|
||||||
N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
|
|
||||||
|
|
||||||
- name: Wait for containers to be ready
|
|
||||||
run: |
|
|
||||||
echo "⏳ Waiting for containers to be ready..."
|
|
||||||
sleep 45
|
|
||||||
|
|
||||||
# Check if all containers are running
|
|
||||||
echo "📊 Checking container status..."
|
|
||||||
docker compose -f docker-compose.staging.yml ps
|
|
||||||
|
|
||||||
# Wait for application container to be healthy
|
|
||||||
echo "🏥 Waiting for application container to be healthy..."
|
|
||||||
for i in {1..60}; do
|
|
||||||
if docker exec portfolio-app-staging curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Application container is healthy!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for application container... ($i/60)"
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
# Additional wait for main page to be accessible
|
|
||||||
echo "🌐 Waiting for main page to be accessible..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if curl -f http://localhost:3001/ > /dev/null 2>&1; then
|
|
||||||
echo "✅ Main page is accessible!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for main page... ($i/30)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Health check
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running comprehensive health checks..."
|
|
||||||
|
|
||||||
# Check container status
|
|
||||||
echo "📊 Container status:"
|
|
||||||
docker compose -f docker-compose.staging.yml ps
|
|
||||||
|
|
||||||
# Check application container
|
|
||||||
echo "🏥 Checking application container..."
|
|
||||||
if docker exec portfolio-app-staging curl -f http://localhost:3000/api/health; then
|
|
||||||
echo "✅ Application health check passed!"
|
|
||||||
else
|
|
||||||
echo "❌ Application health check failed!"
|
|
||||||
docker logs portfolio-app-staging --tail=50
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check main page
|
|
||||||
if curl -f http://localhost:3001/ > /dev/null; then
|
|
||||||
echo "✅ Main page is accessible!"
|
|
||||||
else
|
|
||||||
echo "❌ Main page is not accessible!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All health checks passed! Staging deployment successful!"
|
|
||||||
|
|
||||||
- name: Cleanup old images
|
|
||||||
run: |
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker image prune -f
|
|
||||||
docker system prune -f
|
|
||||||
echo "✅ Cleanup completed"
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
name: CI/CD Pipeline (Woodpecker)
|
|
||||||
|
|
||||||
when:
|
|
||||||
event: push
|
|
||||||
branch: production
|
|
||||||
|
|
||||||
steps:
|
|
||||||
build:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- echo "🚀 Starting CI/CD Pipeline"
|
|
||||||
- echo "📋 Step 1: Installing dependencies..."
|
|
||||||
- npm ci --prefer-offline --no-audit
|
|
||||||
- echo "🔍 Step 2: Running linting..."
|
|
||||||
- npm run lint
|
|
||||||
- echo "🧪 Step 3: Running tests..."
|
|
||||||
- npm run test
|
|
||||||
- echo "🏗️ Step 4: Building application..."
|
|
||||||
- npm run build
|
|
||||||
- echo "🔒 Step 5: Running security scan..."
|
|
||||||
- npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
|
|
||||||
volumes:
|
|
||||||
- node_modules:/app/node_modules
|
|
||||||
|
|
||||||
docker-build:
|
|
||||||
image: docker:latest
|
|
||||||
commands:
|
|
||||||
- echo "🐳 Building Docker image..."
|
|
||||||
- docker build -t portfolio-app:latest .
|
|
||||||
- docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
image: docker:latest
|
|
||||||
commands:
|
|
||||||
- echo "🚀 Deploying application..."
|
|
||||||
|
|
||||||
# Verify secrets and variables
|
|
||||||
- echo "🔍 Verifying secrets and variables..."
|
|
||||||
- |
|
|
||||||
if [ -z "$NEXT_PUBLIC_BASE_URL" ]; then
|
|
||||||
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$MY_EMAIL" ]; then
|
|
||||||
echo "❌ MY_EMAIL variable is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$MY_INFO_EMAIL" ]; then
|
|
||||||
echo "❌ MY_INFO_EMAIL variable is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$MY_PASSWORD" ]; then
|
|
||||||
echo "❌ MY_PASSWORD secret is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$MY_INFO_PASSWORD" ]; then
|
|
||||||
echo "❌ MY_INFO_PASSWORD secret is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "$ADMIN_BASIC_AUTH" ]; then
|
|
||||||
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ All required secrets and variables are present"
|
|
||||||
|
|
||||||
# Check if current container is running
|
|
||||||
- |
|
|
||||||
if docker ps -q -f name=portfolio-app | grep -q .; then
|
|
||||||
echo "📊 Current container is running, proceeding with zero-downtime update"
|
|
||||||
CURRENT_CONTAINER_RUNNING=true
|
|
||||||
else
|
|
||||||
echo "📊 No current container running, doing fresh deployment"
|
|
||||||
CURRENT_CONTAINER_RUNNING=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure database and redis are running
|
|
||||||
- echo "🔧 Ensuring database and redis are running..."
|
|
||||||
- docker compose up -d postgres redis
|
|
||||||
- sleep 10
|
|
||||||
|
|
||||||
# Deploy with zero downtime
|
|
||||||
- |
|
|
||||||
if [ "$CURRENT_CONTAINER_RUNNING" = "true" ]; then
|
|
||||||
echo "🔄 Performing rolling update..."
|
|
||||||
|
|
||||||
# Generate unique container name
|
|
||||||
TIMESTAMP=$(date +%s)
|
|
||||||
TEMP_CONTAINER_NAME="portfolio-app-temp-$TIMESTAMP"
|
|
||||||
echo "🔧 Using temporary container name: $TEMP_CONTAINER_NAME"
|
|
||||||
|
|
||||||
# Clean up any existing temporary containers
|
|
||||||
echo "🧹 Cleaning up any existing temporary containers..."
|
|
||||||
docker rm -f portfolio-app-new portfolio-app-temp-* portfolio-app-backup || true
|
|
||||||
|
|
||||||
# Find and remove any containers with portfolio-app in the name (except the main one)
|
|
||||||
EXISTING_CONTAINERS=$(docker ps -a --format "table {{.Names}}" | grep "portfolio-app" | grep -v "^portfolio-app$" || true)
|
|
||||||
if [ -n "$EXISTING_CONTAINERS" ]; then
|
|
||||||
echo "🗑️ Removing existing portfolio-app containers:"
|
|
||||||
echo "$EXISTING_CONTAINERS"
|
|
||||||
echo "$EXISTING_CONTAINERS" | xargs -r docker rm -f || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also clean up any stopped containers
|
|
||||||
docker container prune -f || true
|
|
||||||
|
|
||||||
# Start new container with unique temporary name
|
|
||||||
docker run -d \
|
|
||||||
--name $TEMP_CONTAINER_NAME \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network portfolio_net \
|
|
||||||
-e NODE_ENV=$NODE_ENV \
|
|
||||||
-e LOG_LEVEL=$LOG_LEVEL \
|
|
||||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
|
||||||
-e REDIS_URL=redis://redis:6379 \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
|
||||||
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
|
|
||||||
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
|
||||||
-e MY_EMAIL="$MY_EMAIL" \
|
|
||||||
-e MY_INFO_EMAIL="$MY_INFO_EMAIL" \
|
|
||||||
-e MY_PASSWORD="$MY_PASSWORD" \
|
|
||||||
-e MY_INFO_PASSWORD="$MY_INFO_PASSWORD" \
|
|
||||||
-e ADMIN_BASIC_AUTH="$ADMIN_BASIC_AUTH" \
|
|
||||||
portfolio-app:latest
|
|
||||||
|
|
||||||
# Wait for new container to be ready
|
|
||||||
echo "⏳ Waiting for new container to be ready..."
|
|
||||||
sleep 15
|
|
||||||
|
|
||||||
# Health check new container
|
|
||||||
for i in {1..20}; do
|
|
||||||
if docker exec $TEMP_CONTAINER_NAME curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ New container is healthy!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Health check attempt $i/20..."
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
# Stop old container
|
|
||||||
echo "🛑 Stopping old container..."
|
|
||||||
docker stop portfolio-app || true
|
|
||||||
docker rm portfolio-app || true
|
|
||||||
|
|
||||||
# Rename new container
|
|
||||||
docker rename $TEMP_CONTAINER_NAME portfolio-app
|
|
||||||
|
|
||||||
# Update port mapping
|
|
||||||
docker stop portfolio-app
|
|
||||||
docker rm portfolio-app
|
|
||||||
|
|
||||||
# Start with correct port
|
|
||||||
docker run -d \
|
|
||||||
--name portfolio-app \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network portfolio_net \
|
|
||||||
-p 3000:3000 \
|
|
||||||
-e NODE_ENV=$NODE_ENV \
|
|
||||||
-e LOG_LEVEL=$LOG_LEVEL \
|
|
||||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
|
||||||
-e REDIS_URL=redis://redis:6379 \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
|
||||||
-e NEXT_PUBLIC_UMAMI_URL="$NEXT_PUBLIC_UMAMI_URL" \
|
|
||||||
-e NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
|
||||||
-e MY_EMAIL="$MY_EMAIL" \
|
|
||||||
-e MY_INFO_EMAIL="$MY_INFO_EMAIL" \
|
|
||||||
-e MY_PASSWORD="$MY_PASSWORD" \
|
|
||||||
-e MY_INFO_PASSWORD="$MY_INFO_PASSWORD" \
|
|
||||||
-e ADMIN_BASIC_AUTH="$ADMIN_BASIC_AUTH" \
|
|
||||||
portfolio-app:latest
|
|
||||||
|
|
||||||
echo "✅ Rolling update completed!"
|
|
||||||
else
|
|
||||||
echo "🆕 Fresh deployment..."
|
|
||||||
docker compose up -d
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for container to be ready
|
|
||||||
- echo "⏳ Waiting for container to be ready..."
|
|
||||||
- sleep 15
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
- |
|
|
||||||
echo "🏥 Performing health check..."
|
|
||||||
for i in {1..40}; do
|
|
||||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Application is healthy!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Health check attempt $i/40..."
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
# Final verification
|
|
||||||
- echo "🔍 Final health verification..."
|
|
||||||
- docker ps --filter "name=portfolio-app" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
|
||||||
- |
|
|
||||||
if curl -f http://localhost:3000/api/health; then
|
|
||||||
echo "✅ Health endpoint accessible"
|
|
||||||
else
|
|
||||||
echo "❌ Health endpoint not accessible"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
if curl -f http://localhost:3000/ > /dev/null; then
|
|
||||||
echo "✅ Main page is accessible"
|
|
||||||
else
|
|
||||||
echo "❌ Main page is not accessible"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- echo "✅ Deployment successful!"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
- docker image prune -f
|
|
||||||
- docker system prune -f
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
environment:
|
|
||||||
- NODE_ENV
|
|
||||||
- LOG_LEVEL
|
|
||||||
- NEXT_PUBLIC_BASE_URL
|
|
||||||
- NEXT_PUBLIC_UMAMI_URL
|
|
||||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
|
||||||
- MY_EMAIL
|
|
||||||
- MY_INFO_EMAIL
|
|
||||||
- MY_PASSWORD
|
|
||||||
- MY_INFO_PASSWORD
|
|
||||||
- ADMIN_BASIC_AUTH
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
node_modules:
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
name: Debug Secrets
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
debug-secrets:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Debug Environment Variables
|
|
||||||
run: |
|
|
||||||
echo "🔍 Checking if secrets are available..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "📊 VARIABLES:"
|
|
||||||
echo "✅ NODE_ENV: ${{ vars.NODE_ENV }}"
|
|
||||||
echo "✅ LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
|
|
||||||
echo "✅ NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
|
||||||
echo "✅ NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
|
|
||||||
echo "✅ NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
|
|
||||||
echo "✅ MY_EMAIL: ${{ vars.MY_EMAIL }}"
|
|
||||||
echo "✅ MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🔐 SECRETS:"
|
|
||||||
if [ -n "${{ secrets.MY_PASSWORD }}" ]; then
|
|
||||||
echo "✅ MY_PASSWORD: Set (length: ${#MY_PASSWORD})"
|
|
||||||
else
|
|
||||||
echo "❌ MY_PASSWORD: Not set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${{ secrets.MY_INFO_PASSWORD }}" ]; then
|
|
||||||
echo "✅ MY_INFO_PASSWORD: Set (length: ${#MY_INFO_PASSWORD})"
|
|
||||||
else
|
|
||||||
echo "❌ MY_INFO_PASSWORD: Not set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
|
|
||||||
echo "✅ ADMIN_BASIC_AUTH: Set (length: ${#ADMIN_BASIC_AUTH})"
|
|
||||||
else
|
|
||||||
echo "❌ ADMIN_BASIC_AUTH: Not set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "📋 Summary:"
|
|
||||||
echo "Variables: 7 configured"
|
|
||||||
echo "Secrets: 3 configured"
|
|
||||||
echo "Total environment variables: 10"
|
|
||||||
env:
|
|
||||||
NODE_ENV: ${{ vars.NODE_ENV }}
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
|
|
||||||
- name: Test Docker Environment
|
|
||||||
run: |
|
|
||||||
echo "🐳 Testing Docker environment with secrets..."
|
|
||||||
|
|
||||||
# Create a test container to verify environment variables
|
|
||||||
docker run --rm \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
|
||||||
-e REDIS_URL=redis://redis:6379 \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
|
||||||
-e MY_EMAIL="${{ secrets.MY_EMAIL }}" \
|
|
||||||
-e MY_INFO_EMAIL="${{ secrets.MY_INFO_EMAIL }}" \
|
|
||||||
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
|
|
||||||
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
|
|
||||||
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
|
|
||||||
alpine:latest sh -c '
|
|
||||||
echo "Environment variables in container:"
|
|
||||||
echo "NODE_ENV: $NODE_ENV"
|
|
||||||
echo "DATABASE_URL: $DATABASE_URL"
|
|
||||||
echo "REDIS_URL: $REDIS_URL"
|
|
||||||
echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL"
|
|
||||||
echo "MY_EMAIL: $MY_EMAIL"
|
|
||||||
echo "MY_INFO_EMAIL: $MY_INFO_EMAIL"
|
|
||||||
echo "MY_PASSWORD: [HIDDEN - length: ${#MY_PASSWORD}]"
|
|
||||||
echo "MY_INFO_PASSWORD: [HIDDEN - length: ${#MY_INFO_PASSWORD}]"
|
|
||||||
echo "ADMIN_BASIC_AUTH: [HIDDEN - length: ${#ADMIN_BASIC_AUTH}]"
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Validate Secret Formats
|
|
||||||
run: |
|
|
||||||
echo "🔐 Validating secret formats..."
|
|
||||||
|
|
||||||
# Check NEXT_PUBLIC_BASE_URL format
|
|
||||||
if [[ "${{ secrets.NEXT_PUBLIC_BASE_URL }}" =~ ^https?:// ]]; then
|
|
||||||
echo "✅ NEXT_PUBLIC_BASE_URL: Valid URL format"
|
|
||||||
else
|
|
||||||
echo "❌ NEXT_PUBLIC_BASE_URL: Invalid URL format (should start with http:// or https://)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check email formats
|
|
||||||
if [[ "${{ secrets.MY_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
|
||||||
echo "✅ MY_EMAIL: Valid email format"
|
|
||||||
else
|
|
||||||
echo "❌ MY_EMAIL: Invalid email format"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ secrets.MY_INFO_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
|
||||||
echo "✅ MY_INFO_EMAIL: Valid email format"
|
|
||||||
else
|
|
||||||
echo "❌ MY_INFO_EMAIL: Invalid email format"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check ADMIN_BASIC_AUTH format (should be username:password)
|
|
||||||
if [[ "${{ secrets.ADMIN_BASIC_AUTH }}" =~ ^[^:]+:.+$ ]]; then
|
|
||||||
echo "✅ ADMIN_BASIC_AUTH: Valid format (username:password)"
|
|
||||||
else
|
|
||||||
echo "❌ ADMIN_BASIC_AUTH: Invalid format (should be username:password)"
|
|
||||||
fi
|
|
||||||
126
.gitea/workflows/dev-deploy.yml
Normal file
126
.gitea/workflows/dev-deploy.yml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
name: Dev Deployment (Zero Downtime)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ dev ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
DOCKER_IMAGE: portfolio-app
|
||||||
|
IMAGE_TAG: staging
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-dev:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
echo "🏗️ Building dev Docker image..."
|
||||||
|
docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} .
|
||||||
|
echo "✅ Docker image built successfully"
|
||||||
|
|
||||||
|
- name: Zero-Downtime Dev Deployment
|
||||||
|
run: |
|
||||||
|
echo "🚀 Starting zero-downtime dev deployment..."
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
CONTAINER_NAME="portfolio-app-staging"
|
||||||
|
HEALTH_PORT="3002"
|
||||||
|
|
||||||
|
# Backup current container ID if running
|
||||||
|
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
|
||||||
|
|
||||||
|
# Start new container with updated image
|
||||||
|
echo "🆕 Starting new dev container..."
|
||||||
|
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
|
||||||
|
|
||||||
|
# Wait for new container to be healthy
|
||||||
|
echo "⏳ Waiting for new container to be healthy..."
|
||||||
|
for i in {1..60}; do
|
||||||
|
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||||
|
if [ ! -z "$NEW_CONTAINER" ]; then
|
||||||
|
# Check health status
|
||||||
|
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||||
|
if [ "$HEALTH" == "healthy" ]; then
|
||||||
|
echo "✅ New container is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# Also check HTTP health endpoint
|
||||||
|
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ New container is responding!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "⏳ Waiting... ($i/60)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify new container is working
|
||||||
|
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
|
||||||
|
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove old container if it exists and is different
|
||||||
|
if [ ! -z "$OLD_CONTAINER" ]; then
|
||||||
|
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||||
|
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
||||||
|
echo "🧹 Removing old container..."
|
||||||
|
docker stop $OLD_CONTAINER 2>/dev/null || true
|
||||||
|
docker rm $OLD_CONTAINER 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Dev deployment completed!"
|
||||||
|
env:
|
||||||
|
NODE_ENV: staging
|
||||||
|
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
|
||||||
|
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||||
|
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||||
|
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||||
|
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||||
|
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||||
|
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||||
|
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||||
|
|
||||||
|
- name: Dev Health Check
|
||||||
|
run: |
|
||||||
|
echo "🔍 Running dev health checks..."
|
||||||
|
for i in {1..20}; do
|
||||||
|
if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then
|
||||||
|
echo "✅ Dev is fully operational!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "⏳ Waiting for dev... ($i/20)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
|
||||||
|
docker compose -f docker-compose.staging.yml logs --tail=50
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
echo "🧹 Cleaning up old images..."
|
||||||
|
docker image prune -f
|
||||||
|
echo "✅ Cleanup completed"
|
||||||
129
.gitea/workflows/production-deploy.yml
Normal file
129
.gitea/workflows/production-deploy.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Production Deployment (Zero Downtime)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ production ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
DOCKER_IMAGE: portfolio-app
|
||||||
|
IMAGE_TAG: production
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test:production
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
echo "🏗️ Building production Docker image..."
|
||||||
|
docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} .
|
||||||
|
docker tag ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} ${{ env.DOCKER_IMAGE }}:latest
|
||||||
|
echo "✅ Docker image built successfully"
|
||||||
|
|
||||||
|
- name: Zero-Downtime Production Deployment
|
||||||
|
run: |
|
||||||
|
echo "🚀 Starting zero-downtime production deployment..."
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose.production.yml"
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
HEALTH_PORT="3000"
|
||||||
|
|
||||||
|
# Backup current container ID if running
|
||||||
|
OLD_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME || echo "")
|
||||||
|
|
||||||
|
# Start new container with updated image (docker-compose will handle this)
|
||||||
|
echo "🆕 Starting new production container..."
|
||||||
|
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio
|
||||||
|
|
||||||
|
# Wait for new container to be healthy
|
||||||
|
echo "⏳ Waiting for new container to be healthy..."
|
||||||
|
for i in {1..60}; do
|
||||||
|
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||||
|
if [ ! -z "$NEW_CONTAINER" ]; then
|
||||||
|
# Check health status
|
||||||
|
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||||
|
if [ "$HEALTH" == "healthy" ]; then
|
||||||
|
echo "✅ New container is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# Also check HTTP health endpoint
|
||||||
|
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ New container is responding!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "⏳ Waiting... ($i/60)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify new container is working
|
||||||
|
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||||
|
echo "❌ New container failed health check!"
|
||||||
|
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove old container if it exists and is different
|
||||||
|
if [ ! -z "$OLD_CONTAINER" ]; then
|
||||||
|
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
||||||
|
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
||||||
|
echo "🧹 Removing old container..."
|
||||||
|
docker stop $OLD_CONTAINER 2>/dev/null || true
|
||||||
|
docker rm $OLD_CONTAINER 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Production deployment completed with zero downtime!"
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
|
||||||
|
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||||
|
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||||
|
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||||
|
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||||
|
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||||
|
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||||
|
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||||
|
|
||||||
|
- name: Production Health Check
|
||||||
|
run: |
|
||||||
|
echo "🔍 Running production health checks..."
|
||||||
|
for i in {1..20}; do
|
||||||
|
if curl -f http://localhost:3000/api/health && curl -f http://localhost:3000/ > /dev/null; then
|
||||||
|
echo "✅ Production is fully operational!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "⏳ Waiting for production... ($i/20)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "❌ Production health check failed!"
|
||||||
|
docker compose -f docker-compose.production.yml logs --tail=50
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
echo "🧹 Cleaning up old images..."
|
||||||
|
docker image prune -f
|
||||||
|
echo "✅ Cleanup completed"
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
name: Test and Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '20'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-and-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: 'package-lock.json'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run security scan
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running npm audit..."
|
|
||||||
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
name: Test Gitea Variables and Secrets
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ production ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-variables:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Test Variables and Secrets Access
|
|
||||||
run: |
|
|
||||||
echo "🔍 Testing Gitea Variables and Secrets access..."
|
|
||||||
|
|
||||||
# Test Variables
|
|
||||||
echo "📝 Testing Variables:"
|
|
||||||
echo "NEXT_PUBLIC_BASE_URL: '${{ vars.NEXT_PUBLIC_BASE_URL }}'"
|
|
||||||
echo "MY_EMAIL: '${{ vars.MY_EMAIL }}'"
|
|
||||||
echo "MY_INFO_EMAIL: '${{ vars.MY_INFO_EMAIL }}'"
|
|
||||||
echo "NODE_ENV: '${{ vars.NODE_ENV }}'"
|
|
||||||
echo "LOG_LEVEL: '${{ vars.LOG_LEVEL }}'"
|
|
||||||
echo "NEXT_PUBLIC_UMAMI_URL: '${{ vars.NEXT_PUBLIC_UMAMI_URL }}'"
|
|
||||||
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID: '${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}'"
|
|
||||||
|
|
||||||
# Test Secrets (without revealing values)
|
|
||||||
echo ""
|
|
||||||
echo "🔐 Testing Secrets:"
|
|
||||||
echo "MY_PASSWORD: '$([ -n "${{ secrets.MY_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
|
||||||
echo "MY_INFO_PASSWORD: '$([ -n "${{ secrets.MY_INFO_PASSWORD }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
|
||||||
echo "ADMIN_BASIC_AUTH: '$([ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ] && echo "[SET]" || echo "[NOT SET]")'"
|
|
||||||
|
|
||||||
# Check if variables are empty
|
|
||||||
echo ""
|
|
||||||
echo "🔍 Checking for empty variables:"
|
|
||||||
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
|
|
||||||
echo "❌ NEXT_PUBLIC_BASE_URL is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ NEXT_PUBLIC_BASE_URL is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${{ vars.MY_EMAIL }}" ]; then
|
|
||||||
echo "❌ MY_EMAIL is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ MY_EMAIL is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
|
|
||||||
echo "❌ MY_INFO_EMAIL is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ MY_INFO_EMAIL is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check secrets
|
|
||||||
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
|
|
||||||
echo "❌ MY_PASSWORD secret is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ MY_PASSWORD secret is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
|
|
||||||
echo "❌ MY_INFO_PASSWORD secret is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ MY_INFO_PASSWORD secret is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
|
|
||||||
echo "❌ ADMIN_BASIC_AUTH secret is empty or not set"
|
|
||||||
else
|
|
||||||
echo "✅ ADMIN_BASIC_AUTH secret is set"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "📊 Summary:"
|
|
||||||
echo "Variables set: $(echo '${{ vars.NEXT_PUBLIC_BASE_URL }}' | wc -c)"
|
|
||||||
echo "Secrets set: $(echo '${{ secrets.MY_PASSWORD }}' | wc -c)"
|
|
||||||
|
|
||||||
- name: Test Environment Variable Export
|
|
||||||
run: |
|
|
||||||
echo "🧪 Testing environment variable export..."
|
|
||||||
|
|
||||||
# Export variables as environment variables
|
|
||||||
export NODE_ENV="${{ vars.NODE_ENV }}"
|
|
||||||
export LOG_LEVEL="${{ vars.LOG_LEVEL }}"
|
|
||||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
|
||||||
export NEXT_PUBLIC_UMAMI_URL="${{ vars.NEXT_PUBLIC_UMAMI_URL }}"
|
|
||||||
export NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}"
|
|
||||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
|
||||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
|
||||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
|
||||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
|
||||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
|
||||||
|
|
||||||
echo "📝 Exported environment variables:"
|
|
||||||
echo "NODE_ENV: ${NODE_ENV:-[NOT SET]}"
|
|
||||||
echo "LOG_LEVEL: ${LOG_LEVEL:-[NOT SET]}"
|
|
||||||
echo "NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-[NOT SET]}"
|
|
||||||
echo "MY_EMAIL: ${MY_EMAIL:-[NOT SET]}"
|
|
||||||
echo "MY_INFO_EMAIL: ${MY_INFO_EMAIL:-[NOT SET]}"
|
|
||||||
echo "MY_PASSWORD: $([ -n "${MY_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
|
|
||||||
echo "MY_INFO_PASSWORD: $([ -n "${MY_INFO_PASSWORD}" ] && echo "[SET]" || echo "[NOT SET]")"
|
|
||||||
echo "ADMIN_BASIC_AUTH: $([ -n "${ADMIN_BASIC_AUTH}" ] && echo "[SET]" || echo "[NOT SET]")"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 🚀 Auto-Deployment Status
|
|
||||||
|
|
||||||
## Current Setup
|
|
||||||
|
|
||||||
### GitHub Actions Workflow (`.github/workflows/ci-cd.yml`)
|
|
||||||
|
|
||||||
**Triggers on**: Push to `main` OR `production` branches
|
|
||||||
|
|
||||||
**What happens on `main` branch**:
|
|
||||||
- ✅ Runs tests
|
|
||||||
- ✅ Runs linting
|
|
||||||
- ✅ Builds Docker image
|
|
||||||
- ✅ Pushes image to registry
|
|
||||||
- ❌ **Does NOT deploy to server**
|
|
||||||
|
|
||||||
**What happens on `production` branch**:
|
|
||||||
- ✅ Runs tests
|
|
||||||
- ✅ Runs linting
|
|
||||||
- ✅ Builds Docker image
|
|
||||||
- ✅ Pushes image to registry
|
|
||||||
- ✅ **Deploys to server automatically**
|
|
||||||
|
|
||||||
### Key Line in Workflow
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Line 159 in .github/workflows/ci-cd.yml
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
|
||||||
```
|
|
||||||
|
|
||||||
This means deployment **only** happens on `production` branch.
|
|
||||||
|
|
||||||
## Answer: Can you merge to main and auto-deploy?
|
|
||||||
|
|
||||||
**❌ NO** - Merging to `main` will:
|
|
||||||
- Build and test everything
|
|
||||||
- Create Docker image
|
|
||||||
- **But NOT deploy to your server**
|
|
||||||
|
|
||||||
**✅ YES** - Merging to `production` will:
|
|
||||||
- Build and test everything
|
|
||||||
- Create Docker image
|
|
||||||
- **AND deploy to your server automatically**
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
### Option 1: Use Production Branch (Current Setup)
|
|
||||||
```bash
|
|
||||||
# Merge dev → main (tests/build only)
|
|
||||||
git checkout main
|
|
||||||
git merge dev
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# Then merge main → production (auto-deploys)
|
|
||||||
git checkout production
|
|
||||||
git merge main
|
|
||||||
git push origin production # ← This triggers deployment
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Enable Auto-Deploy on Main
|
|
||||||
If you want `main` to auto-deploy, I can update the workflow to deploy on `main` as well.
|
|
||||||
|
|
||||||
### Option 3: Manual Deployment
|
|
||||||
After merging to `main`, manually run:
|
|
||||||
```bash
|
|
||||||
./scripts/gitea-deploy.sh
|
|
||||||
# or
|
|
||||||
./scripts/auto-deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
**Keep current setup** (deploy only on `production`):
|
|
||||||
- ✅ Safer: `main` is for testing builds
|
|
||||||
- ✅ `production` is explicitly for deployments
|
|
||||||
- ✅ Can test on `main` without deploying
|
|
||||||
- ✅ Clear separation of concerns
|
|
||||||
|
|
||||||
**Workflow**:
|
|
||||||
1. Merge `dev` → `main` (validates build works)
|
|
||||||
2. Test the built image if needed
|
|
||||||
3. Merge `main` → `production` (auto-deploys)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Current Status**: Auto-deployment is configured, but only for `production` branch.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 🧹 Codebase Cleanup Plan
|
|
||||||
|
|
||||||
## MD Files Analysis
|
|
||||||
|
|
||||||
### ✅ KEEP (Essential Documentation)
|
|
||||||
1. **README.md** - Main project documentation
|
|
||||||
2. **docs/ai-image-generation/README.md** - AI feature docs
|
|
||||||
3. **docs/ai-image-generation/SETUP.md** - Setup guide
|
|
||||||
4. **docs/ai-image-generation/QUICKSTART.md** - Quick start
|
|
||||||
5. **docs/ai-image-generation/WEBHOOK_SETUP.md** - Webhook setup (just created)
|
|
||||||
6. **TESTING_GUIDE.md** - Testing documentation
|
|
||||||
7. **SAFE_PUSH_TO_MAIN.md** - Deployment guide
|
|
||||||
8. **AUTO_DEPLOYMENT_STATUS.md** - Deployment status (just created)
|
|
||||||
|
|
||||||
### ❌ REMOVE (Old/Duplicate/Outdated)
|
|
||||||
1. **CHANGELOG_DEV.md** - Old changelog, can be in git history
|
|
||||||
2. **PUSH_READY.md** - One-time status file
|
|
||||||
3. **COMMIT_MESSAGE.txt** - One-time commit message
|
|
||||||
4. **DEPLOYMENT-FIXES.md** - Old fixes, should be in git
|
|
||||||
5. **DEPLOYMENT-IMPROVEMENTS.md** - Old improvements
|
|
||||||
6. **DEPLOYMENT.md** - Duplicate of PRODUCTION-DEPLOYMENT.md
|
|
||||||
7. **AFTER_PUSH_SETUP.md** - One-time setup guide
|
|
||||||
8. **PRE_PUSH_CHECKLIST.md** - Can merge into SAFE_PUSH_TO_MAIN.md
|
|
||||||
9. **TEST_FIXES.md** - One-time fix notes
|
|
||||||
10. **AUTOMATED_TESTING_SETUP.md** - Info now in TESTING_GUIDE.md
|
|
||||||
11. **SECURITY-UPDATE.md** - Old update notes
|
|
||||||
12. **SECURITY-CHECKLIST.md** - Can merge into SECURITY.md
|
|
||||||
13. **ANALYTICS.md** - If not actively used
|
|
||||||
14. **PRODUCTION-DEPLOYMENT.md** - If DEPLOYMENT.md covers it
|
|
||||||
|
|
||||||
### 📁 CONSOLIDATE (Merge into main docs)
|
|
||||||
- **docs/IMPROVEMENTS_SUMMARY.md** → Merge into README or remove
|
|
||||||
- **docs/CODING_DETECTION_DEBUG.md** → Remove if not needed
|
|
||||||
- **docs/DYNAMIC_ACTIVITY_MANAGEMENT.md** → Keep if actively used
|
|
||||||
- **docs/ACTIVITY_FEATURES.md** → Keep if actively used
|
|
||||||
- **docs/N8N_CHAT_SETUP.md** → Keep if using n8n chat
|
|
||||||
- **docs/N8N_INTEGRATION.md** → Keep if using n8n
|
|
||||||
|
|
||||||
## Old/Unused Files to Remove
|
|
||||||
|
|
||||||
### Scripts (Many duplicates)
|
|
||||||
- `scripts/test-fix.sh` - One-time fix
|
|
||||||
- `scripts/test-deployment.sh` - One-time test
|
|
||||||
- `scripts/quick-health-fix.sh` - One-time fix
|
|
||||||
- `scripts/fix-connection.sh` - One-time fix
|
|
||||||
- `scripts/debug-gitea-actions.sh` - Debug script, not needed
|
|
||||||
- Multiple docker-compose files (keep only needed ones)
|
|
||||||
|
|
||||||
### Disabled Workflows
|
|
||||||
- `.gitea/workflows/*.disabled` - Remove all disabled workflows
|
|
||||||
|
|
||||||
### Old Test Results
|
|
||||||
- `test-results/` - Can be regenerated
|
|
||||||
- `playwright-report/` - Can be regenerated
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
- `logs/*.log` - Should be in .gitignore
|
|
||||||
|
|
||||||
## Git Remote Issue
|
|
||||||
Current: `https://git.dk0.dev/denshooter/portfolio`
|
|
||||||
Issue: Can't connect to git.dk0.dev:443
|
|
||||||
|
|
||||||
Options:
|
|
||||||
1. Check if server is up
|
|
||||||
2. Use SSH instead: `git@git.dk0.dev:denshooter/portfolio.git`
|
|
||||||
3. Check if URL changed
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 🧹 Cleanup Summary
|
|
||||||
|
|
||||||
## Files Removed
|
|
||||||
|
|
||||||
### Documentation (15 files)
|
|
||||||
- ✅ CHANGELOG_DEV.md - Old changelog
|
|
||||||
- ✅ PUSH_READY.md - One-time status
|
|
||||||
- ✅ COMMIT_MESSAGE.txt - One-time commit message
|
|
||||||
- ✅ DEPLOYMENT-FIXES.md - Old fixes
|
|
||||||
- ✅ DEPLOYMENT-IMPROVEMENTS.md - Old improvements
|
|
||||||
- ✅ DEPLOYMENT.md - Duplicate
|
|
||||||
- ✅ AFTER_PUSH_SETUP.md - One-time setup
|
|
||||||
- ✅ PRE_PUSH_CHECKLIST.md - Merged into SAFE_PUSH_TO_MAIN.md
|
|
||||||
- ✅ TEST_FIXES.md - One-time fixes
|
|
||||||
- ✅ AUTOMATED_TESTING_SETUP.md - Info in TESTING_GUIDE.md
|
|
||||||
- ✅ SECURITY-UPDATE.md - Old update
|
|
||||||
- ✅ SECURITY-CHECKLIST.md - Merged into SECURITY.md
|
|
||||||
- ✅ PRODUCTION-DEPLOYMENT.md - Duplicate
|
|
||||||
- ✅ ANALYTICS.md - Not actively used
|
|
||||||
- ✅ docs/IMPROVEMENTS_SUMMARY.md - Old summary
|
|
||||||
- ✅ docs/CODING_DETECTION_DEBUG.md - Debug notes
|
|
||||||
|
|
||||||
### Scripts (4 files)
|
|
||||||
- ✅ scripts/quick-health-fix.sh - One-time fix
|
|
||||||
- ✅ scripts/fix-connection.sh - One-time fix
|
|
||||||
- ✅ scripts/debug-gitea-actions.sh - Debug script
|
|
||||||
|
|
||||||
### Workflows (7 files)
|
|
||||||
- ✅ .gitea/workflows/*.disabled - All disabled workflows removed
|
|
||||||
|
|
||||||
### Docker Configs (2 files)
|
|
||||||
- ✅ docker-compose.zero-downtime.yml - Old version
|
|
||||||
- ✅ docker-compose.zero-downtime-fixed.yml - Old version
|
|
||||||
- ✅ nginx-zero-downtime.conf - Unused
|
|
||||||
|
|
||||||
## Files Kept (Essential)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- ✅ README.md - Main docs
|
|
||||||
- ✅ DEV-SETUP.md - Setup guide
|
|
||||||
- ✅ SECURITY.md - Security info
|
|
||||||
- ✅ TESTING_GUIDE.md - Testing docs
|
|
||||||
- ✅ SAFE_PUSH_TO_MAIN.md - Deployment guide
|
|
||||||
- ✅ AUTO_DEPLOYMENT_STATUS.md - Deployment status
|
|
||||||
- ✅ docs/ai-image-generation/* - AI feature docs
|
|
||||||
- ✅ docs/ACTIVITY_FEATURES.md - Activity features
|
|
||||||
- ✅ docs/DYNAMIC_ACTIVITY_MANAGEMENT.md - Activity management
|
|
||||||
- ✅ docs/N8N_CHAT_SETUP.md - n8n chat setup
|
|
||||||
- ✅ docs/N8N_INTEGRATION.md - n8n integration
|
|
||||||
|
|
||||||
### Docker Configs
|
|
||||||
- ✅ docker-compose.yml - Main config
|
|
||||||
- ✅ docker-compose.production.yml - Production
|
|
||||||
- ✅ docker-compose.dev.minimal.yml - Dev minimal
|
|
||||||
|
|
||||||
## Git Remote Fixed
|
|
||||||
|
|
||||||
**Before**: `https://git.dk0.dev/denshooter/portfolio` (HTTPS - connection issues)
|
|
||||||
**After**: `git@git.dk0.dev:denshooter/portfolio.git` (SSH - more reliable)
|
|
||||||
|
|
||||||
## .gitignore Updated
|
|
||||||
|
|
||||||
Added:
|
|
||||||
- `logs/*.log` - Log files
|
|
||||||
- `test-results/` - Test results
|
|
||||||
- `playwright-report/` - Playwright reports
|
|
||||||
- `coverage/` - Coverage reports
|
|
||||||
- `.idea/` - IDE files
|
|
||||||
- `.vscode/` - IDE files
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Test Git connection**:
|
|
||||||
```bash
|
|
||||||
git fetch
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **If SSH doesn't work**, switch back to HTTPS:
|
|
||||||
```bash
|
|
||||||
git remote set-url origin https://git.dk0.dev/denshooter/portfolio.git
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Commit cleanup**:
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "chore: Clean up old documentation and unused files"
|
|
||||||
git push origin dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
- **Removed**: ~30 files
|
|
||||||
- **Kept**: Essential documentation and configs
|
|
||||||
- **Fixed**: Git remote connection
|
|
||||||
- **Updated**: .gitignore for better file management
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# 🔧 Deployment Fixes Applied
|
|
||||||
|
|
||||||
## Issues Fixed
|
|
||||||
|
|
||||||
### 1. Port 3001 Already Allocated ❌ → ✅
|
|
||||||
**Problem**: Port 3001 was already in use, causing staging deployment to fail.
|
|
||||||
|
|
||||||
**Fix**:
|
|
||||||
- Changed staging port from `3001` to `3002`
|
|
||||||
- Changed PostgreSQL staging port from `5433` to `5434`
|
|
||||||
- Changed Redis staging port from `6380` to `6381`
|
|
||||||
|
|
||||||
### 2. Docker Compose Version Warning ❌ → ✅
|
|
||||||
**Problem**: `version: '3.8'` is obsolete in newer Docker Compose.
|
|
||||||
|
|
||||||
**Fix**: Removed `version` line from `docker-compose.staging.yml`
|
|
||||||
|
|
||||||
### 3. Missing N8N Environment Variables ❌ → ✅
|
|
||||||
**Problem**: `N8N_SECRET_TOKEN` warning appeared.
|
|
||||||
|
|
||||||
**Fix**: Added `N8N_WEBHOOK_URL` and `N8N_SECRET_TOKEN` to staging compose file
|
|
||||||
|
|
||||||
### 4. Wrong Compose File Used ❌ → ✅
|
|
||||||
**Problem**: Gitea workflow was using wrong compose file (stopping production containers).
|
|
||||||
|
|
||||||
**Fix**:
|
|
||||||
- Updated `ci-cd-with-gitea-vars.yml` to detect branch and use correct compose file
|
|
||||||
- Created dedicated `staging-deploy.yml` workflow
|
|
||||||
- Staging now uses `docker-compose.staging.yml`
|
|
||||||
- Production uses `docker-compose.production.yml`
|
|
||||||
|
|
||||||
## Updated Ports
|
|
||||||
|
|
||||||
| Service | Staging | Production |
|
|
||||||
|---------|---------|------------|
|
|
||||||
| App | **3002** ✅ | **3000** |
|
|
||||||
| PostgreSQL | **5434** ✅ | **5432** |
|
|
||||||
| Redis | **6381** ✅ | **6379** |
|
|
||||||
|
|
||||||
## How It Works Now
|
|
||||||
|
|
||||||
### Staging (dev/main branch)
|
|
||||||
```bash
|
|
||||||
git push origin dev
|
|
||||||
# → Uses docker-compose.staging.yml
|
|
||||||
# → Deploys to port 3002
|
|
||||||
# → Does NOT touch production containers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (production branch)
|
|
||||||
```bash
|
|
||||||
git push origin production
|
|
||||||
# → Uses docker-compose.production.yml
|
|
||||||
# → Deploys to port 3000
|
|
||||||
# → Zero-downtime deployment
|
|
||||||
# → Does NOT touch staging containers
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Updated
|
|
||||||
|
|
||||||
- ✅ `docker-compose.staging.yml` - Fixed ports, removed version, added N8N vars
|
|
||||||
- ✅ `.gitea/workflows/ci-cd-with-gitea-vars.yml` - Branch detection, correct compose files
|
|
||||||
- ✅ `.gitea/workflows/staging-deploy.yml` - New dedicated staging workflow
|
|
||||||
- ✅ `STAGING_SETUP.md` - Updated port references
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Test staging deployment**:
|
|
||||||
```bash
|
|
||||||
git push origin dev
|
|
||||||
# Should deploy to port 3002 without errors
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify staging**:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3002/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **When ready for production**:
|
|
||||||
```bash
|
|
||||||
git checkout production
|
|
||||||
git merge main
|
|
||||||
git push origin production
|
|
||||||
# Deploys safely to port 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**All fixes applied!** Staging and production are now completely isolated. 🚀
|
|
||||||
200
DEPLOYMENT_SETUP.md
Normal file
200
DEPLOYMENT_SETUP.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 🚀 Deployment Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This project uses a **dual-branch deployment strategy** with zero-downtime deployments:
|
||||||
|
|
||||||
|
- **Production Branch** (`production`) → Serves `https://dk0.dev` on port 3000
|
||||||
|
- **Dev Branch** (`dev`) → Serves `https://dev.dk0.dev` on port 3002
|
||||||
|
|
||||||
|
Both environments are completely isolated with separate:
|
||||||
|
- Docker containers
|
||||||
|
- Databases (PostgreSQL)
|
||||||
|
- Redis instances
|
||||||
|
- Networks
|
||||||
|
- Volumes
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
### Production Branch
|
||||||
|
- **Branch**: `production`
|
||||||
|
- **Domain**: `https://dk0.dev`
|
||||||
|
- **Port**: `3000`
|
||||||
|
- **Container**: `portfolio-app`
|
||||||
|
- **Database**: `portfolio_db` (port 5432)
|
||||||
|
- **Redis**: `portfolio-redis` (port 6379)
|
||||||
|
- **Image Tag**: `portfolio-app:production` / `portfolio-app:latest`
|
||||||
|
|
||||||
|
### Dev Branch
|
||||||
|
- **Branch**: `dev`
|
||||||
|
- **Domain**: `https://dev.dk0.dev`
|
||||||
|
- **Port**: `3002`
|
||||||
|
- **Container**: `portfolio-app-staging`
|
||||||
|
- **Database**: `portfolio_staging_db` (port 5434)
|
||||||
|
- **Redis**: `portfolio-redis-staging` (port 6381)
|
||||||
|
- **Image Tag**: `portfolio-app:staging`
|
||||||
|
|
||||||
|
## Automatic Deployment
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Push to `production` branch**:
|
||||||
|
- Triggers `.gitea/workflows/production-deploy.yml`
|
||||||
|
- Runs tests, builds, and deploys to production
|
||||||
|
- Zero-downtime deployment (starts new container, waits for health, removes old)
|
||||||
|
|
||||||
|
2. **Push to `dev` branch**:
|
||||||
|
- Triggers `.gitea/workflows/dev-deploy.yml`
|
||||||
|
- Runs tests, builds, and deploys to dev/staging
|
||||||
|
- Zero-downtime deployment
|
||||||
|
|
||||||
|
### Zero-Downtime Process
|
||||||
|
|
||||||
|
1. Build new Docker image
|
||||||
|
2. Start new container with updated image
|
||||||
|
3. Wait for new container to be healthy (health checks)
|
||||||
|
4. Verify HTTP endpoints respond correctly
|
||||||
|
5. Remove old container (if different)
|
||||||
|
6. Cleanup old images
|
||||||
|
|
||||||
|
## Manual Deployment
|
||||||
|
|
||||||
|
### Production
|
||||||
|
```bash
|
||||||
|
# Build and deploy production
|
||||||
|
docker build -t portfolio-app:latest .
|
||||||
|
docker compose -f docker-compose.production.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev/Staging
|
||||||
|
```bash
|
||||||
|
# Build and deploy dev
|
||||||
|
docker build -t portfolio-app:staging .
|
||||||
|
docker compose -f docker-compose.staging.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required Gitea Variables
|
||||||
|
- `NEXT_PUBLIC_BASE_URL` - Base URL for the application
|
||||||
|
- `MY_EMAIL` - Email address for contact
|
||||||
|
- `MY_INFO_EMAIL` - Info email address
|
||||||
|
- `LOG_LEVEL` - Logging level (info/debug)
|
||||||
|
|
||||||
|
### Required Gitea Secrets
|
||||||
|
- `MY_PASSWORD` - Email password
|
||||||
|
- `MY_INFO_PASSWORD` - Info email password
|
||||||
|
- `ADMIN_BASIC_AUTH` - Admin basic auth credentials
|
||||||
|
- `N8N_SECRET_TOKEN` - Optional: n8n webhook secret
|
||||||
|
|
||||||
|
### Optional Variables
|
||||||
|
- `N8N_WEBHOOK_URL` - n8n webhook URL for automation
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
Both environments have health check endpoints:
|
||||||
|
- Production: `http://localhost:3000/api/health`
|
||||||
|
- Dev: `http://localhost:3002/api/health`
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Check Container Status
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
docker compose -f docker-compose.production.yml ps
|
||||||
|
|
||||||
|
# Dev
|
||||||
|
docker compose -f docker-compose.staging.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
docker logs portfolio-app --tail=100 -f
|
||||||
|
|
||||||
|
# Dev
|
||||||
|
docker logs portfolio-app-staging --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# Dev
|
||||||
|
curl http://localhost:3002/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
1. Check logs: `docker logs <container-name>`
|
||||||
|
2. Verify environment variables are set
|
||||||
|
3. Check database/redis connectivity
|
||||||
|
4. Verify ports aren't already in use
|
||||||
|
|
||||||
|
### Deployment Fails
|
||||||
|
1. Check Gitea Actions logs
|
||||||
|
2. Verify all required secrets/variables are set
|
||||||
|
3. Check if old containers are blocking ports
|
||||||
|
4. Verify Docker image builds successfully
|
||||||
|
|
||||||
|
### Zero-Downtime Issues
|
||||||
|
- Old container might still be running - check with `docker ps`
|
||||||
|
- Health checks might be failing - check container logs
|
||||||
|
- Port conflicts - verify ports 3000 and 3002 are available
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If a deployment fails or causes issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production rollback
|
||||||
|
docker compose -f docker-compose.production.yml down
|
||||||
|
docker tag portfolio-app:previous portfolio-app:latest
|
||||||
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
|
||||||
|
# Dev rollback
|
||||||
|
docker compose -f docker-compose.staging.yml down
|
||||||
|
docker tag portfolio-app:staging-previous portfolio-app:staging
|
||||||
|
docker compose -f docker-compose.staging.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always test on dev branch first** before pushing to production
|
||||||
|
2. **Monitor health checks** after deployment
|
||||||
|
3. **Keep old images** for quick rollback (last 3 versions)
|
||||||
|
4. **Use feature flags** for new features
|
||||||
|
5. **Document breaking changes** before deploying
|
||||||
|
6. **Run tests locally** before pushing
|
||||||
|
|
||||||
|
## Network Configuration
|
||||||
|
|
||||||
|
- **Production Network**: `portfolio_net` + `proxy` (external)
|
||||||
|
- **Dev Network**: `portfolio_staging_net`
|
||||||
|
- **Isolation**: Complete separation ensures no interference
|
||||||
|
|
||||||
|
## Database Management
|
||||||
|
|
||||||
|
### Production Database
|
||||||
|
- **Container**: `portfolio-postgres`
|
||||||
|
- **Port**: `5432` (internal only)
|
||||||
|
- **Database**: `portfolio_db`
|
||||||
|
- **User**: `portfolio_user`
|
||||||
|
|
||||||
|
### Dev Database
|
||||||
|
- **Container**: `portfolio-postgres-staging`
|
||||||
|
- **Port**: `5434` (external), `5432` (internal)
|
||||||
|
- **Database**: `portfolio_staging_db`
|
||||||
|
- **User**: `portfolio_user`
|
||||||
|
|
||||||
|
## Redis Configuration
|
||||||
|
|
||||||
|
### Production Redis
|
||||||
|
- **Container**: `portfolio-redis`
|
||||||
|
- **Port**: `6379` (internal only)
|
||||||
|
|
||||||
|
### Dev Redis
|
||||||
|
- **Container**: `portfolio-redis-staging`
|
||||||
|
- **Port**: `6381` (external), `6379` (internal)
|
||||||
236
DEV_TESTING.md
236
DEV_TESTING.md
@@ -1,236 +0,0 @@
|
|||||||
# 🧪 Dev Branch Testing Guide
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Dieses Dokument erklärt, wie du dein Portfolio-Projekt auf dem `dev` Branch testen kannst, bevor du es in Production deployst.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
|
|
||||||
1. ✅ n8n läuft bereits auf `n8n.dk0.dev`
|
|
||||||
2. ✅ Gitea Repository ist eingerichtet
|
|
||||||
3. ✅ Docker und Docker Compose sind installiert
|
|
||||||
|
|
||||||
## Setup für lokales Testen mit n8n
|
|
||||||
|
|
||||||
### 1. Environment Variables konfigurieren
|
|
||||||
|
|
||||||
Erstelle eine `.env.local` Datei (oder aktualisiere deine bestehende `.env`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# n8n Integration
|
|
||||||
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
|
||||||
N8N_API_KEY=dein-n8n-api-key
|
|
||||||
N8N_SECRET_TOKEN=dein-n8n-secret-token
|
|
||||||
|
|
||||||
# Application
|
|
||||||
NODE_ENV=development
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Database (wird automatisch von docker-compose.dev.minimal.yml gesetzt)
|
|
||||||
# DATABASE_URL=postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public
|
|
||||||
|
|
||||||
# Redis (wird automatisch von docker-compose.dev.minimal.yml gesetzt)
|
|
||||||
# REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Email Configuration
|
|
||||||
MY_EMAIL=contact@dk0.dev
|
|
||||||
MY_INFO_EMAIL=info@dk0.dev
|
|
||||||
MY_PASSWORD=dein-email-passwort
|
|
||||||
MY_INFO_PASSWORD=dein-info-email-passwort
|
|
||||||
|
|
||||||
# Analytics
|
|
||||||
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
|
|
||||||
|
|
||||||
# Security
|
|
||||||
ADMIN_BASIC_AUTH=admin:dein-sicheres-passwort
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Lokal testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Starte Datenbank und Redis
|
|
||||||
npm run dev:minimal
|
|
||||||
|
|
||||||
# 2. In einem neuen Terminal: Starte die Next.js App
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# 3. Öffne http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. n8n Webhook testen
|
|
||||||
|
|
||||||
Teste die Verbindung zu deinem n8n Server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Teste den Status Endpoint
|
|
||||||
curl https://n8n.dk0.dev/webhook/denshooter-71242/status
|
|
||||||
|
|
||||||
# Teste den Chat Endpoint (wenn konfiguriert)
|
|
||||||
curl -X POST https://n8n.dk0.dev/webhook/chat \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer DEIN_N8N_SECRET_TOKEN" \
|
|
||||||
-d '{"message": "Hallo", "history": []}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Staging Deployment auf dem Server
|
|
||||||
|
|
||||||
### 1. Gitea Variables und Secrets konfigurieren
|
|
||||||
|
|
||||||
Gehe zu deinem Gitea Repository → Settings → Secrets/Variables und füge hinzu:
|
|
||||||
|
|
||||||
**Variables:**
|
|
||||||
- `NEXT_PUBLIC_BASE_URL` = `https://staging.dk0.dev` (oder deine Staging URL)
|
|
||||||
- `MY_EMAIL` = `contact@dk0.dev`
|
|
||||||
- `MY_INFO_EMAIL` = `info@dk0.dev`
|
|
||||||
- `NEXT_PUBLIC_UMAMI_URL` = `https://analytics.dk0.dev`
|
|
||||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` = `b3665829-927a-4ada-b9bb-fcf24171061e`
|
|
||||||
- `N8N_WEBHOOK_URL` = `https://n8n.dk0.dev`
|
|
||||||
- `LOG_LEVEL` = `debug`
|
|
||||||
|
|
||||||
**Secrets:**
|
|
||||||
- `MY_PASSWORD` = Dein Email Passwort
|
|
||||||
- `MY_INFO_PASSWORD` = Dein Info Email Passwort
|
|
||||||
- `ADMIN_BASIC_AUTH` = `admin:dein-sicheres-passwort`
|
|
||||||
- `N8N_API_KEY` = Dein n8n API Key (optional)
|
|
||||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
|
||||||
|
|
||||||
### 2. Push zum dev Branch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stelle sicher, dass du auf dem dev Branch bist
|
|
||||||
git checkout dev
|
|
||||||
|
|
||||||
# Committe deine Änderungen
|
|
||||||
git add .
|
|
||||||
git commit -m "Test: Dev deployment"
|
|
||||||
|
|
||||||
# Push zum dev Branch (triggert automatisch Staging Deployment)
|
|
||||||
git push origin dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Deployment überwachen
|
|
||||||
|
|
||||||
Nach dem Push:
|
|
||||||
1. Gehe zu deinem Gitea Repository → Actions
|
|
||||||
2. Überwache den Workflow `CI/CD Pipeline (Dev/Staging)`
|
|
||||||
3. Der Workflow wird:
|
|
||||||
- Tests ausführen
|
|
||||||
- Docker Image bauen
|
|
||||||
- Staging Container auf Port 3001 deployen
|
|
||||||
|
|
||||||
### 4. Staging testen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auf deinem Server: Prüfe Container Status
|
|
||||||
docker ps | grep staging
|
|
||||||
|
|
||||||
# Prüfe Health Endpoint
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# Prüfe n8n Status Endpoint
|
|
||||||
curl http://localhost:3001/api/n8n/status
|
|
||||||
|
|
||||||
# Logs ansehen
|
|
||||||
docker logs portfolio-app-staging -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Staging URL konfigurieren
|
|
||||||
|
|
||||||
Falls du eine Subdomain für Staging hast (z.B. `staging.dk0.dev`):
|
|
||||||
- Konfiguriere deinen Reverse Proxy (Nginx/Traefik) um auf Port 3001 zu zeigen
|
|
||||||
- Oder verwende direkt `http://dein-server-ip:3001`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Dev Container wird nicht erstellt
|
|
||||||
|
|
||||||
1. **Prüfe Gitea Workflow:**
|
|
||||||
- Gehe zu Repository → Actions
|
|
||||||
- Prüfe ob der Workflow `ci-cd-dev-staging.yml` existiert
|
|
||||||
- Prüfe ob der Workflow auf `dev` Branch Push reagiert
|
|
||||||
|
|
||||||
2. **Prüfe Gitea Variables:**
|
|
||||||
- Stelle sicher, dass alle erforderlichen Variables und Secrets gesetzt sind
|
|
||||||
- Prüfe die Workflow Logs für fehlende Variablen
|
|
||||||
|
|
||||||
3. **Prüfe Docker:**
|
|
||||||
```bash
|
|
||||||
# Auf deinem Server
|
|
||||||
docker ps -a
|
|
||||||
docker images | grep portfolio-app
|
|
||||||
```
|
|
||||||
|
|
||||||
### n8n Verbindungsfehler
|
|
||||||
|
|
||||||
1. **Prüfe n8n URL:**
|
|
||||||
```bash
|
|
||||||
# Teste ob n8n erreichbar ist
|
|
||||||
curl https://n8n.dk0.dev/webhook/denshooter-71242/status
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Prüfe Environment Variables:**
|
|
||||||
```bash
|
|
||||||
# Im Container
|
|
||||||
docker exec portfolio-app-staging env | grep N8N
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Prüfe n8n Webhook Konfiguration:**
|
|
||||||
- Stelle sicher, dass der Webhook in n8n aktiviert ist
|
|
||||||
- Prüfe ob der Webhook-Pfad korrekt ist (`/webhook/denshooter-71242/status`)
|
|
||||||
|
|
||||||
### Datenbank Fehler
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe ob die Datenbank läuft
|
|
||||||
docker ps | grep postgres-staging
|
|
||||||
|
|
||||||
# Prüfe Datenbank Logs
|
|
||||||
docker logs portfolio-postgres-staging
|
|
||||||
|
|
||||||
# Prüfe Verbindung
|
|
||||||
docker exec portfolio-postgres-staging pg_isready -U portfolio_user -d portfolio_staging_db
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Übersicht
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Push to dev │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Run Tests │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Build Docker │
|
|
||||||
│ Image (staging) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Deploy Staging │
|
|
||||||
│ (Port 3001) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Health Check │
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nächste Schritte
|
|
||||||
|
|
||||||
1. ✅ Teste lokal mit `npm run dev`
|
|
||||||
2. ✅ Konfiguriere Gitea Variables und Secrets
|
|
||||||
3. ✅ Push zum `dev` Branch
|
|
||||||
4. ✅ Teste Staging auf Port 3001
|
|
||||||
5. ✅ Wenn alles funktioniert: Merge zu `production` Branch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Tipp:** Verwende `LOG_LEVEL=debug` in Staging um mehr Informationen zu sehen!
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# Docker Build Fix - Standalone Output Issue
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Der Docker Build schlägt fehl mit:
|
|
||||||
```
|
|
||||||
ERROR: failed to calculate checksum of ref ... "/app/.next/standalone/app": not found
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ursache
|
|
||||||
|
|
||||||
Next.js erstellt das `standalone` Output nur, wenn:
|
|
||||||
1. `output: "standalone"` in `next.config.ts` gesetzt ist ✅ (bereits konfiguriert)
|
|
||||||
2. Der Build erfolgreich abgeschlossen wird
|
|
||||||
3. Alle Abhängigkeiten korrekt aufgelöst werden
|
|
||||||
|
|
||||||
## Lösung
|
|
||||||
|
|
||||||
### 1. n8n Status Route Fix
|
|
||||||
|
|
||||||
Die Route wurde angepasst, um während des Builds nicht zu fehlschlagen, wenn `N8N_WEBHOOK_URL` nicht gesetzt ist:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Prüft jetzt, ob N8N_WEBHOOK_URL gesetzt ist
|
|
||||||
if (!n8nWebhookUrl) {
|
|
||||||
return NextResponse.json({ /* fallback */ });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Dockerfile Verbesserungen
|
|
||||||
|
|
||||||
- **Verification Step**: Prüft, ob das standalone Verzeichnis existiert
|
|
||||||
- **Debug Output**: Zeigt die Verzeichnisstruktur, falls Probleme auftreten
|
|
||||||
- **Robustere Fehlerbehandlung**: Bessere Fehlermeldungen
|
|
||||||
|
|
||||||
### 3. Mögliche Ursachen und Lösungen
|
|
||||||
|
|
||||||
#### Problem: Standalone Output wird nicht erstellt
|
|
||||||
|
|
||||||
**Lösung 1: Prüfe next.config.ts**
|
|
||||||
```typescript
|
|
||||||
// Stelle sicher, dass dies gesetzt ist:
|
|
||||||
output: "standalone",
|
|
||||||
outputFileTracingRoot: path.join(process.cwd()),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lösung 2: Prüfe Build-Logs**
|
|
||||||
```bash
|
|
||||||
# Schaue in die Build-Logs, ob es Fehler gibt
|
|
||||||
docker build . 2>&1 | grep -i "standalone\|error"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lösung 3: Lokaler Test**
|
|
||||||
```bash
|
|
||||||
# Teste lokal, ob standalone erstellt wird
|
|
||||||
npm run build
|
|
||||||
ls -la .next/standalone/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Problem: Falsche Verzeichnisstruktur
|
|
||||||
|
|
||||||
**✅ GELÖST**: Die Debug-Ausgabe zeigt, dass Next.js 15 die Struktur `.next/standalone/` direkt verwendet:
|
|
||||||
- `.next/standalone/server.js` ✅
|
|
||||||
- `.next/standalone/.next/` ✅
|
|
||||||
- `.next/standalone/node_modules/` ✅
|
|
||||||
- `.next/standalone/package.json` ✅
|
|
||||||
|
|
||||||
**NICHT**: `.next/standalone/app/server.js` ❌
|
|
||||||
|
|
||||||
Das Dockerfile wurde korrigiert, um `.next/standalone/` direkt zu kopieren.
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### 1. Lokaler Build Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Baue lokal
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Prüfe ob standalone existiert
|
|
||||||
test -d .next/standalone && echo "✅ Standalone exists" || echo "❌ Standalone missing"
|
|
||||||
|
|
||||||
# Zeige Struktur
|
|
||||||
ls -la .next/standalone/
|
|
||||||
find .next/standalone -name "server.js"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Docker Build mit Debug
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Baue mit mehr Output
|
|
||||||
docker build --progress=plain -t portfolio-app:test .
|
|
||||||
|
|
||||||
# Oder baue nur bis zum Builder Stage
|
|
||||||
docker build --target builder -t portfolio-builder:test .
|
|
||||||
docker run --rm portfolio-builder:test ls -la .next/standalone/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Prüfe Build-Logs
|
|
||||||
|
|
||||||
Der aktualisierte Dockerfile gibt jetzt Debug-Informationen aus:
|
|
||||||
- Zeigt `.next/` Verzeichnisstruktur
|
|
||||||
- Sucht nach `standalone` Verzeichnis
|
|
||||||
- Zeigt `server.js` Location
|
|
||||||
|
|
||||||
## Alternative: Fallback ohne Standalone
|
|
||||||
|
|
||||||
Falls das standalone Output weiterhin Probleme macht, kann man auf ein vollständiges Image zurückgreifen:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Statt standalone zu kopieren, kopiere alles
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nachteil**: Größeres Image, aber funktioniert immer.
|
|
||||||
|
|
||||||
## Nächste Schritte
|
|
||||||
|
|
||||||
1. ✅ n8n Status Route Fix (bereits gemacht)
|
|
||||||
2. ✅ Dockerfile Debug-Verbesserungen (bereits gemacht)
|
|
||||||
3. 🔄 Push zum dev Branch und Build testen
|
|
||||||
4. 📊 Build-Logs analysieren
|
|
||||||
5. 🔧 Falls nötig: Dockerfile weiter anpassen
|
|
||||||
|
|
||||||
## Workflow Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Committe Änderungen
|
|
||||||
git add .
|
|
||||||
git commit -m "Fix: Docker build standalone output issue"
|
|
||||||
|
|
||||||
# 2. Push zum dev Branch
|
|
||||||
git push origin dev
|
|
||||||
|
|
||||||
# 3. Überwache Gitea Actions
|
|
||||||
# Gehe zu Repository → Actions → CI/CD Pipeline (Dev/Staging)
|
|
||||||
|
|
||||||
# 4. Prüfe Build-Logs
|
|
||||||
# Schaue nach den Debug-Ausgaben im Build-Step
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Hinweis**: Falls das Problem weiterhin besteht, schaue in die Build-Logs nach den Debug-Ausgaben, die der aktualisierte Dockerfile jetzt ausgibt. Diese zeigen genau, wo das Problem liegt.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 🔧 Git Connection Fix
|
|
||||||
|
|
||||||
## Issue
|
|
||||||
```
|
|
||||||
fatal: unable to access 'https://git.dk0.dev/denshooter/portfolio/':
|
|
||||||
Failed to connect to git.dk0.dev port 443 after 75002 ms: Couldn't connect to server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Solutions
|
|
||||||
|
|
||||||
### Option 1: Check Server Status
|
|
||||||
The server is reachable via HTTP (tested), but Git might need authentication.
|
|
||||||
|
|
||||||
### Option 2: Configure Git Credentials
|
|
||||||
```bash
|
|
||||||
# Store credentials
|
|
||||||
git config --global credential.helper store
|
|
||||||
|
|
||||||
# Or use keychain (macOS)
|
|
||||||
git config --global credential.helper osxkeychain
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: Use Personal Access Token
|
|
||||||
1. Go to: https://git.dk0.dev/user/settings/applications
|
|
||||||
2. Generate a new token
|
|
||||||
3. Use it when pushing:
|
|
||||||
```bash
|
|
||||||
git push https://YOUR_TOKEN@git.dk0.dev/denshooter/portfolio.git
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 4: Check Firewall/Network
|
|
||||||
- Port 443 might be blocked
|
|
||||||
- Try from different network
|
|
||||||
- Check if VPN is needed
|
|
||||||
|
|
||||||
### Option 5: Use SSH (if port 22 opens)
|
|
||||||
```bash
|
|
||||||
git remote set-url origin git@git.dk0.dev:denshooter/portfolio.git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
- Remote URL: `https://git.dk0.dev/denshooter/portfolio.git`
|
|
||||||
- Server reachable: ✅ (HTTP works)
|
|
||||||
- Git connection: ⚠️ (May need credentials)
|
|
||||||
|
|
||||||
## Quick Test
|
|
||||||
```bash
|
|
||||||
# Test connection
|
|
||||||
curl -I https://git.dk0.dev
|
|
||||||
|
|
||||||
# Test Git
|
|
||||||
git ls-remote https://git.dk0.dev/denshooter/portfolio.git
|
|
||||||
```
|
|
||||||
@@ -251,19 +251,13 @@ function EditorPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Markdown components for react-markdown with security
|
// Markdown components for react-markdown with security
|
||||||
const markdownComponents = {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
a: ({
|
const markdownComponents: any = {
|
||||||
node: _node,
|
a: ({ node: _node, ...props }: { node?: unknown; href?: string; children?: React.ReactNode }) => {
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
node?: unknown;
|
|
||||||
href?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
// Validate URLs to prevent javascript: and data: protocols
|
// Validate URLs to prevent javascript: and data: protocols
|
||||||
const href = props.href || "";
|
const href = props.href || "";
|
||||||
const isSafe =
|
const isSafe =
|
||||||
href && !href.startsWith("javascript:") && !href.startsWith("data:");
|
href && typeof href === 'string' && !href.startsWith("javascript:") && !href.startsWith("data:");
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...props}
|
{...props}
|
||||||
@@ -277,18 +271,11 @@ function EditorPageContent() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
img: ({
|
img: ({ node: _node, ...props }: { node?: unknown; src?: string; alt?: string }) => {
|
||||||
node: _node,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
node?: unknown;
|
|
||||||
src?: string;
|
|
||||||
alt?: string;
|
|
||||||
}) => {
|
|
||||||
// Validate image URLs
|
// Validate image URLs
|
||||||
const src = props.src || "";
|
const src = props.src;
|
||||||
const isSafe =
|
const isSafe =
|
||||||
src && !src.startsWith("javascript:") && !src.startsWith("data:");
|
src && typeof src === 'string' && !src.startsWith("javascript:") && !src.startsWith("data:");
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
return isSafe ? <img {...props} src={src} alt={props.alt || ""} /> : null;
|
return isSafe ? <img {...props} src={src} alt={props.alt || ""} /> : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,733 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Save,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
Settings,
|
|
||||||
Globe,
|
|
||||||
Github,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
List,
|
|
||||||
Quote,
|
|
||||||
Code,
|
|
||||||
Link2,
|
|
||||||
ListOrdered,
|
|
||||||
Underline,
|
|
||||||
Strikethrough,
|
|
||||||
Type,
|
|
||||||
Columns
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content?: string;
|
|
||||||
category: string;
|
|
||||||
difficulty?: string;
|
|
||||||
tags?: string[];
|
|
||||||
featured: boolean;
|
|
||||||
published: boolean;
|
|
||||||
github?: string;
|
|
||||||
live?: string;
|
|
||||||
image?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GhostEditorProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
project?: Project | null;
|
|
||||||
onSave: (projectData: Partial<Project>) => void;
|
|
||||||
isCreating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GhostEditor: React.FC<GhostEditorProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
project,
|
|
||||||
onSave,
|
|
||||||
isCreating
|
|
||||||
}) => {
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [category, setCategory] = useState('Web Development');
|
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
|
||||||
const [github, setGithub] = useState('');
|
|
||||||
const [live, setLive] = useState('');
|
|
||||||
const [featured, setFeatured] = useState(false);
|
|
||||||
const [published, setPublished] = useState(false);
|
|
||||||
const [difficulty, setDifficulty] = useState('Intermediate');
|
|
||||||
|
|
||||||
// Editor UI state
|
|
||||||
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split');
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [wordCount, setWordCount] = useState(0);
|
|
||||||
const [readingTime, setReadingTime] = useState(0);
|
|
||||||
|
|
||||||
const titleRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
|
|
||||||
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (project && !isCreating) {
|
|
||||||
setTitle(project.title);
|
|
||||||
setDescription(project.description);
|
|
||||||
setContent(project.content || '');
|
|
||||||
setCategory(project.category);
|
|
||||||
setTags(project.tags || []);
|
|
||||||
setGithub(project.github || '');
|
|
||||||
setLive(project.live || '');
|
|
||||||
setFeatured(project.featured);
|
|
||||||
setPublished(project.published);
|
|
||||||
setDifficulty(project.difficulty || 'Intermediate');
|
|
||||||
} else {
|
|
||||||
// Reset for new project
|
|
||||||
setTitle('');
|
|
||||||
setDescription('');
|
|
||||||
setContent('');
|
|
||||||
setCategory('Web Development');
|
|
||||||
setTags([]);
|
|
||||||
setGithub('');
|
|
||||||
setLive('');
|
|
||||||
setFeatured(false);
|
|
||||||
setPublished(false);
|
|
||||||
setDifficulty('Intermediate');
|
|
||||||
}
|
|
||||||
}, [project, isCreating, isOpen]);
|
|
||||||
|
|
||||||
// Calculate word count and reading time
|
|
||||||
useEffect(() => {
|
|
||||||
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
|
|
||||||
setWordCount(words);
|
|
||||||
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const projectData = {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
content,
|
|
||||||
category,
|
|
||||||
tags,
|
|
||||||
github,
|
|
||||||
live,
|
|
||||||
featured,
|
|
||||||
published,
|
|
||||||
difficulty
|
|
||||||
};
|
|
||||||
onSave(projectData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTag = (tag: string) => {
|
|
||||||
if (tag.trim() && !tags.includes(tag.trim())) {
|
|
||||||
setTags([...tags, tag.trim()]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
|
||||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
|
|
||||||
if (!contentRef.current) return;
|
|
||||||
|
|
||||||
const textarea = contentRef.current;
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const selection = selectedText || content.substring(start, end);
|
|
||||||
|
|
||||||
let newText = '';
|
|
||||||
let cursorOffset = 0;
|
|
||||||
|
|
||||||
switch (syntax) {
|
|
||||||
case 'bold':
|
|
||||||
newText = `**${selection || 'bold text'}**`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'italic':
|
|
||||||
newText = `*${selection || 'italic text'}*`;
|
|
||||||
cursorOffset = selection ? newText.length : 1;
|
|
||||||
break;
|
|
||||||
case 'underline':
|
|
||||||
newText = `<u>${selection || 'underlined text'}</u>`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'strikethrough':
|
|
||||||
newText = `~~${selection || 'strikethrough text'}~~`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'heading1':
|
|
||||||
newText = `# ${selection || 'Heading 1'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'heading2':
|
|
||||||
newText = `## ${selection || 'Heading 2'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'heading3':
|
|
||||||
newText = `### ${selection || 'Heading 3'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 4;
|
|
||||||
break;
|
|
||||||
case 'list':
|
|
||||||
newText = `- ${selection || 'List item'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'list-ordered':
|
|
||||||
newText = `1. ${selection || 'List item'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'quote':
|
|
||||||
newText = `> ${selection || 'Quote'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'code':
|
|
||||||
if (selection.includes('\n')) {
|
|
||||||
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
|
|
||||||
cursorOffset = selection ? newText.length : 4;
|
|
||||||
} else {
|
|
||||||
newText = `\`${selection || 'code'}\``;
|
|
||||||
cursorOffset = selection ? newText.length : 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'link':
|
|
||||||
newText = `[${selection || 'link text'}](url)`;
|
|
||||||
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
|
|
||||||
break;
|
|
||||||
case 'image':
|
|
||||||
newText = ``;
|
|
||||||
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,750 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Save,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Settings,
|
|
||||||
Globe,
|
|
||||||
Github,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
List,
|
|
||||||
Quote,
|
|
||||||
Code,
|
|
||||||
Link2,
|
|
||||||
ListOrdered,
|
|
||||||
Underline,
|
|
||||||
Strikethrough,
|
|
||||||
GripVertical,
|
|
||||||
Image as ImageIcon
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content?: string;
|
|
||||||
category: string;
|
|
||||||
difficulty?: string;
|
|
||||||
tags?: string[];
|
|
||||||
featured: boolean;
|
|
||||||
published: boolean;
|
|
||||||
github?: string;
|
|
||||||
live?: string;
|
|
||||||
image?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResizableGhostEditorProps {
|
|
||||||
project?: Project | null;
|
|
||||||
onSave: (projectData: Partial<Project>) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
isCreating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResizableGhostEditor: React.FC<ResizableGhostEditorProps> = ({
|
|
||||||
project,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
isCreating
|
|
||||||
}) => {
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
const [category, setCategory] = useState('Web Development');
|
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
|
||||||
const [github, setGithub] = useState('');
|
|
||||||
const [live, setLive] = useState('');
|
|
||||||
const [featured, setFeatured] = useState(false);
|
|
||||||
const [published, setPublished] = useState(false);
|
|
||||||
const [difficulty, setDifficulty] = useState('Intermediate');
|
|
||||||
|
|
||||||
// Editor UI state
|
|
||||||
const [showPreview, setShowPreview] = useState(true);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [previewWidth, setPreviewWidth] = useState(50); // Percentage
|
|
||||||
const [wordCount, setWordCount] = useState(0);
|
|
||||||
const [readingTime, setReadingTime] = useState(0);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
|
|
||||||
const titleRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
|
||||||
const resizeRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const categories = ['Web Development', 'Full-Stack', 'Web Application', 'Mobile App', 'Design'];
|
|
||||||
const difficulties = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (project && !isCreating) {
|
|
||||||
setTitle(project.title);
|
|
||||||
setDescription(project.description);
|
|
||||||
setContent(project.content || '');
|
|
||||||
setCategory(project.category);
|
|
||||||
setTags(project.tags || []);
|
|
||||||
setGithub(project.github || '');
|
|
||||||
setLive(project.live || '');
|
|
||||||
setFeatured(project.featured);
|
|
||||||
setPublished(project.published);
|
|
||||||
setDifficulty(project.difficulty || 'Intermediate');
|
|
||||||
} else {
|
|
||||||
// Reset for new project
|
|
||||||
setTitle('');
|
|
||||||
setDescription('');
|
|
||||||
setContent('');
|
|
||||||
setCategory('Web Development');
|
|
||||||
setTags([]);
|
|
||||||
setGithub('');
|
|
||||||
setLive('');
|
|
||||||
setFeatured(false);
|
|
||||||
setPublished(false);
|
|
||||||
setDifficulty('Intermediate');
|
|
||||||
}
|
|
||||||
}, [project, isCreating]);
|
|
||||||
|
|
||||||
// Calculate word count and reading time
|
|
||||||
useEffect(() => {
|
|
||||||
const words = content.trim().split(/\s+/).filter(word => word.length > 0).length;
|
|
||||||
setWordCount(words);
|
|
||||||
setReadingTime(Math.ceil(words / 200)); // Average reading speed: 200 words/minute
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
// Handle resizing
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isResizing) return;
|
|
||||||
|
|
||||||
const containerWidth = window.innerWidth - (showSettings ? 320 : 0); // Account for settings sidebar
|
|
||||||
const newWidth = Math.max(20, Math.min(80, (e.clientX / containerWidth) * 100));
|
|
||||||
setPreviewWidth(100 - newWidth); // Invert since we're setting editor width
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsResizing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isResizing) {
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [isResizing, showSettings]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const projectData = {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
content,
|
|
||||||
category,
|
|
||||||
tags,
|
|
||||||
github,
|
|
||||||
live,
|
|
||||||
featured,
|
|
||||||
published,
|
|
||||||
difficulty
|
|
||||||
};
|
|
||||||
onSave(projectData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTag = (tag: string) => {
|
|
||||||
if (tag.trim() && !tags.includes(tag.trim())) {
|
|
||||||
setTags([...tags, tag.trim()]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
|
||||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertMarkdown = useCallback((syntax: string, selectedText: string = '') => {
|
|
||||||
if (!contentRef.current) return;
|
|
||||||
|
|
||||||
const textarea = contentRef.current;
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const selection = selectedText || content.substring(start, end);
|
|
||||||
|
|
||||||
let newText = '';
|
|
||||||
let cursorOffset = 0;
|
|
||||||
|
|
||||||
switch (syntax) {
|
|
||||||
case 'bold':
|
|
||||||
newText = `**${selection || 'bold text'}**`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'italic':
|
|
||||||
newText = `*${selection || 'italic text'}*`;
|
|
||||||
cursorOffset = selection ? newText.length : 1;
|
|
||||||
break;
|
|
||||||
case 'underline':
|
|
||||||
newText = `<u>${selection || 'underlined text'}</u>`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'strikethrough':
|
|
||||||
newText = `~~${selection || 'strikethrough text'}~~`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'heading1':
|
|
||||||
newText = `# ${selection || 'Heading 1'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'heading2':
|
|
||||||
newText = `## ${selection || 'Heading 2'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'heading3':
|
|
||||||
newText = `### ${selection || 'Heading 3'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 4;
|
|
||||||
break;
|
|
||||||
case 'list':
|
|
||||||
newText = `- ${selection || 'List item'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'list-ordered':
|
|
||||||
newText = `1. ${selection || 'List item'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 3;
|
|
||||||
break;
|
|
||||||
case 'quote':
|
|
||||||
newText = `> ${selection || 'Quote'}`;
|
|
||||||
cursorOffset = selection ? newText.length : 2;
|
|
||||||
break;
|
|
||||||
case 'code':
|
|
||||||
if (selection.includes('\n')) {
|
|
||||||
newText = `\`\`\`\n${selection || 'code block'}\n\`\`\``;
|
|
||||||
cursorOffset = selection ? newText.length : 4;
|
|
||||||
} else {
|
|
||||||
newText = `\`${selection || 'code'}\``;
|
|
||||||
cursorOffset = selection ? newText.length : 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'link':
|
|
||||||
newText = `[${selection || 'link text'}](url)`;
|
|
||||||
cursorOffset = selection ? newText.length - 4 : newText.length - 4;
|
|
||||||
break;
|
|
||||||
case 'image':
|
|
||||||
newText = ``;
|
|
||||||
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,7 +1,5 @@
|
|||||||
# Production Docker Compose configuration for dk0.dev
|
# Production Docker Compose configuration for dk0.dev
|
||||||
# Optimized for production deployment
|
# Optimized for production deployment with zero-downtime support
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
portfolio:
|
portfolio:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- NODE_ENV=staging
|
- NODE_ENV=staging
|
||||||
- DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public
|
- DATABASE_URL=postgresql://portfolio_user:portfolio_staging_pass@postgres-staging:5432/portfolio_staging_db?schema=public
|
||||||
- REDIS_URL=redis://redis-staging:6379
|
- REDIS_URL=redis://redis-staging:6379
|
||||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://staging.dk0.dev}
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://dev.dk0.dev}
|
||||||
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
||||||
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
||||||
- MY_PASSWORD=${MY_PASSWORD}
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
|
|||||||
2085
package-lock.json
generated
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": "./scripts/pre-push.sh",
|
||||||
"pre-push:full": "./scripts/pre-push-full.sh",
|
"pre-push:full": "./scripts/pre-push-full.sh",
|
||||||
"pre-push:quick": "./scripts/pre-push-quick.sh",
|
"pre-push:quick": "./scripts/pre-push-quick.sh",
|
||||||
"test:all": "./scripts/test-all.sh",
|
"test:all": "npm run test && npm run test:e2e",
|
||||||
"buildAnalyze": "cross-env ANALYZE=true next build",
|
"buildAnalyze": "cross-env ANALYZE=true next build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
|
"test:production": "NODE_ENV=production jest --config jest.config.production.ts",
|
||||||
@@ -25,7 +25,6 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:debug": "playwright test --debug",
|
||||||
"test:all": "npm run test && npm run test:e2e",
|
|
||||||
"test:critical": "playwright test e2e/critical-paths.spec.ts",
|
"test:critical": "playwright test e2e/critical-paths.spec.ts",
|
||||||
"test:hydration": "playwright test e2e/hydration.spec.ts",
|
"test:hydration": "playwright test e2e/hydration.spec.ts",
|
||||||
"test:email": "playwright test e2e/email.spec.ts",
|
"test:email": "playwright test e2e/email.spec.ts",
|
||||||
@@ -37,8 +36,8 @@
|
|||||||
"db:reset": "prisma db push --force-reset",
|
"db:reset": "prisma db push --force-reset",
|
||||||
"docker:build": "docker build -t portfolio-app .",
|
"docker:build": "docker build -t portfolio-app .",
|
||||||
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
||||||
"docker:compose": "docker compose -f docker-compose.prod.yml up -d",
|
"docker:compose": "docker compose -f docker-compose.production.yml up -d",
|
||||||
"docker:down": "docker compose -f docker-compose.prod.yml down",
|
"docker:down": "docker compose -f docker-compose.production.yml down",
|
||||||
"docker:dev": "docker compose -f docker-compose.dev.minimal.yml up -d",
|
"docker:dev": "docker compose -f docker-compose.dev.minimal.yml up -d",
|
||||||
"docker:dev:down": "docker compose -f docker-compose.dev.minimal.yml down",
|
"docker:dev:down": "docker compose -f docker-compose.dev.minimal.yml down",
|
||||||
"deploy": "./scripts/deploy.sh",
|
"deploy": "./scripts/deploy.sh",
|
||||||
@@ -56,8 +55,8 @@
|
|||||||
"@next/bundle-analyzer": "^15.1.7",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.6.1",
|
||||||
"framer-motion": "^12.24.10",
|
"framer-motion": "^12.24.10",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
@@ -65,13 +64,13 @@
|
|||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"react": "^19.0.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"tailwind-merge": "^2.2.1"
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ set -e
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
CONTAINER_NAME="portfolio-app"
|
CONTAINER_NAME="portfolio-app"
|
||||||
COMPOSE_FILE="docker-compose.prod.yml"
|
COMPOSE_FILE="docker-compose.production.yml"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
|
|||||||
Reference in New Issue
Block a user