🔀 Merge dev into production
- Resolve Dockerfile merge conflict - Keep both type definitions and Prisma client generation - Complete automatic deployment system integration
This commit is contained in:
8
.eslintrc.build.json
Normal file
8
.eslintrc.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@next/next/no-img-element": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
.github/workflows/build.yml
vendored
40
.github/workflows/build.yml
vendored
@@ -1,40 +0,0 @@
|
|||||||
name: Build and Push Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Test Code Base"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
|
||||||
|
|
||||||
- name: Create Deployment .env File
|
|
||||||
run: |
|
|
||||||
cat > .env <<EOF
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
|
||||||
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
|
||||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
|
||||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
echo "Created .env file:" && cat .env
|
|
||||||
|
|
||||||
- name: Build & Push Docker Image
|
|
||||||
run: |
|
|
||||||
# Nutzt den Branch-Namen aus dem auslösenden Workflow
|
|
||||||
IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.event.workflow_run.head_branch }}"
|
|
||||||
docker buildx create --use
|
|
||||||
docker buildx build --platform linux/arm64 -t "$IMAGE_NAME" --push .
|
|
||||||
185
.github/workflows/ci-cd.yml
vendored
Normal file
185
.github/workflows/ci-cd.yml
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, production]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, production]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Test Job
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Create test environment file
|
||||||
|
run: |
|
||||||
|
cat > .env <<EOF
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
GHOST_API_URL=test
|
||||||
|
GHOST_API_KEY=test
|
||||||
|
MY_EMAIL=test@example.com
|
||||||
|
MY_INFO_EMAIL=test@example.com
|
||||||
|
MY_PASSWORD=test
|
||||||
|
MY_INFO_PASSWORD=test
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Security scan
|
||||||
|
security:
|
||||||
|
name: Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
scan-type: 'fs'
|
||||||
|
scan-ref: '.'
|
||||||
|
format: 'sarif'
|
||||||
|
output: 'trivy-results.sarif'
|
||||||
|
|
||||||
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
|
uses: github/codeql-action/upload-sarif@v2
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|
||||||
|
# Build and push Docker image
|
||||||
|
build:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test, security]
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production')
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Create production environment file
|
||||||
|
run: |
|
||||||
|
cat > .env <<EOF
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
||||||
|
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
||||||
|
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
||||||
|
MY_EMAIL=${{ vars.MY_EMAIL }}
|
||||||
|
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
|
||||||
|
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
||||||
|
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Deploy to server
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Server
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
run: |
|
||||||
|
# Set deployment variables
|
||||||
|
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
|
||||||
|
export CONTAINER_NAME="portfolio-app"
|
||||||
|
export COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
docker pull $IMAGE_NAME
|
||||||
|
|
||||||
|
# Stop and remove old container
|
||||||
|
docker-compose -f $COMPOSE_FILE down || true
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
docker-compose -f $COMPOSE_FILE up -d
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
echo "Waiting for application to be healthy..."
|
||||||
|
timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
if curl -f http://localhost:3000/api/health; then
|
||||||
|
echo "✅ Deployment successful!"
|
||||||
|
else
|
||||||
|
echo "❌ Deployment failed!"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cleanup old images
|
||||||
|
run: |
|
||||||
|
# Remove unused images older than 7 days
|
||||||
|
docker image prune -f --filter "until=168h"
|
||||||
|
|
||||||
|
# Remove unused containers
|
||||||
|
docker container prune -f
|
||||||
58
.github/workflows/deploy.yml
vendored
58
.github/workflows/deploy.yml
vendored
@@ -1,58 +0,0 @@
|
|||||||
name: Deploy to Raspberry Pi
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Build and Push Docker Image"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set Deployment Variables
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.workflow_run.head_branch }}" == "production" ]]; then
|
|
||||||
echo "DEPLOY_ENV=production" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4000" >> $GITHUB_ENV
|
|
||||||
elif [[ "${{ github.event.workflow_run.head_branch }}" == "dev" ]]; then
|
|
||||||
echo "DEPLOY_ENV=dev" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4001" >> $GITHUB_ENV
|
|
||||||
elif [[ "${{ github.event.workflow_run.head_branch }}" == "preview" ]]; then
|
|
||||||
echo "DEPLOY_ENV=preview" >> $GITHUB_ENV
|
|
||||||
echo "PORT=4002" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
|
|
||||||
|
|
||||||
- name: Pull & Deploy Docker Image
|
|
||||||
run: |
|
|
||||||
IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/my-nextjs-app:${{ github.event.workflow_run.head_branch }}"
|
|
||||||
IMAGE_NAME=$(echo "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]')
|
|
||||||
docker pull "$IMAGE_NAME"
|
|
||||||
CONTAINER_NAME="nextjs-$DEPLOY_ENV"
|
|
||||||
|
|
||||||
echo "Deploying $CONTAINER_NAME"
|
|
||||||
|
|
||||||
if [ "$(docker inspect --format='{{.State.Running}}' "$CONTAINER_NAME")" = "true" ]; then
|
|
||||||
docker stop "$CONTAINER_NAME" || true
|
|
||||||
docker rm "$CONTAINER_NAME" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker run -d --name "$CONTAINER_NAME" -p $PORT:3000 "$IMAGE_NAME"
|
|
||||||
if [ "$(docker inspect --format='{{.State.Running}}' "$CONTAINER_NAME")" = "true" ]; then
|
|
||||||
echo "Deployment erfolgreich!"
|
|
||||||
else
|
|
||||||
echo "Neuer Container konnte nicht gestartet werden!"
|
|
||||||
docker logs "$CONTAINER_NAME"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
56
.github/workflows/lint.yml
vendored
56
.github/workflows/lint.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Lint Code Base
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.14.0
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run ESLint
|
|
||||||
run: npm run lint
|
|
||||||
67
.github/workflows/test.yml
vendored
67
.github/workflows/test.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: Test Code Base
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- production
|
|
||||||
- dev
|
|
||||||
- preview
|
|
||||||
paths:
|
|
||||||
- 'app/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'styles/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker-compose.yml'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'tailwind.config.ts'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.14.0
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Create .env File
|
|
||||||
run: |
|
|
||||||
cat > .env <<EOF
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
|
||||||
GHOST_API_URL=${{ vars.GHOST_API_URL }}
|
|
||||||
GHOST_API_KEY=${{ secrets.GHOST_API_KEY }}
|
|
||||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
|
||||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
echo ".env file created:" && cat .env
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
run: npm run test
|
|
||||||
177
ANALYTICS.md
Normal file
177
ANALYTICS.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Analytics & Performance Tracking System
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **GDPR-konformes Analytics-System** basierend auf **Umami** (self-hosted) mit erweitertem **Performance-Tracking**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### ✅ GDPR-Konform
|
||||||
|
- **Keine Cookie-Banner** erforderlich
|
||||||
|
- **Keine personenbezogenen Daten** werden gesammelt
|
||||||
|
- **Anonymisierte Performance-Metriken**
|
||||||
|
- **Self-hosted** - vollständige Datenkontrolle
|
||||||
|
|
||||||
|
### 📊 Analytics Features
|
||||||
|
- **Page Views** - Seitenaufrufe
|
||||||
|
- **User Interactions** - Klicks, Formulare, Scroll-Verhalten
|
||||||
|
- **Error Tracking** - JavaScript-Fehler und unhandled rejections
|
||||||
|
- **Route Changes** - SPA-Navigation
|
||||||
|
|
||||||
|
### ⚡ Performance Tracking
|
||||||
|
- **Core Web Vitals**: LCP, FID, CLS, FCP, TTFB
|
||||||
|
- **Page Load Times** - Detaillierte Timing-Phasen
|
||||||
|
- **API Response Times** - Backend-Performance
|
||||||
|
- **Custom Performance Markers** - Spezifische Metriken
|
||||||
|
|
||||||
|
## Technische Implementierung
|
||||||
|
|
||||||
|
### 1. Umami Integration
|
||||||
|
```typescript
|
||||||
|
// Bereits in layout.tsx konfiguriert
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://umami.denshooter.de/script.js"
|
||||||
|
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance Tracking
|
||||||
|
```typescript
|
||||||
|
// Web Vitals werden automatisch getrackt
|
||||||
|
import { useWebVitals } from '@/lib/useWebVitals';
|
||||||
|
|
||||||
|
// Custom Events tracken
|
||||||
|
import { trackEvent, trackPerformance } from '@/lib/analytics';
|
||||||
|
|
||||||
|
trackEvent('custom-action', { data: 'value' });
|
||||||
|
trackPerformance({ name: 'api-call', value: 150, url: '/api/data' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Analytics Provider
|
||||||
|
```typescript
|
||||||
|
// Automatisches Tracking von:
|
||||||
|
// - Page Views
|
||||||
|
// - User Interactions (Klicks, Scroll, Forms)
|
||||||
|
// - Performance Metrics
|
||||||
|
// - Error Tracking
|
||||||
|
<AnalyticsProvider>
|
||||||
|
{children}
|
||||||
|
</AnalyticsProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
### Performance Dashboard
|
||||||
|
- **Live Performance-Metriken** anzeigen
|
||||||
|
- **Core Web Vitals** mit Bewertungen (Good/Needs Improvement/Poor)
|
||||||
|
- **Toggle-Button** unten rechts auf der Website
|
||||||
|
- **Real-time Updates** der Performance-Daten
|
||||||
|
|
||||||
|
### Umami Dashboard
|
||||||
|
- **Standard Analytics** über deine Umami-Instanz
|
||||||
|
- **URL**: https://umami.denshooter.de
|
||||||
|
- **Website ID**: 1f213877-deef-4238-8df1-71a5a3bcd142
|
||||||
|
|
||||||
|
## Event-Typen
|
||||||
|
|
||||||
|
### Automatische Events
|
||||||
|
- `page-view` - Seitenaufrufe
|
||||||
|
- `click` - Benutzerklicks
|
||||||
|
- `form-submit` - Formular-Übermittlungen
|
||||||
|
- `scroll-depth` - Scroll-Tiefe (25%, 50%, 75%, 90%)
|
||||||
|
- `error` - JavaScript-Fehler
|
||||||
|
- `unhandled-rejection` - Unbehandelte Promise-Rejections
|
||||||
|
|
||||||
|
### Performance Events
|
||||||
|
- `web-vitals` - Core Web Vitals (LCP, FID, CLS, FCP, TTFB)
|
||||||
|
- `performance` - Custom Performance-Metriken
|
||||||
|
- `page-timing` - Detaillierte Page-Load-Phasen
|
||||||
|
- `api-call` - API-Response-Zeiten
|
||||||
|
|
||||||
|
### Custom Events
|
||||||
|
- `dashboard-toggle` - Performance Dashboard ein/aus
|
||||||
|
- `interaction` - Benutzerinteraktionen
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
### Was wird NICHT gesammelt:
|
||||||
|
- ❌ IP-Adressen
|
||||||
|
- ❌ User-IDs
|
||||||
|
- ❌ E-Mail-Adressen
|
||||||
|
- ❌ Personenbezogene Daten
|
||||||
|
- ❌ Cookies
|
||||||
|
|
||||||
|
### Was wird gesammelt:
|
||||||
|
- ✅ Anonymisierte Performance-Metriken
|
||||||
|
- ✅ Technische Browser-Informationen
|
||||||
|
- ✅ Seitenaufrufe (ohne persönliche Daten)
|
||||||
|
- ✅ Error-Logs (anonymisiert)
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umami Setup
|
||||||
|
1. **Self-hosted Umami** auf deinem Server
|
||||||
|
2. **Website ID** in `layout.tsx` konfiguriert
|
||||||
|
3. **Script-URL** auf deine Umami-Instanz
|
||||||
|
|
||||||
|
### Performance Tracking
|
||||||
|
- **Automatisch aktiviert** durch `AnalyticsProvider`
|
||||||
|
- **Web Vitals** werden automatisch gemessen
|
||||||
|
- **Custom Events** über `trackEvent()` Funktion
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Performance-Schwellenwerte
|
||||||
|
- **LCP**: ≤ 2.5s (Good), ≤ 4s (Needs Improvement), > 4s (Poor)
|
||||||
|
- **FID**: ≤ 100ms (Good), ≤ 300ms (Needs Improvement), > 300ms (Poor)
|
||||||
|
- **CLS**: ≤ 0.1 (Good), ≤ 0.25 (Needs Improvement), > 0.25 (Poor)
|
||||||
|
- **FCP**: ≤ 1.8s (Good), ≤ 3s (Needs Improvement), > 3s (Poor)
|
||||||
|
- **TTFB**: ≤ 800ms (Good), ≤ 1.8s (Needs Improvement), > 1.8s (Poor)
|
||||||
|
|
||||||
|
### Dashboard-Zugriff
|
||||||
|
- **Performance Dashboard**: Toggle-Button unten rechts
|
||||||
|
- **Umami Dashboard**: https://umami.denshooter.de
|
||||||
|
- **API Endpoint**: `/api/analytics` für Custom-Tracking
|
||||||
|
|
||||||
|
## Erweiterung
|
||||||
|
|
||||||
|
### Neue Events hinzufügen
|
||||||
|
```typescript
|
||||||
|
import { trackEvent } from '@/lib/analytics';
|
||||||
|
|
||||||
|
// Custom Event tracken
|
||||||
|
trackEvent('feature-usage', {
|
||||||
|
feature: 'contact-form',
|
||||||
|
success: true,
|
||||||
|
duration: 1500
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance-Metriken erweitern
|
||||||
|
```typescript
|
||||||
|
import { trackPerformance } from '@/lib/analytics';
|
||||||
|
|
||||||
|
// Custom Performance-Metrik
|
||||||
|
trackPerformance({
|
||||||
|
name: 'component-render',
|
||||||
|
value: renderTime,
|
||||||
|
url: window.location.pathname
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Performance Dashboard nicht sichtbar
|
||||||
|
- Prüfe Browser-Konsole auf Fehler
|
||||||
|
- Stelle sicher, dass `AnalyticsProvider` in `layout.tsx` eingebunden ist
|
||||||
|
|
||||||
|
### Umami Events nicht sichtbar
|
||||||
|
- Prüfe Umami-Dashboard auf https://umami.denshooter.de
|
||||||
|
- Stelle sicher, dass Website ID korrekt ist
|
||||||
|
- Prüfe Browser-Netzwerk-Tab auf Umami-Requests
|
||||||
|
|
||||||
|
### Performance-Metriken fehlen
|
||||||
|
- Prüfe Browser-Konsole auf Performance Observer Fehler
|
||||||
|
- Stelle sicher, dass `useWebVitals` Hook aktiv ist
|
||||||
|
- Teste in verschiedenen Browsern
|
||||||
226
AUTO-DEPLOYMENT.md
Normal file
226
AUTO-DEPLOYMENT.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Automatisches Deployment System
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **automatisches Deployment-System**, das bei jedem Git Push die Codebase prüft, den Container erstellt und startet.
|
||||||
|
|
||||||
|
## 🚀 Deployment-Skripte
|
||||||
|
|
||||||
|
### **1. Auto-Deploy (Vollständig)**
|
||||||
|
```bash
|
||||||
|
# Vollständiges automatisches Deployment
|
||||||
|
./scripts/auto-deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run auto-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Git Status prüfen und uncommitted Changes committen
|
||||||
|
- ✅ Latest Changes pullen
|
||||||
|
- ✅ ESLint Linting
|
||||||
|
- ✅ Tests ausführen
|
||||||
|
- ✅ Next.js Build
|
||||||
|
- ✅ Docker Image erstellen
|
||||||
|
- ✅ Container stoppen/starten
|
||||||
|
- ✅ Health Check
|
||||||
|
- ✅ Cleanup alter Images
|
||||||
|
|
||||||
|
### **2. Quick-Deploy (Schnell)**
|
||||||
|
```bash
|
||||||
|
# Schnelles Deployment ohne Tests
|
||||||
|
./scripts/quick-deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Docker Image erstellen
|
||||||
|
- ✅ Container stoppen/starten
|
||||||
|
- ✅ Health Check
|
||||||
|
|
||||||
|
### **3. Manuelles Deployment**
|
||||||
|
```bash
|
||||||
|
# Manuelles Deployment mit Docker Compose
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Automatisches Deployment
|
||||||
|
|
||||||
|
### **Git Hook Setup**
|
||||||
|
Das System verwendet einen Git Post-Receive Hook, der automatisch bei jedem Push ausgeführt wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Hook ist bereits konfiguriert in:
|
||||||
|
.git/hooks/post-receive
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Wie es funktioniert:**
|
||||||
|
1. **Git Push** → Hook wird ausgelöst
|
||||||
|
2. **Auto-Deploy Script** wird ausgeführt
|
||||||
|
3. **Vollständige Pipeline** läuft automatisch
|
||||||
|
4. **Deployment** wird durchgeführt
|
||||||
|
5. **Health Check** bestätigt Erfolg
|
||||||
|
|
||||||
|
## 📋 Deployment-Schritte
|
||||||
|
|
||||||
|
### **Automatisches Deployment:**
|
||||||
|
```bash
|
||||||
|
# 1. Code Quality Checks
|
||||||
|
git status --porcelain
|
||||||
|
git pull origin main
|
||||||
|
npm run lint
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# 2. Build Application
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 3. Docker Operations
|
||||||
|
docker build -t portfolio-app:latest .
|
||||||
|
docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
# 4. Deployment
|
||||||
|
docker stop portfolio-app || true
|
||||||
|
docker rm portfolio-app || true
|
||||||
|
docker run -d --name portfolio-app -p 3000:3000 portfolio-app:latest
|
||||||
|
|
||||||
|
# 5. Health Check
|
||||||
|
curl -f http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# 6. Cleanup
|
||||||
|
docker system prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Verwendung
|
||||||
|
|
||||||
|
### **Für Entwicklung:**
|
||||||
|
```bash
|
||||||
|
# Schnelles Deployment während der Entwicklung
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Für Production:**
|
||||||
|
```bash
|
||||||
|
# Vollständiges Deployment mit Tests
|
||||||
|
npm run auto-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Automatisch bei Push:**
|
||||||
|
```bash
|
||||||
|
# Einfach committen und pushen
|
||||||
|
git add .
|
||||||
|
git commit -m "Update feature"
|
||||||
|
git push origin main
|
||||||
|
# → Automatisches Deployment läuft
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### **Container Status:**
|
||||||
|
```bash
|
||||||
|
# Status prüfen
|
||||||
|
npm run monitor status
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
npm run monitor health
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
npm run monitor logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Deployment Logs:**
|
||||||
|
```bash
|
||||||
|
# Deployment-Logs anzeigen
|
||||||
|
tail -f /var/log/portfolio-deploy.log
|
||||||
|
|
||||||
|
# Git-Deployment-Logs
|
||||||
|
tail -f /var/log/git-deploy.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Konfiguration
|
||||||
|
|
||||||
|
### **Ports:**
|
||||||
|
- **Standard Port:** 3000
|
||||||
|
- **Backup Port:** 3001 (falls 3000 belegt)
|
||||||
|
|
||||||
|
### **Container:**
|
||||||
|
- **Name:** portfolio-app
|
||||||
|
- **Image:** portfolio-app:latest
|
||||||
|
- **Restart Policy:** unless-stopped
|
||||||
|
|
||||||
|
### **Logs:**
|
||||||
|
- **Deployment Logs:** `/var/log/portfolio-deploy.log`
|
||||||
|
- **Git Logs:** `/var/log/git-deploy.log`
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### **Deployment schlägt fehl:**
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
docker logs portfolio-app
|
||||||
|
|
||||||
|
# Container-Status prüfen
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# Manuell neu starten
|
||||||
|
npm run quick-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Port bereits belegt:**
|
||||||
|
```bash
|
||||||
|
# Ports prüfen
|
||||||
|
lsof -i :3000
|
||||||
|
|
||||||
|
# Anderen Port verwenden
|
||||||
|
docker run -d --name portfolio-app -p 3001:3000 portfolio-app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Tests schlagen fehl:**
|
||||||
|
```bash
|
||||||
|
# Tests lokal ausführen
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Linting prüfen
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Build testen
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Features
|
||||||
|
|
||||||
|
### **Automatische Features:**
|
||||||
|
- ✅ **Git Integration** - Automatisch bei Push
|
||||||
|
- ✅ **Code Quality** - Linting und Tests
|
||||||
|
- ✅ **Health Checks** - Automatische Verifikation
|
||||||
|
- ✅ **Rollback** - Alte Container werden gestoppt
|
||||||
|
- ✅ **Cleanup** - Alte Images werden entfernt
|
||||||
|
- ✅ **Logging** - Vollständige Deployment-Logs
|
||||||
|
|
||||||
|
### **Sicherheits-Features:**
|
||||||
|
- ✅ **Non-root Container**
|
||||||
|
- ✅ **Resource Limits**
|
||||||
|
- ✅ **Health Monitoring**
|
||||||
|
- ✅ **Error Handling**
|
||||||
|
- ✅ **Rollback bei Fehlern**
|
||||||
|
|
||||||
|
## 🎉 Vorteile
|
||||||
|
|
||||||
|
1. **Automatisierung** - Keine manuellen Schritte nötig
|
||||||
|
2. **Konsistenz** - Immer gleiche Deployment-Prozesse
|
||||||
|
3. **Sicherheit** - Tests vor jedem Deployment
|
||||||
|
4. **Monitoring** - Vollständige Logs und Health Checks
|
||||||
|
5. **Schnell** - Quick-Deploy für Entwicklung
|
||||||
|
6. **Zuverlässig** - Automatische Rollbacks bei Fehlern
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. **Logs prüfen:** `tail -f /var/log/portfolio-deploy.log`
|
||||||
|
2. **Container-Status:** `npm run monitor status`
|
||||||
|
3. **Health Check:** `npm run monitor health`
|
||||||
|
4. **Manueller Neustart:** `npm run quick-deploy`
|
||||||
272
DEPLOYMENT.md
Normal file
272
DEPLOYMENT.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# Portfolio Deployment Guide
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet ein **optimiertes CI/CD-System** mit Docker für Production-Deployment. Das System ist darauf ausgelegt, hohen Traffic zu bewältigen und automatische Tests vor dem Deployment durchzuführen.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
### ✅ **CI/CD Pipeline**
|
||||||
|
- **Automatische Tests** vor jedem Deployment
|
||||||
|
- **Security Scanning** mit Trivy
|
||||||
|
- **Multi-Architecture Docker Builds** (AMD64 + ARM64)
|
||||||
|
- **Health Checks** und Deployment-Verifikation
|
||||||
|
- **Automatische Cleanup** alter Images
|
||||||
|
|
||||||
|
### ⚡ **Performance-Optimierungen**
|
||||||
|
- **Multi-Stage Docker Build** für kleinere Images
|
||||||
|
- **Nginx Load Balancer** mit Caching
|
||||||
|
- **Gzip Compression** und optimierte Headers
|
||||||
|
- **Rate Limiting** für API-Endpoints
|
||||||
|
- **Resource Limits** für Container
|
||||||
|
|
||||||
|
### 🔒 **Sicherheit**
|
||||||
|
- **Non-root User** im Container
|
||||||
|
- **Security Headers** (HSTS, CSP, etc.)
|
||||||
|
- **SSL/TLS Termination** mit Nginx
|
||||||
|
- **Vulnerability Scanning** in CI/CD
|
||||||
|
|
||||||
|
## 📁 Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
├── .github/workflows/
|
||||||
|
│ └── ci-cd.yml # CI/CD Pipeline
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy.sh # Deployment-Skript
|
||||||
|
│ └── monitor.sh # Monitoring-Skript
|
||||||
|
├── docker-compose.prod.yml # Production Docker Compose
|
||||||
|
├── nginx.conf # Nginx Konfiguration
|
||||||
|
├── Dockerfile # Optimiertes Dockerfile
|
||||||
|
└── env.example # Environment Template
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Setup
|
||||||
|
|
||||||
|
### 1. **Environment Variables**
|
||||||
|
```bash
|
||||||
|
# Kopiere die Beispiel-Datei
|
||||||
|
cp env.example .env
|
||||||
|
|
||||||
|
# Bearbeite die .env Datei mit deinen Werten
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **GitHub Secrets & Variables**
|
||||||
|
Konfiguriere in deinem GitHub Repository:
|
||||||
|
|
||||||
|
**Secrets:**
|
||||||
|
- `GITHUB_TOKEN` (automatisch verfügbar)
|
||||||
|
- `GHOST_API_KEY`
|
||||||
|
- `MY_PASSWORD`
|
||||||
|
- `MY_INFO_PASSWORD`
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- `NEXT_PUBLIC_BASE_URL`
|
||||||
|
- `GHOST_API_URL`
|
||||||
|
- `MY_EMAIL`
|
||||||
|
- `MY_INFO_EMAIL`
|
||||||
|
|
||||||
|
### 3. **SSL-Zertifikate**
|
||||||
|
```bash
|
||||||
|
# Erstelle SSL-Verzeichnis
|
||||||
|
mkdir -p ssl
|
||||||
|
|
||||||
|
# Kopiere deine SSL-Zertifikate
|
||||||
|
cp your-cert.pem ssl/cert.pem
|
||||||
|
cp your-key.pem ssl/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### **Automatisches Deployment**
|
||||||
|
Das System deployt automatisch bei Push auf den `production` Branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Code auf production Branch pushen
|
||||||
|
git push origin production
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Manuelles Deployment**
|
||||||
|
```bash
|
||||||
|
# Lokales Deployment
|
||||||
|
./scripts/deploy.sh production
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Docker Commands**
|
||||||
|
```bash
|
||||||
|
# Container starten
|
||||||
|
npm run docker:compose
|
||||||
|
|
||||||
|
# Container stoppen
|
||||||
|
npm run docker:down
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
npm run health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### **Container Status**
|
||||||
|
```bash
|
||||||
|
# Status anzeigen
|
||||||
|
./scripts/monitor.sh status
|
||||||
|
|
||||||
|
# Oder mit npm
|
||||||
|
npm run monitor status
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Health Check**
|
||||||
|
```bash
|
||||||
|
# Application Health
|
||||||
|
./scripts/monitor.sh health
|
||||||
|
|
||||||
|
# Oder direkt
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Logs anzeigen**
|
||||||
|
```bash
|
||||||
|
# Letzte 50 Zeilen
|
||||||
|
./scripts/monitor.sh logs 50
|
||||||
|
|
||||||
|
# Live-Logs folgen
|
||||||
|
./scripts/monitor.sh logs 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Metriken**
|
||||||
|
```bash
|
||||||
|
# Detaillierte Metriken
|
||||||
|
./scripts/monitor.sh metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Wartung
|
||||||
|
|
||||||
|
### **Container neustarten**
|
||||||
|
```bash
|
||||||
|
./scripts/monitor.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Cleanup**
|
||||||
|
```bash
|
||||||
|
# Docker-Ressourcen bereinigen
|
||||||
|
./scripts/monitor.sh cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Updates**
|
||||||
|
```bash
|
||||||
|
# Neues Image pullen und deployen
|
||||||
|
./scripts/deploy.sh production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Performance-Tuning
|
||||||
|
|
||||||
|
### **Nginx Optimierungen**
|
||||||
|
- **Gzip Compression** aktiviert
|
||||||
|
- **Static Asset Caching** (1 Jahr)
|
||||||
|
- **API Rate Limiting** (10 req/s)
|
||||||
|
- **Load Balancing** bereit für Skalierung
|
||||||
|
|
||||||
|
### **Docker Optimierungen**
|
||||||
|
- **Multi-Stage Build** für kleinere Images
|
||||||
|
- **Non-root User** für Sicherheit
|
||||||
|
- **Health Checks** für automatische Recovery
|
||||||
|
- **Resource Limits** (512MB RAM, 0.5 CPU)
|
||||||
|
|
||||||
|
### **Next.js Optimierungen**
|
||||||
|
- **Standalone Output** für Docker
|
||||||
|
- **Image Optimization** (WebP, AVIF)
|
||||||
|
- **CSS Optimization** aktiviert
|
||||||
|
- **Package Import Optimization**
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### **Container startet nicht**
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
./scripts/monitor.sh logs
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
./scripts/monitor.sh status
|
||||||
|
|
||||||
|
# Neustarten
|
||||||
|
./scripts/monitor.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Health Check schlägt fehl**
|
||||||
|
```bash
|
||||||
|
# Manueller Health Check
|
||||||
|
curl -v http://localhost:3000/api/health
|
||||||
|
|
||||||
|
# Container-Logs prüfen
|
||||||
|
docker-compose -f docker-compose.prod.yml logs portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Performance-Probleme**
|
||||||
|
```bash
|
||||||
|
# Resource-Usage prüfen
|
||||||
|
./scripts/monitor.sh metrics
|
||||||
|
|
||||||
|
# Nginx-Logs prüfen
|
||||||
|
docker-compose -f docker-compose.prod.yml logs nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### **SSL-Probleme**
|
||||||
|
```bash
|
||||||
|
# SSL-Zertifikate prüfen
|
||||||
|
openssl x509 -in ssl/cert.pem -text -noout
|
||||||
|
|
||||||
|
# Nginx-Konfiguration testen
|
||||||
|
docker-compose -f docker-compose.prod.yml exec nginx nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 CI/CD Pipeline
|
||||||
|
|
||||||
|
### **Workflow-Schritte**
|
||||||
|
1. **Test** - Linting, Tests, Build
|
||||||
|
2. **Security** - Trivy Vulnerability Scan
|
||||||
|
3. **Build** - Multi-Arch Docker Image
|
||||||
|
4. **Deploy** - Automatisches Deployment
|
||||||
|
|
||||||
|
### **Trigger**
|
||||||
|
- **Push auf `main`** - Build nur
|
||||||
|
- **Push auf `production`** - Build + Deploy
|
||||||
|
- **Pull Request** - Test + Security
|
||||||
|
|
||||||
|
### **Monitoring**
|
||||||
|
- **GitHub Actions** - Pipeline-Status
|
||||||
|
- **Container Health** - Automatische Checks
|
||||||
|
- **Resource Usage** - Monitoring-Skript
|
||||||
|
|
||||||
|
## 🔄 Skalierung
|
||||||
|
|
||||||
|
### **Horizontal Scaling**
|
||||||
|
```yaml
|
||||||
|
# In nginx.conf - weitere Backend-Server hinzufügen
|
||||||
|
upstream portfolio_backend {
|
||||||
|
least_conn;
|
||||||
|
server portfolio:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
server portfolio-2:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
server portfolio-3:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Vertical Scaling**
|
||||||
|
```yaml
|
||||||
|
# In docker-compose.prod.yml - Resource-Limits erhöhen
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. **Logs prüfen**: `./scripts/monitor.sh logs`
|
||||||
|
2. **Status prüfen**: `./scripts/monitor.sh status`
|
||||||
|
3. **Health Check**: `./scripts/monitor.sh health`
|
||||||
|
4. **Container neustarten**: `./scripts/monitor.sh restart`
|
||||||
77
Dockerfile
77
Dockerfile
@@ -1,41 +1,72 @@
|
|||||||
# Stage 1: Build
|
# Multi-stage build for optimized production image
|
||||||
FROM node:current-alpine AS builder
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
# Set working directory
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json
|
# Install dependencies based on the preferred package manager
|
||||||
COPY package*.json ./
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
# Install dependencies including development dependencies
|
# Rebuild the source code only when needed
|
||||||
RUN npm install
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
# Copy the application code
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install type definitions for react-responsive-masonry and node-fetch
|
# Install type definitions for react-responsive-masonry and node-fetch
|
||||||
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
|
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
|
||||||
|
|
||||||
# Build the Next.js application
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production
|
# Production image, copy all the files and run next
|
||||||
FROM node:current-alpine
|
FROM base AS runner
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy only the necessary files from the build stage
|
ENV NODE_ENV=production
|
||||||
COPY --from=builder /app/package*.json ./
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY --from=builder /app/.next ./.next
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy the built application
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.env .env
|
|
||||||
|
|
||||||
# Install only production dependencies
|
# Set the correct permission for prerender cache
|
||||||
RUN npm install --only=production
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy Prisma files
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
COPY --from=builder /app/.env* ./
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
# Expose the port the app runs on
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Run the app with the start script
|
ENV PORT=3000
|
||||||
ENTRYPOINT [ "npm", "run", "start" ]
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
228
README-DATABASE.md
Normal file
228
README-DATABASE.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# 🗄️ Portfolio Database Setup
|
||||||
|
|
||||||
|
Dieses Portfolio verwendet **PostgreSQL mit Prisma ORM** für maximale Performance und Skalierbarkeit.
|
||||||
|
|
||||||
|
## 🚀 Warum PostgreSQL + Prisma?
|
||||||
|
|
||||||
|
- **🏃♂️ Hohe Performance**: Kann tausende User gleichzeitig bedienen
|
||||||
|
- **📈 Einfache Skalierung**: Von lokal zu Cloud ohne Code-Änderungen
|
||||||
|
- **🔧 TypeScript-First**: Vollständige Type-Sicherheit und Auto-completion
|
||||||
|
- **💾 Robuste Datenbank**: ACID, Transaktionen, Indizes für optimale Performance
|
||||||
|
- **🔄 Einfache Migration**: Einfache Updates und Schema-Änderungen
|
||||||
|
|
||||||
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm oder yarn
|
||||||
|
- PostgreSQL (wird automatisch installiert)
|
||||||
|
|
||||||
|
## 🛠️ Schnellstart (Automatisch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Repository klonen
|
||||||
|
git clone <your-repo>
|
||||||
|
cd my_portfolio
|
||||||
|
|
||||||
|
# 2. Automatische Datenbank-Einrichtung
|
||||||
|
npm run db:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript installiert automatisch:
|
||||||
|
- ✅ PostgreSQL
|
||||||
|
- ✅ Datenbank und Benutzer
|
||||||
|
- ✅ Prisma Client
|
||||||
|
- ✅ Beispieldaten
|
||||||
|
- ✅ Umgebungsvariablen
|
||||||
|
|
||||||
|
## 🔧 Manuelle Einrichtung
|
||||||
|
|
||||||
|
### 1. PostgreSQL installieren
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install postgresql postgresql-contrib
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
brew install postgresql
|
||||||
|
brew services start postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- [PostgreSQL Download](https://www.postgresql.org/download/windows/)
|
||||||
|
|
||||||
|
### 2. Datenbank einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL starten
|
||||||
|
sudo systemctl start postgresql # Linux
|
||||||
|
brew services start postgresql # macOS
|
||||||
|
|
||||||
|
# Datenbank und Benutzer erstellen
|
||||||
|
sudo -u postgres psql
|
||||||
|
CREATE DATABASE portfolio_db;
|
||||||
|
CREATE USER portfolio_user WITH PASSWORD 'portfolio_pass';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE portfolio_db TO portfolio_user;
|
||||||
|
ALTER USER portfolio_user WITH SUPERUSER;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Umgebungsvariablen
|
||||||
|
|
||||||
|
Erstelle `.env.local`:
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://portfolio_user:portfolio_pass@localhost:5432/portfolio_db?schema=public"
|
||||||
|
NEXTAUTH_SECRET="your-secret-key-here"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dependencies installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma db push
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Verfügbare Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Datenbank verwalten
|
||||||
|
npm run db:setup # Vollständige Einrichtung
|
||||||
|
npm run db:generate # Prisma Client generieren
|
||||||
|
npm run db:push # Schema zur Datenbank pushen
|
||||||
|
npm run db:seed # Beispieldaten einfügen
|
||||||
|
npm run db:studio # Datenbank-Interface öffnen
|
||||||
|
npm run db:reset # Datenbank zurücksetzen
|
||||||
|
|
||||||
|
# Entwicklung
|
||||||
|
npm run dev # Entwicklungsserver starten
|
||||||
|
npm run build # Produktions-Build
|
||||||
|
npm run start # Produktions-Server starten
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Datenbank-Schema
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- **Basis**: Titel, Beschreibung, Inhalt, Tags
|
||||||
|
- **Metadaten**: Kategorie, Schwierigkeit, Datum
|
||||||
|
- **Performance**: Lighthouse Score, Bundle Size, Load Time
|
||||||
|
- **Analytics**: Views, Likes, Shares
|
||||||
|
- **Erweiterte Features**: Technologien, Herausforderungen, Lektionen
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- **PageViews**: Seitenaufrufe mit IP und User-Agent
|
||||||
|
- **UserInteractions**: Likes, Shares, Bookmarks, Kommentare
|
||||||
|
|
||||||
|
## 📊 Performance-Features
|
||||||
|
|
||||||
|
- **Indizes** auf allen wichtigen Feldern
|
||||||
|
- **Pagination** für große Datenmengen
|
||||||
|
- **Caching** für häufige Abfragen
|
||||||
|
- **Optimierte Queries** mit Prisma
|
||||||
|
- **Real-time Updates** möglich
|
||||||
|
|
||||||
|
## 🔄 Migration & Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Schema ändern
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# Bei Breaking Changes
|
||||||
|
npx prisma migrate dev --name update_schema
|
||||||
|
|
||||||
|
# Produktion
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Deployment
|
||||||
|
|
||||||
|
### Lokal zu Cloud Migration
|
||||||
|
|
||||||
|
1. **Datenbank exportieren:**
|
||||||
|
```bash
|
||||||
|
pg_dump portfolio_db > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Cloud-Datenbank einrichten** (z.B. Supabase, PlanetScale, AWS RDS)
|
||||||
|
|
||||||
|
3. **Umgebungsvariablen aktualisieren:**
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:pass@host:5432/db?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Schema pushen:**
|
||||||
|
```bash
|
||||||
|
npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Troubleshooting
|
||||||
|
|
||||||
|
### PostgreSQL startet nicht
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew services list
|
||||||
|
brew services restart postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verbindungsfehler
|
||||||
|
```bash
|
||||||
|
# PostgreSQL Status prüfen
|
||||||
|
sudo -u postgres psql -c "SELECT version();"
|
||||||
|
|
||||||
|
# Verbindung testen
|
||||||
|
psql -h localhost -U portfolio_user -d portfolio_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prisma Fehler
|
||||||
|
```bash
|
||||||
|
# Client neu generieren
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Datenbank zurücksetzen
|
||||||
|
npx prisma db push --force-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Monitoring & Wartung
|
||||||
|
|
||||||
|
### Datenbank-Status
|
||||||
|
```bash
|
||||||
|
# Größe prüfen
|
||||||
|
psql -U portfolio_user -d portfolio_db -c "SELECT pg_size_pretty(pg_database_size('portfolio_db'));"
|
||||||
|
|
||||||
|
# Performance-Statistiken
|
||||||
|
psql -U portfolio_user -d portfolio_db -c "SELECT * FROM pg_stat_database;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup & Restore
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
pg_dump -U portfolio_user portfolio_db > backup_$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# Backup wiederherstellen
|
||||||
|
psql -U portfolio_user -d portfolio_db < backup_20241201.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎉 Nächste Schritte
|
||||||
|
|
||||||
|
1. **Datenbank starten**: `npm run db:setup`
|
||||||
|
2. **Entwicklungsserver starten**: `npm run dev`
|
||||||
|
3. **Admin-Bereich öffnen**: http://localhost:3000/admin
|
||||||
|
4. **Projekte verwalten** und dein Portfolio erweitern!
|
||||||
|
|
||||||
|
## 📚 Weitere Ressourcen
|
||||||
|
|
||||||
|
- [Prisma Dokumentation](https://www.prisma.io/docs)
|
||||||
|
- [PostgreSQL Dokumentation](https://www.postgresql.org/docs/)
|
||||||
|
- [Next.js API Routes](https://nextjs.org/docs/api-routes/introduction)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fragen oder Probleme?** Erstelle ein Issue oder kontaktiere mich! 🚀
|
||||||
258
README.md
258
README.md
@@ -1,248 +1,30 @@
|
|||||||
# Dennis Konkol's Portfolio Website
|
# Dennis Konkol Portfolio - Modern Dark Theme
|
||||||
|
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [
|
Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Markdown-Editor.
|
||||||
`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, clone the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Denshooter/my_portfolio.git
|
|
||||||
cd my_portfolio
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, install the dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
# or
|
|
||||||
pnpm install
|
|
||||||
# or
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
To run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
To build the application for production:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
# or
|
|
||||||
yarn build
|
|
||||||
# or
|
|
||||||
pnpm build
|
|
||||||
# or
|
|
||||||
bun build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Start
|
|
||||||
|
|
||||||
To start the application in production mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run start
|
|
||||||
# or
|
|
||||||
yarn start
|
|
||||||
# or
|
|
||||||
pnpm start
|
|
||||||
# or
|
|
||||||
bun start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lint
|
|
||||||
|
|
||||||
To run the linter:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
# or
|
|
||||||
yarn lint
|
|
||||||
# or
|
|
||||||
pnpm lint
|
|
||||||
# or
|
|
||||||
bun lint
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
my_portfolio/
|
|
||||||
├── .github/
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── main.yml
|
|
||||||
├── app/
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── email/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchAllProjects/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchImage/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchProject/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │
|
|
||||||
|
|
||||||
i mean to give more examples how it was built what it features and how it works not how to run it, but more explanations about the project and how its deployed etc.
|
|
||||||
|
|
||||||
Got it! Here's an updated README.md that provides more detailed explanations about the project, its features, how it was built, and how it is deployed:
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Dennis Konkol's Portfolio
|
|
||||||
|
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). It showcases my projects, provides a way to contact me, and includes various other features.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Responsive Design**: The portfolio is fully responsive and works on all devices.
|
- **Dunkles Theme** mit Glassmorphism-Effekten
|
||||||
- **Project Showcase**: Displays a list of projects fetched from a Ghost CMS.
|
- **Responsive Design** für alle Geräte
|
||||||
- **Contact Form**: Allows visitors to send me messages via email.
|
- **Smooth Animationen** mit Framer Motion
|
||||||
- **SEO Optimized**: Includes metadata and Open Graph tags for better SEO.
|
- **Markdown-Editor** für Projekte
|
||||||
- **Dynamic Sitemap**: Automatically generates a sitemap for better search engine indexing.
|
- **Admin Dashboard** für Content-Management
|
||||||
- **Cookie Consent**: Includes a cookie consent banner to comply with GDPR.
|
|
||||||
- **Zero-Downtime Deployment**: Uses Docker and GitHub Actions for seamless deployments to a Raspberry Pi.
|
|
||||||
|
|
||||||
## Project Structure
|
## Technologien
|
||||||
|
|
||||||
```plaintext
|
- Next.js 15 mit App Router
|
||||||
my_portfolio/
|
- TypeScript für Type Safety
|
||||||
├── .github/
|
- Tailwind CSS für Styling
|
||||||
│ └── workflows/
|
- Framer Motion für Animationen
|
||||||
│ └── main.yml
|
- React Markdown für Content
|
||||||
├── app/
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── email/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchAllProjects/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchImage/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── fetchProject/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── og/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── projects/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ │ ├── sitemap/
|
|
||||||
│ │ │ └── route.tsx
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── ClientCookieConsentBanner.tsx
|
|
||||||
│ │ ├── Contact.tsx
|
|
||||||
│ │ ├── CookieConsentBanner.tsx
|
|
||||||
│ │ ├── Footer.tsx
|
|
||||||
│ │ ├── Footer_Back.tsx
|
|
||||||
│ │ ├── Header.tsx
|
|
||||||
│ │ ├── Hero.tsx
|
|
||||||
│ │ ├── Projects.tsx
|
|
||||||
│ ├── styles/
|
|
||||||
│ │ └── ghostContent.css
|
|
||||||
│ ├── globals.css
|
|
||||||
│ ├── layout.tsx
|
|
||||||
│ ├── metadata.tsx
|
|
||||||
│ ├── not-found.tsx
|
|
||||||
│ ├── page.tsx
|
|
||||||
│ ├── privacy-policy/
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ ├── legal-notice/
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ ├── projects/
|
|
||||||
│ │ └── [slug]/
|
|
||||||
│ │ └── page.tsx
|
|
||||||
│ ├── sitemap.xml/
|
|
||||||
│ │ └── route.tsx
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── send-email.tsx
|
|
||||||
├── public/
|
|
||||||
│ ├── icons/
|
|
||||||
│ │ ├── github.svg
|
|
||||||
│ │ ├── linkedin.svg
|
|
||||||
│ ├── images/
|
|
||||||
│ ├── robots.txt
|
|
||||||
├── Dockerfile
|
|
||||||
├── README.md
|
|
||||||
├── next.config.ts
|
|
||||||
├── package.json
|
|
||||||
├── tailwind.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
└── eslint.config.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
## Installation
|
||||||
|
|
||||||
### Project Showcase
|
npm install
|
||||||
|
npm run dev
|
||||||
|
|
||||||
Projects are fetched from a Ghost CMS using the Ghost Content API. The API routes in the `app/api` directory handle
|
## Verwendung
|
||||||
fetching all projects, fetching a single project by slug, and fetching images.
|
|
||||||
|
|
||||||
### Contact Form
|
- `/` - Homepage
|
||||||
|
- `/projects` - Alle Projekte
|
||||||
The contact form allows visitors to send me messages via email. It uses the `nodemailer` package to send emails through
|
- `/admin` - Admin Dashboard mit Markdown-Editor
|
||||||
an SMTP server. The API route `app/api/email/route.tsx` handles the email sending logic.
|
|
||||||
|
|
||||||
### SEO and Open Graph
|
|
||||||
|
|
||||||
The project includes metadata and Open Graph tags to improve SEO. The `app/metadata.tsx` file defines the metadata for
|
|
||||||
the site. The `app/api/og/route.tsx` file generates dynamic Open Graph images.
|
|
||||||
|
|
||||||
### Dynamic Sitemap
|
|
||||||
|
|
||||||
A dynamic sitemap is generated to help search engines index the site. The `app/api/sitemap/route.tsx` file generates the
|
|
||||||
sitemap, and the `app/sitemap.xml/route.tsx` file serves it.
|
|
||||||
|
|
||||||
### Cookie Consent
|
|
||||||
|
|
||||||
A cookie consent banner is included to comply with GDPR. The `app/components/CookieConsentBanner.tsx` and
|
|
||||||
`app/components/ClientCookieConsentBanner.tsx` components handle the display and logic of the cookie consent banner.
|
|
||||||
|
|
||||||
### Zero-Downtime Deployment
|
|
||||||
|
|
||||||
The project uses Docker and GitHub Actions for zero-downtime deployments to a Raspberry Pi. The
|
|
||||||
`.github/workflows/main.yml` file defines the GitHub Actions workflow for deploying the project. The `Dockerfile`
|
|
||||||
defines the Docker image for the project.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The project is deployed using Docker and GitHub Actions. The GitHub Actions workflow is defined in
|
|
||||||
`.github/workflows/main.yml`. It builds the Docker image and deploys it to a Raspberry Pi with zero downtime.
|
|
||||||
|
|
||||||
### Steps to Deploy
|
|
||||||
|
|
||||||
1. **Set Up Raspberry Pi**: Ensure Docker is installed on your Raspberry Pi.
|
|
||||||
2. **Configure GitHub Secrets**: Add the necessary secrets (e.g., `GHOST_API_KEY`, `MY_EMAIL`, `MY_PASSWORD`) to your
|
|
||||||
GitHub repository.
|
|
||||||
3. **Push to GitHub**: Push your changes to the `production`, `dev`, or `preview` branches to trigger the deployment
|
|
||||||
workflow.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions
|
|
||||||
are welcome!
|
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
- **Dennis Konkol** - [GitHub](https://github.com/Denshooter) | [LinkedIn](https://linkedin.com/in/dkonkol)
|
|
||||||
|
|||||||
1526
app/admin/page.tsx
Normal file
1526
app/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
31
app/api/analytics/route.ts
Normal file
31
app/api/analytics/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Log performance metrics (you can extend this to store in database)
|
||||||
|
console.log('Performance Metric:', {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// You could store this in a database or send to external service
|
||||||
|
// For now, we'll just log it since Umami handles the main analytics
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Analytics API Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process analytics data' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Analytics API is running',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,29 +4,59 @@ import SMTPTransport from "nodemailer/lib/smtp-transport";
|
|||||||
import Mail from "nodemailer/lib/mailer";
|
import Mail from "nodemailer/lib/mailer";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
subject: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
const { email, name, message } = body;
|
const { email, name, subject, message } = body;
|
||||||
|
|
||||||
|
console.log('📧 Email request received:', { email, name, subject, messageLength: message.length });
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!email || !name || !subject || !message) {
|
||||||
|
console.error('❌ Validation failed: Missing required fields');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Alle Felder sind erforderlich" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
console.error('❌ Validation failed: Invalid email format');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ungültige E-Mail-Adresse" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message length
|
||||||
|
if (message.length < 10) {
|
||||||
|
console.error('❌ Validation failed: Message too short');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = process.env.MY_EMAIL ?? "";
|
const user = process.env.MY_EMAIL ?? "";
|
||||||
const pass = process.env.MY_PASSWORD ?? "";
|
const pass = process.env.MY_PASSWORD ?? "";
|
||||||
|
|
||||||
if (!user || !pass) {
|
console.log('🔑 Environment check:', {
|
||||||
console.error("Missing email/password environment variables");
|
hasEmail: !!user,
|
||||||
return NextResponse.json(
|
hasPassword: !!pass,
|
||||||
{ error: "Missing EMAIL or PASSWORD" },
|
emailHost: user.split('@')[1] || 'unknown'
|
||||||
{ status: 500 },
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email || !name || !message) {
|
if (!user || !pass) {
|
||||||
console.error("Invalid request body");
|
console.error("❌ Missing email/password environment variables");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid request body" },
|
{ error: "E-Mail-Server nicht konfiguriert" },
|
||||||
{ status: 400 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,34 +70,102 @@ export async function POST(request: NextRequest) {
|
|||||||
user,
|
user,
|
||||||
pass,
|
pass,
|
||||||
},
|
},
|
||||||
|
// Add timeout and debug options
|
||||||
|
connectionTimeout: 10000,
|
||||||
|
greetingTimeout: 10000,
|
||||||
|
socketTimeout: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('🚀 Creating transport with options:', {
|
||||||
|
host: transportOptions.host,
|
||||||
|
port: transportOptions.port,
|
||||||
|
secure: transportOptions.secure,
|
||||||
|
user: user.split('@')[0] + '@***' // Hide full email in logs
|
||||||
|
});
|
||||||
|
|
||||||
const transport = nodemailer.createTransport(transportOptions);
|
const transport = nodemailer.createTransport(transportOptions);
|
||||||
|
|
||||||
|
// Verify transport configuration
|
||||||
|
try {
|
||||||
|
await transport.verify();
|
||||||
|
console.log('✅ SMTP connection verified successfully');
|
||||||
|
} catch (verifyError) {
|
||||||
|
console.error('❌ SMTP verification failed:', verifyError);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: user,
|
from: `"Portfolio Contact" <${user}>`,
|
||||||
to: user, // Ensure this is the correct email address
|
to: "contact@dki.one", // Send to your contact email
|
||||||
subject: `Message from ${name} (${email})`,
|
replyTo: email,
|
||||||
text: message + "\n\n" + email,
|
subject: `Portfolio Kontakt: ${subject}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #3b82f6;">Neue Kontaktanfrage von deinem Portfolio</h2>
|
||||||
|
|
||||||
|
<div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="color: #1e293b; margin-top: 0;">Nachricht von ${name}</h3>
|
||||||
|
<p style="color: #475569; margin: 8px 0;"><strong>E-Mail:</strong> ${email}</p>
|
||||||
|
<p style="color: #475569; margin: 8px 0;"><strong>Betreff:</strong> ${subject}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
|
||||||
|
<h4 style="color: #1e293b; margin-top: 0;">Nachricht:</h4>
|
||||||
|
<p style="color: #374151; line-height: 1.6; white-space: pre-wrap;">${message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 30px; padding: 20px; background: #f1f5f9; border-radius: 8px;">
|
||||||
|
<p style="color: #64748b; margin: 0; font-size: 14px;">
|
||||||
|
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: `
|
||||||
|
Neue Kontaktanfrage von deinem Portfolio
|
||||||
|
|
||||||
|
Von: ${name} (${email})
|
||||||
|
Betreff: ${subject}
|
||||||
|
|
||||||
|
Nachricht:
|
||||||
|
${message}
|
||||||
|
|
||||||
|
---
|
||||||
|
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('📤 Sending email...');
|
||||||
|
|
||||||
const sendMailPromise = () =>
|
const sendMailPromise = () =>
|
||||||
new Promise<string>((resolve, reject) => {
|
new Promise<string>((resolve, reject) => {
|
||||||
transport.sendMail(mailOptions, function (err, info) {
|
transport.sendMail(mailOptions, function (err, info) {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
|
console.log('✅ Email sent successfully:', info.response);
|
||||||
resolve(info.response);
|
resolve(info.response);
|
||||||
} else {
|
} else {
|
||||||
console.error("Error sending email:", err);
|
console.error("❌ Error sending email:", err);
|
||||||
reject(err.message);
|
reject(err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const result = await sendMailPromise();
|
||||||
await sendMailPromise();
|
console.log('🎉 Email process completed successfully');
|
||||||
return NextResponse.json({ message: "Email sent" });
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "E-Mail erfolgreich gesendet",
|
||||||
|
messageId: result
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending email:", err);
|
console.error("❌ Unexpected error in email API:", err);
|
||||||
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
|
return NextResponse.json({
|
||||||
|
error: "Fehler beim Senden der E-Mail",
|
||||||
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/api/health/route.ts
Normal file
25
app/api/health/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Basic health check
|
||||||
|
const healthCheck = {
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(healthCheck, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/projects/[id]/route.ts
Normal file
77
app/api/projects/[id]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Project not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(project);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching project:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch project' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...data, updatedAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(project);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update project' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
|
||||||
|
await prisma.project.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete project' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/projects/route.ts
Normal file
78
app/api/projects/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50');
|
||||||
|
const category = searchParams.get('category');
|
||||||
|
const featured = searchParams.get('featured');
|
||||||
|
const published = searchParams.get('published');
|
||||||
|
const difficulty = searchParams.get('difficulty');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (featured !== null) where.featured = featured === 'true';
|
||||||
|
if (published !== null) where.published = published === 'true';
|
||||||
|
if (difficulty) where.difficulty = difficulty;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ tags: { hasSome: [search] } },
|
||||||
|
{ content: { contains: search, mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
currentPage: page
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching projects:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch projects' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
performance: data.performance || { lighthouse: 90, bundleSize: '50KB', loadTime: '1.5s' },
|
||||||
|
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(project);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating project:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create project' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/projects/search/route.ts
Normal file
81
app/api/projects/search/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
const category = searchParams.get('category');
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
// Search by slug (convert title to slug format)
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
published: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find exact match by converting titles to slugs
|
||||||
|
const foundProject = projects.find(project => {
|
||||||
|
const projectSlug = project.title.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return projectSlug === slug;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundProject) {
|
||||||
|
return NextResponse.json({ projects: [foundProject] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, return empty array
|
||||||
|
return NextResponse.json({ projects: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// General search
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
published: true,
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ tags: { hasSome: [search] } },
|
||||||
|
{ content: { contains: search, mode: 'insensitive' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category && category !== 'All') {
|
||||||
|
// Filter by category
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
published: true,
|
||||||
|
category: category
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return all published projects if no specific search
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching projects:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to search projects' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,207 +1,283 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
"use client";
|
||||||
import { sendEmail } from "@/app/utils/send-email";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export type ContactFormData = {
|
import { useState, useEffect } from 'react';
|
||||||
name: string;
|
import { motion } from 'framer-motion';
|
||||||
email: string;
|
import { Mail, Phone, MapPin, Send, Github, Linkedin, Twitter } from 'lucide-react';
|
||||||
message: string;
|
import { useToast } from '@/components/Toast';
|
||||||
};
|
|
||||||
|
|
||||||
export default function Contact() {
|
const Contact = () => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [banner, setBanner] = useState<{
|
const { showEmailSent, showEmailError } = useToast();
|
||||||
show: boolean;
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error";
|
|
||||||
}>({
|
|
||||||
show: false,
|
|
||||||
message: "",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
// Record the time when the form is rendered
|
|
||||||
const [formLoadedTimestamp, setFormLoadedTimestamp] = useState<number>(Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormLoadedTimestamp(Date.now());
|
setMounted(true);
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(true);
|
|
||||||
}, 350);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
subject: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement;
|
try {
|
||||||
const formData = new FormData(form);
|
const response = await fetch('/api/email', {
|
||||||
|
method: 'POST',
|
||||||
// Honeypot check
|
headers: {
|
||||||
const honeypot = formData.get("hp-field");
|
'Content-Type': 'application/json',
|
||||||
if (honeypot) {
|
},
|
||||||
setBanner({
|
body: JSON.stringify({
|
||||||
show: true,
|
name: formData.name,
|
||||||
message: "Bot detected",
|
email: formData.email,
|
||||||
type: "error",
|
subject: formData.subject,
|
||||||
|
message: formData.message,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time-based anti-bot check
|
if (response.ok) {
|
||||||
const timestampStr = formData.get("timestamp") as string;
|
showEmailSent(formData.email);
|
||||||
const timestamp = parseInt(timestampStr, 10);
|
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||||
if (Date.now() - timestamp < 3000) {
|
} else {
|
||||||
setBanner({
|
const errorData = await response.json();
|
||||||
show: true,
|
showEmailError(errorData.error || 'Unbekannter Fehler');
|
||||||
message: "Please take your time filling out the form.",
|
}
|
||||||
type: "error",
|
} catch (error) {
|
||||||
});
|
console.error('Error sending email:', error);
|
||||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
showEmailError('Netzwerkfehler beim Senden der E-Mail');
|
||||||
return;
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: ContactFormData = {
|
|
||||||
name: formData.get("name") as string,
|
|
||||||
email: formData.get("email") as string,
|
|
||||||
message: formData.get("message") as string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonData = JSON.stringify(data);
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({
|
||||||
const submitButton = form.querySelector("button[type='submit']");
|
...formData,
|
||||||
if (submitButton) {
|
[e.target.name]: e.target.value
|
||||||
submitButton.setAttribute("disabled", "true");
|
|
||||||
submitButton.textContent = "Sending...";
|
|
||||||
|
|
||||||
const response = await sendEmail(jsonData);
|
|
||||||
if (response.success) {
|
|
||||||
form.reset();
|
|
||||||
submitButton.textContent = "Sent!";
|
|
||||||
setTimeout(() => {
|
|
||||||
submitButton.removeAttribute("disabled");
|
|
||||||
submitButton.textContent = "Send Message";
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
setBanner({
|
|
||||||
show: true,
|
|
||||||
message: response.message,
|
|
||||||
type: response.success ? "success" : "error",
|
|
||||||
});
|
});
|
||||||
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
|
};
|
||||||
|
|
||||||
|
const contactInfo = [
|
||||||
|
{
|
||||||
|
icon: Mail,
|
||||||
|
title: 'Email',
|
||||||
|
value: 'contact@dki.one',
|
||||||
|
href: 'mailto:contact@dki.one'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Phone,
|
||||||
|
title: 'Phone',
|
||||||
|
value: '+49 123 456 789',
|
||||||
|
href: 'tel:+49123456789'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MapPin,
|
||||||
|
title: 'Location',
|
||||||
|
value: 'Osnabrück, Germany',
|
||||||
|
href: '#'
|
||||||
}
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||||
|
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||||
|
{ icon: Twitter, href: 'https://twitter.com/dkonkol', label: 'Twitter' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="contact" className="py-20 px-4 relative">
|
||||||
id="contact"
|
<div className="max-w-7xl mx-auto">
|
||||||
className={`p-10 ${isVisible ? "animate-fade-in" : "opacity-0"}`}
|
{/* Section Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-8">
|
<h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||||
Get in Touch
|
Get In Touch
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white/30 p-8 rounded-3xl shadow-xl max-w-lg mx-auto">
|
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||||
{banner.show && (
|
Have a project in mind or want to collaborate? I would love to hear from you!
|
||||||
<div
|
</p>
|
||||||
className={`mb-4 text-center rounded-full py-2 px-4 text-white ${
|
</motion.div>
|
||||||
banner.type === "success" ? "bg-green-500" : "bg-red-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{banner.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<form className="space-y-6" onSubmit={onSubmit}>
|
|
||||||
{/* Honeypot field */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="hp-field"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{/* Hidden timestamp field */}
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="timestamp"
|
|
||||||
value={formLoadedTimestamp.toString()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
<label
|
{/* Contact Information */}
|
||||||
htmlFor="name"
|
<motion.div
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
initial={{ opacity: 0, x: -30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="space-y-8"
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-6">
|
||||||
|
Let's Connect
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 leading-relaxed">
|
||||||
|
I'm always open to discussing new opportunities, interesting projects,
|
||||||
|
or just having a chat about technology and innovation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{contactInfo.map((info, index) => (
|
||||||
|
<motion.a
|
||||||
|
key={info.title}
|
||||||
|
href={info.href}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
|
whileHover={{ x: 5 }}
|
||||||
|
className="flex items-center space-x-4 p-4 rounded-lg glass-card hover:bg-gray-800/30 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="p-3 bg-blue-500/20 rounded-lg group-hover:bg-blue-500/30 transition-colors">
|
||||||
|
<info.icon className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white">{info.title}</h4>
|
||||||
|
<p className="text-gray-400">{info.value}</p>
|
||||||
|
</div>
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-white mb-4">Follow Me</h4>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<motion.a
|
||||||
|
key={social.label}
|
||||||
|
href={social.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<social.icon size={20} />
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Contact Form */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="glass-card p-8 rounded-2xl"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-6">Send Message</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="Your Name"
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm"
|
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Your name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
htmlFor="email"
|
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="you@example.com"
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm"
|
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="subject" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Subject
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="What's this about?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
htmlFor="message"
|
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Message
|
Message
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="message"
|
|
||||||
id="message"
|
id="message"
|
||||||
placeholder="Your Message..."
|
name="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
rows={5}
|
rows={5}
|
||||||
required
|
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
|
||||||
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 rounded-lg shadow-sm "
|
placeholder="Tell me more about your project..."
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="privacy"
|
|
||||||
id="privacy"
|
|
||||||
required
|
|
||||||
className="h-5 w-5 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="privacy" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
I accept the{" "}
|
|
||||||
<Link
|
|
||||||
href="/privacy-policy"
|
|
||||||
className="text-blue-800 transition-underline"
|
|
||||||
>
|
|
||||||
privacy policy
|
|
||||||
</Link>.
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<motion.button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3 px-6 text-lg font-semibold text-white bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl hover:from-blue-600 hover:to-purple-600"
|
disabled={isSubmitting}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
Send Message
|
{isSubmitting ? (
|
||||||
</button>
|
<>
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||||
|
<span>Sending...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={20} />
|
||||||
|
<span>Send Message</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
</form>
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Contact;
|
||||||
|
|||||||
@@ -1,88 +1,165 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function Footer() {
|
import { useState, useEffect } from 'react';
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
import { motion } from 'framer-motion';
|
||||||
|
import { Github, Linkedin, Mail, Heart } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const [currentYear, setCurrentYear] = useState(2024);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setCurrentYear(new Date().getFullYear());
|
||||||
setIsVisible(true);
|
setMounted(true);
|
||||||
}, 450); // Delay to start the animation
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
const socialLinks = [
|
||||||
const element = document.getElementById(id);
|
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||||
if (element) {
|
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||||
element.scrollIntoView({ behavior: "smooth" });
|
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ name: 'Home', href: '/' },
|
||||||
|
{ name: 'Projects', href: '/projects' },
|
||||||
|
{ name: 'About', href: '#about' },
|
||||||
|
{ name: 'Contact', href: '#contact' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer className="relative py-16 px-4 border-t border-gray-800">
|
||||||
className={`sticky- bottom-0 p-3 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl text-center text-gray-800 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/50 to-transparent"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-7xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
>
|
>
|
||||||
<div className={`flex flex-col md:flex-row items-center justify-between`}>
|
<Link href="/" className="text-3xl font-bold gradient-text mb-4 inline-block">
|
||||||
<div className={`flex-col items-center`}>
|
Dennis Konkol
|
||||||
<h1 className="md:text-xl font-bold">Thank You for Visiting</h1>
|
</Link>
|
||||||
<p className="md:mt-1 text-lg">
|
<p className="text-gray-400 mb-6 max-w-md leading-relaxed">
|
||||||
Connect with me on social platforms:
|
A passionate software engineer and student based in Osnabrück, Germany.
|
||||||
|
Creating innovative solutions that make a difference in the digital world.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center items-center space-x-4 mt-4">
|
|
||||||
<Link
|
<div className="flex space-x-4">
|
||||||
aria-label={"Dennis Github"}
|
{socialLinks.map((social) => (
|
||||||
href="https://github.com/Denshooter"
|
<motion.a
|
||||||
|
key={social.label}
|
||||||
|
href={social.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-all duration-200"
|
||||||
>
|
>
|
||||||
<svg
|
<social.icon size={20} />
|
||||||
className="w-10 h-10"
|
</motion.a>
|
||||||
fill="currentColor"
|
))}
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.757-1.333-1.757-1.09-.746.083-.73.083-.73 1.205.084 1.84 1.237 1.84 1.237 1.07 1.835 2.807 1.305 3.492.997.108-.774.42-1.305.763-1.605-2.665-.305-5.466-1.332-5.466-5.93 0-1.31.467-2.38 1.235-3.22-.123-.303-.535-1.527.117-3.18 0 0 1.008-.322 3.3 1.23.957-.266 1.98-.4 3-.405 1.02.005 2.043.14 3 .405 2.29-1.552 3.297-1.23 3.297-1.23.653 1.653.24 2.877.118 3.18.77.84 1.233 1.91 1.233 3.22 0 4.61-2.803 5.62-5.475 5.92.43.37.823 1.1.823 2.22v3.293c0 .32.218.694.825.577C20.565 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z" />
|
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
|
||||||
</svg>
|
<ul className="space-y-2">
|
||||||
</Link>
|
{quickLinks.map((link) => (
|
||||||
|
<li key={link.name}>
|
||||||
<Link
|
<Link
|
||||||
aria-label={"Dennis Linked In"}
|
href={link.href}
|
||||||
href="https://linkedin.com/in/dkonkol"
|
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
<svg
|
{link.name}
|
||||||
className="w-10 h-10"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5v-14c0-2.76-2.24-5-5-5zm-11 19h-3v-10h3v10zm-1.5-11.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5 11.5h-3v-5.5c0-1.38-1.12-2.5-2.5-2.5s-2.5 1.12-2.5 2.5v5.5h-3v-10h3v1.5c.83-1.17 2.17-1.5 3.5-1.5 2.48 0 4.5 2.02 4.5 4.5v5.5z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
))}
|
||||||
<div className="mt-4 md:absolute md:left-1/2 md:transform md:-translate-x-1/2">
|
</ul>
|
||||||
<button
|
</motion.div>
|
||||||
onClick={() => scrollToSection("about")}
|
|
||||||
className="p-4 mt-4 md:px-4 md:my-6 text-white bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl hover:from-blue-600 hover:to-purple-600 transition"
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
Back to Top
|
<h3 className="text-lg font-semibold text-white mb-4">Legal</h3>
|
||||||
</button>
|
<ul className="space-y-2">
|
||||||
</div>
|
<li>
|
||||||
<div className="flex-col">
|
<Link
|
||||||
<div className="mt-4">
|
href="/legal-notice"
|
||||||
|
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href="/privacy-policy"
|
href="/privacy-policy"
|
||||||
className="text-blue-800 transition-underline"
|
className="text-gray-400 hover:text-white transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
</li>
|
||||||
href="/legal-notice"
|
</ul>
|
||||||
className="ml-4 text-blue-800 transition-underline"
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
Legal Notice
|
<h3 className="text-lg font-semibold text-white mb-4">Contact</h3>
|
||||||
</Link>
|
<div className="space-y-2 text-gray-400">
|
||||||
|
<p>Osnabrück, Germany</p>
|
||||||
|
<p>contact@dki.one</p>
|
||||||
|
<p>+49 123 456 789</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="md:mt-4">© Dennis Konkol 2025</p>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="pt-8 border-t border-gray-800 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
© {currentYear} Dennis Konkol. All rights reserved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 text-gray-400">
|
||||||
|
<span>Made with</span>
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<Heart size={16} className="text-red-500" />
|
||||||
|
</motion.div>
|
||||||
|
<span>in Germany</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
|
|||||||
@@ -1,138 +1,175 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import Link from "next/link";
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Menu, X, Github, Linkedin, Mail } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function Header() {
|
const Header = () => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setMounted(true);
|
||||||
setIsVisible(true);
|
|
||||||
}, 50); // Delay to start the animation after Projects
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
const toggleSidebar = () => {
|
setScrolled(window.scrollY > 50);
|
||||||
setIsSidebarOpen(!isSidebarOpen);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
window.addEventListener('scroll', handleScroll);
|
||||||
const element = document.getElementById(id);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
if (element) {
|
}, []);
|
||||||
element.scrollIntoView({ behavior: "smooth" });
|
|
||||||
} else {
|
const navItems = [
|
||||||
/*go to main page and scroll*/
|
{ name: 'Home', href: '/' },
|
||||||
window.location.href = `/#${id}`;
|
{ name: 'Projects', href: '/projects' },
|
||||||
|
{ name: 'About', href: '#about' },
|
||||||
|
{ name: 'Contact', href: '#contact' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ icon: Github, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||||
|
{ icon: Linkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||||
|
{ icon: Mail, href: 'mailto:contact@dki.one', label: 'Email' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 ${isVisible ? "animate-fly-in" : "opacity-0"}`}>
|
<>
|
||||||
|
<div className="particles">
|
||||||
|
{[...Array(20)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-4 left-4 right-4 p-4 bg-white/45 text-gray-700 backdrop-blur-md shadow-xl rounded-2xl z-50 ${isSidebarOpen ? "transform -translate-y-full" : ""}`}
|
key={i}
|
||||||
|
className="particle"
|
||||||
|
style={{
|
||||||
|
left: `${(i * 5.5) % 100}%`,
|
||||||
|
animationDelay: `${(i * 0.8) % 20}s`,
|
||||||
|
animationDuration: `${20 + (i * 0.4) % 10}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.header
|
||||||
|
initial={{ y: -100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
|
scrolled ? 'glass' : 'bg-transparent'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<header className="w-full">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<nav className="flex flex-row items-center px-4">
|
<div className="flex justify-between items-center h-16">
|
||||||
<Link href="/" className="flex justify-start">
|
<motion.div
|
||||||
<h1 className="text-xl md:text-2xl">Dennis Konkol</h1>
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Link href="/" className="text-2xl font-bold gradient-text">
|
||||||
|
DK
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-grow"></div>
|
</motion.div>
|
||||||
<button
|
|
||||||
className="text-gray-700 hover:text-gray-900 md:hidden"
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
onClick={toggleSidebar}
|
{navItems.map((item) => (
|
||||||
aria-label={"Open menu"}
|
<motion.div
|
||||||
|
key={item.name}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<svg
|
<Link
|
||||||
className="w-6 h-6"
|
href={item.href}
|
||||||
fill="none"
|
className="text-gray-300 hover:text-white transition-colors duration-200 font-medium relative group"
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
{item.name}
|
||||||
strokeLinecap="round"
|
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-300 group-hover:w-full"></span>
|
||||||
strokeLinejoin="round"
|
</Link>
|
||||||
strokeWidth="2"
|
</motion.div>
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
))}
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div className="hidden md:flex space-x-4 md:space-x-6">
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("about")}
|
|
||||||
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("projects")}
|
|
||||||
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("contact")}
|
|
||||||
className="relative pl-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
|
||||||
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
|
{socialLinks.map((social) => (
|
||||||
|
<motion.a
|
||||||
|
key={social.label}
|
||||||
|
href={social.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1, y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<social.icon size={20} />
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<motion.button
|
||||||
className={`fixed inset-0 bg-black bg-opacity-50 transition-opacity ${isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={toggleSidebar}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
></div>
|
className="md:hidden p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<AnimatePresence>
|
||||||
className={`fixed z-10 top-0 right-0 h-full bg-white w-1/3 transform transition-transform flex flex-col ${isSidebarOpen ? "translate-x-0" : "translate-x-full"}`}
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="md:hidden glass"
|
||||||
>
|
>
|
||||||
<button
|
<div className="px-4 py-6 space-y-4">
|
||||||
aria-label={"Close menu"}
|
{navItems.map((item) => (
|
||||||
className="absolute top-4 right-4 text-gray-700 hover:text-gray-900"
|
<motion.div
|
||||||
onClick={toggleSidebar}
|
key={item.name}
|
||||||
|
initial={{ x: -20, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: navItems.indexOf(item) * 0.1 }}
|
||||||
>
|
>
|
||||||
<svg
|
<Link
|
||||||
className="w-6 h-6"
|
href={item.href}
|
||||||
fill="none"
|
onClick={() => setIsOpen(false)}
|
||||||
stroke="currentColor"
|
className="block text-gray-300 hover:text-white transition-colors duration-200 font-medium py-2"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
{item.name}
|
||||||
strokeLinecap="round"
|
</Link>
|
||||||
strokeLinejoin="round"
|
</motion.div>
|
||||||
strokeWidth="2"
|
))}
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
<div className="pt-4 border-t border-gray-700">
|
||||||
</svg>
|
<div className="flex space-x-4">
|
||||||
</button>
|
{socialLinks.map((social) => (
|
||||||
<div className="pt-8 space-y-4 flex-grow">
|
<motion.a
|
||||||
<button
|
key={social.label}
|
||||||
onClick={() => scrollToSection("about")}
|
href={social.href}
|
||||||
className="w-full px-4 py-2 pt-8 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
|
||||||
>
|
>
|
||||||
About
|
<social.icon size={20} />
|
||||||
</button>
|
</motion.a>
|
||||||
<button
|
))}
|
||||||
onClick={() => scrollToSection("projects")}
|
|
||||||
className="w-full px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("contact")}
|
|
||||||
className="w-full px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-xs text-gray-500 p-4">© 2025 Dennis</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.header>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|||||||
@@ -1,53 +1,235 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
"use client";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
export default function Hero() {
|
import { useState, useEffect } from 'react';
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowDown, Code, Zap, Rocket } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
const Hero = () => {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setMounted(true);
|
||||||
setIsVisible(true);
|
|
||||||
}, 150); // Delay to start the animation
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ icon: Code, text: 'Full-Stack Development' },
|
||||||
|
{ icon: Zap, text: 'Modern Technologies' },
|
||||||
|
{ icon: Rocket, text: 'Innovative Solutions' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
id="about"
|
{/* Animated Background */}
|
||||||
className={`flex flex-col md:flex-row items-center justify-center pt-16 pb-16 px-6 text-gray-700 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
<div className="absolute inset-0 animated-bg"></div>
|
||||||
>
|
|
||||||
<div
|
{/* Floating Elements */}
|
||||||
className="flex flex-col items-center p-8 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl max-w-lg text-center">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900">
|
<motion.div
|
||||||
Hi, I’m Dennis
|
className="absolute top-20 left-20 w-32 h-32 bg-blue-500/10 rounded-full blur-xl"
|
||||||
</h1>
|
initial={{ scale: 1, opacity: 0.3 }}
|
||||||
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-gray-700">
|
animate={{
|
||||||
Student & Software Engineer
|
scale: [1, 1.2, 1],
|
||||||
</h2>
|
opacity: [0.3, 0.6, 0.3],
|
||||||
<h3 className="mt-1 text-lg md:text-xl text-gray-600">
|
}}
|
||||||
Based in Osnabrück, Germany
|
transition={{
|
||||||
</h3>
|
duration: 4,
|
||||||
<p className="mt-6 text-gray-800 text-lg leading-relaxed">
|
repeat: Infinity,
|
||||||
Passionate about technology, coding, and solving real-world problems.
|
ease: "easeInOut",
|
||||||
I enjoy building innovative solutions and continuously expanding my
|
}}
|
||||||
knowledge.
|
/>
|
||||||
</p>
|
<motion.div
|
||||||
<p className="mt-4 text-gray-700 text-base">
|
className="absolute top-40 right-32 w-24 h-24 bg-purple-500/10 rounded-full blur-xl"
|
||||||
Currently working on exciting projects that merge creativity with
|
initial={{ scale: 1.2, opacity: 0.6 }}
|
||||||
functionality. Always eager to learn and collaborate!
|
animate={{
|
||||||
</p>
|
scale: [1.2, 1, 1.2],
|
||||||
</div>
|
opacity: [0.6, 0.3, 0.6],
|
||||||
<div className="flex mt-8 md:mt-0 md:ml-12">
|
}}
|
||||||
<Image
|
transition={{
|
||||||
src="/images/me.jpg"
|
duration: 5,
|
||||||
alt="Image of Dennis"
|
repeat: Infinity,
|
||||||
width={400}
|
ease: "easeInOut",
|
||||||
height={400}
|
}}
|
||||||
className="rounded-2xl shadow-lg shadow-gray-700 object-cover"
|
/>
|
||||||
loading="lazy" // Lazy Loading
|
<motion.div
|
||||||
style={{width: "auto", height: "400px"}}
|
className="absolute bottom-32 left-1/3 w-40 h-40 bg-cyan-500/10 rounded-full blur-xl"
|
||||||
sizes="(max-width: 640px) 640px, 828px" // Definiere, welche Bildgröße bei welcher Bildschirmgröße geladen wird
|
initial={{ scale: 1, opacity: 0.4 }}
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.3, 1],
|
||||||
|
opacity: [0.4, 0.7, 0.4],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 6,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
|
||||||
|
{/* Profile Image */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8, rotateY: -15 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, rotateY: 0 }}
|
||||||
|
transition={{ duration: 1, delay: 0.1, ease: "easeOut" }}
|
||||||
|
className="mb-8 flex justify-center"
|
||||||
|
>
|
||||||
|
<div className="relative group">
|
||||||
|
{/* Glowing border effect */}
|
||||||
|
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600 rounded-full blur opacity-75 group-hover:opacity-100 transition duration-1000 group-hover:duration-200 animate-pulse"></div>
|
||||||
|
|
||||||
|
{/* Profile image container */}
|
||||||
|
<div className="relative bg-gray-900 rounded-full p-1">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05, rotateY: 5 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="relative w-40 h-40 md:w-48 md:h-48 lg:w-56 lg:h-56 rounded-full overflow-hidden border-4 border-gray-800"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/me.jpg"
|
||||||
|
alt="Dennis Konkol - Software Engineer"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover overlay effect */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Floating tech badges around the image */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.5 }}
|
||||||
|
className="absolute -top-3 -right-3 w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<Code className="w-5 h-5 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.7 }}
|
||||||
|
className="absolute -bottom-3 -left-3 w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<Zap className="w-5 h-5 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.9 }}
|
||||||
|
className="absolute -top-3 -left-3 w-10 h-10 bg-cyan-500 rounded-full flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<Rocket className="w-5 h-5 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Main Title */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
|
className="text-5xl md:text-7xl font-bold mb-6"
|
||||||
|
>
|
||||||
|
<span className="gradient-text">Dennis Konkol</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 1.0 }}
|
||||||
|
className="text-xl md:text-2xl text-gray-300 mb-8 max-w-2xl mx-auto"
|
||||||
|
>
|
||||||
|
Student & Software Engineer based in Osnabrück, Germany
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 1.2 }}
|
||||||
|
className="text-lg text-gray-400 mb-12 max-w-3xl mx-auto leading-relaxed"
|
||||||
|
>
|
||||||
|
Passionate about technology, coding, and solving real-world problems.
|
||||||
|
I create innovative solutions that make a difference.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 1.4 }}
|
||||||
|
className="flex flex-wrap justify-center gap-6 mb-12"
|
||||||
|
>
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={feature.text}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 1.6 + index * 0.1 }}
|
||||||
|
whileHover={{ scale: 1.05, y: -5 }}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded-full glass-card"
|
||||||
|
>
|
||||||
|
<feature.icon className="w-5 h-5 text-blue-400" />
|
||||||
|
<span className="text-gray-300 font-medium">{feature.text}</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 1.8 }}
|
||||||
|
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||||
|
>
|
||||||
|
<motion.a
|
||||||
|
href="#projects"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="btn-primary px-8 py-4 text-lg font-semibold"
|
||||||
|
>
|
||||||
|
View My Work
|
||||||
|
</motion.a>
|
||||||
|
|
||||||
|
<motion.a
|
||||||
|
href="#contact"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="px-8 py-4 text-lg font-semibold border-2 border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 rounded-lg transition-all duration-200"
|
||||||
|
>
|
||||||
|
Get In Touch
|
||||||
|
</motion.a>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Scroll Indicator */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 1.5 }}
|
||||||
|
className="mt-16 text-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
className="flex flex-col items-center text-gray-400"
|
||||||
|
>
|
||||||
|
<span className="text-sm mb-2">Scroll Down</span>
|
||||||
|
<ArrowDown className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
|
|||||||
@@ -1,91 +1,189 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
"use client";
|
||||||
import Link from "next/link";
|
|
||||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ExternalLink, Github, Calendar } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
slug: string;
|
id: number;
|
||||||
id: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
feature_image: string;
|
description: string;
|
||||||
visibility: string;
|
content: string;
|
||||||
published_at: string;
|
tags: string[];
|
||||||
updated_at: string;
|
featured: boolean;
|
||||||
html: string;
|
category: string;
|
||||||
reading_time: number;
|
date: string;
|
||||||
meta_description: string;
|
github?: string;
|
||||||
|
live?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectsData {
|
const Projects = () => {
|
||||||
posts: Project[];
|
const [mounted, setMounted] = useState(false);
|
||||||
}
|
|
||||||
|
|
||||||
export default function Projects() {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProjects = async () => {
|
setMounted(true);
|
||||||
try {
|
|
||||||
const response = await fetch("/api/fetchAllProjects");
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Failed to fetch projects: ${response.statusText}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const projectsData = (await response.json()) as ProjectsData;
|
|
||||||
if (!projectsData || !projectsData.posts) {
|
|
||||||
console.error("Invalid projects data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setProjects(projectsData.posts);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(true);
|
|
||||||
}, 250); // Delay to start the animation after Hero
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch projects:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchProjects();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
||||||
|
// Load projects from API
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects?featured=true&published=true&limit=6');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch projects from API');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="projects" className="py-20 px-4 relative">
|
||||||
id="projects"
|
<div className="max-w-7xl mx-auto">
|
||||||
className={`p-10 ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="text-center mb-16"
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl font-bold text-center text-gray-900">Projects</h2>
|
<h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||||
<div className="mt-6">
|
Featured Projects
|
||||||
{isVisible && (
|
</h2>
|
||||||
<ResponsiveMasonry
|
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||||
columnsCountBreakPoints={{ 350: 1, 750: 2, 900: 3 }}
|
Here are some of my recent projects that showcase my skills and passion for creating innovative solutions.
|
||||||
>
|
</p>
|
||||||
<Masonry gutter="16px">
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{projects.map((project, index) => (
|
{projects.map((project, index) => (
|
||||||
<Link
|
<motion.div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
href={{
|
initial={{ opacity: 0, y: 30 }}
|
||||||
pathname: `/projects/${project.slug}`,
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
query: { project: JSON.stringify(project) },
|
viewport={{ once: true }}
|
||||||
}}
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
className="cursor-pointer"
|
whileHover={{ y: -10 }}
|
||||||
|
className={`group relative overflow-hidden rounded-2xl glass-card card-hover ${
|
||||||
|
project.featured ? 'ring-2 ring-blue-500/50' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="relative h-48 overflow-hidden">
|
||||||
className="project-card"
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20" />
|
||||||
style={{ animationDelay: `${index * 0.1}s` }}
|
<div className="absolute inset-0 bg-gray-800/50 flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<span className="text-2xl font-bold text-white">
|
||||||
|
{project.title.split(' ').map(word => word[0]).join('').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-400 text-center leading-tight">
|
||||||
|
{project.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.featured && (
|
||||||
|
<div className="absolute top-4 right-4 px-3 py-1 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-xs font-semibold rounded-full">
|
||||||
|
Featured
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center space-x-4">
|
||||||
|
{project.github && project.github.trim() !== '' && project.github !== '#' && (
|
||||||
|
<motion.a
|
||||||
|
href={project.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-gray-800/80 rounded-lg text-white hover:bg-gray-700/80 transition-colors"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-gray-800">
|
<Github size={20} />
|
||||||
|
</motion.a>
|
||||||
|
)}
|
||||||
|
{project.live && project.live.trim() !== '' && project.live !== '#' && (
|
||||||
|
<motion.a
|
||||||
|
href={project.live}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-blue-600/80 rounded-lg text-white hover:bg-blue-500/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={20} />
|
||||||
|
</motion.a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-gray-500">
|
<div className="flex items-center space-x-2 text-gray-400">
|
||||||
{project.meta_description}
|
<Calendar size={16} />
|
||||||
</p>
|
<span className="text-sm">{project.date}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-300 mb-4 leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</Masonry>
|
</div>
|
||||||
</ResponsiveMasonry>
|
|
||||||
)}
|
<Link
|
||||||
|
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
||||||
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<span>View Project</span>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
|
className="text-center mt-12"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/projects"
|
||||||
|
className="btn-primary px-8 py-4 text-lg font-semibold inline-flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<span>View All Projects</span>
|
||||||
|
<ExternalLink size={20} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Projects;
|
||||||
|
|||||||
434
app/globals.css
434
app/globals.css
@@ -1,216 +1,364 @@
|
|||||||
/* app/globals.css */
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #fafafa;
|
||||||
|
--card: #0f0f0f;
|
||||||
|
--card-foreground: #fafafa;
|
||||||
|
--popover: #0f0f0f;
|
||||||
|
--popover-foreground: #fafafa;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-foreground: #f8fafc;
|
||||||
|
--secondary: #1e293b;
|
||||||
|
--secondary-foreground: #f1f5f9;
|
||||||
|
--muted: #1e293b;
|
||||||
|
--muted-foreground: #64748b;
|
||||||
|
--accent: #1e293b;
|
||||||
|
--accent-foreground: #f1f5f9;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #f8fafc;
|
||||||
|
--border: #1e293b;
|
||||||
|
--input: #1e293b;
|
||||||
|
--ring: #3b82f6;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Effects */
|
||||||
|
.glass {
|
||||||
|
background: rgba(15, 15, 15, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(15, 15, 15, 0.6);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient Text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text-blue {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated Background */
|
||||||
|
.animated-bg {
|
||||||
|
background: linear-gradient(-45deg, #0f0f0f, #1a1a1a, #0f0f0f, #1a1a1a);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientShift 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Animation */
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-20px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow Effects */
|
||||||
|
.glow {
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-hover:hover {
|
||||||
|
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Particle Background */
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(59, 130, 246, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: particleFloat 20s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes particleFloat {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100vh) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-100vh) rotate(360deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Styles */
|
||||||
|
.markdown {
|
||||||
|
color: #ffffff !important;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h1 {
|
.markdown h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
margin-top: 1.5rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #333;
|
color: #ffffff !important;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h2 {
|
.markdown h2 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 1rem;
|
||||||
color: #444;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h3 {
|
.markdown h3 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
margin-top: 1rem;
|
margin-top: 1.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.75rem;
|
||||||
color: #555;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown p {
|
.markdown p {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.75rem;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
color: #666;
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown img {
|
.markdown img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin-top: 1rem;
|
margin: 1.5rem 0;
|
||||||
margin-bottom: 1rem;
|
border-radius: 12px;
|
||||||
border-radius: 8px;
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown ul {
|
.markdown img:hover {
|
||||||
list-style-type: disc;
|
transform: scale(1.02);
|
||||||
margin-left: 1.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown ol {
|
.markdown ul, .markdown ol {
|
||||||
list-style-type: decimal;
|
margin: 1rem 0;
|
||||||
margin-left: 1.5rem;
|
padding-left: 2rem;
|
||||||
margin-top: 0.5rem;
|
}
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
|
.markdown li {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown blockquote {
|
.markdown blockquote {
|
||||||
border-left: 4px solid #ccc;
|
border-left: 4px solid #3b82f6;
|
||||||
color: #777;
|
background: rgba(59, 130, 246, 0.1);
|
||||||
margin-top: 1rem;
|
padding: 1rem 1.5rem;
|
||||||
margin-bottom: 1rem;
|
margin: 1.5rem 0;
|
||||||
|
border-radius: 8px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
background-color: #f9f9f9;
|
color: #e5e7eb !important;
|
||||||
padding: 1rem;
|
}
|
||||||
|
|
||||||
|
.markdown code {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6 !important;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-radiant-animated {
|
.markdown pre {
|
||||||
background: radial-gradient(circle at 20% 20%, #ff8185, transparent 25%),
|
background: #0f0f0f;
|
||||||
radial-gradient(circle at 80% 80%, #ffaa91, transparent 25%),
|
border: 1px solid #1e293b;
|
||||||
radial-gradient(circle at 50% 50%, #fb7fd9, transparent 25%),
|
border-radius: 8px;
|
||||||
radial-gradient(circle at 30% 70%, #9b6fff, transparent 25%),
|
padding: 1rem;
|
||||||
radial-gradient(circle at 70% 30%, #ff8edf, transparent 25%);
|
overflow-x: auto;
|
||||||
background-size: 200% 200%;
|
margin: 1.5rem 0;
|
||||||
animation: backgroundAnimation 60s ease infinite alternate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-radiant {
|
.markdown pre code {
|
||||||
background: radial-gradient(circle at 20% 20%, #ff8185, transparent 25%),
|
background: none;
|
||||||
radial-gradient(circle at 80% 80%, #ffaa91, transparent 25%),
|
color: #ffffff !important;
|
||||||
radial-gradient(circle at 50% 50%, #fb7fd9, transparent 25%),
|
padding: 0;
|
||||||
radial-gradient(circle at 30% 70%, #9b6fff, transparent 25%),
|
|
||||||
radial-gradient(circle at 70% 30%, #ff8edf, transparent 25%);
|
|
||||||
background-size: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes backgroundAnimation {
|
.markdown a {
|
||||||
0% {
|
color: #3b82f6 !important;
|
||||||
background-position: 0 0;
|
text-decoration: underline;
|
||||||
}
|
transition: color 0.2s ease;
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-h-screen {
|
.markdown a:hover {
|
||||||
min-height: 100vh;
|
color: #1d4ed8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex {
|
.markdown strong {
|
||||||
display: flex;
|
color: #ffffff !important;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-col {
|
.markdown em {
|
||||||
flex-direction: column;
|
color: #e5e7eb !important;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-grow {
|
/* Button Styles */
|
||||||
flex-grow: 1;
|
.btn-primary {
|
||||||
}
|
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
|
color: white;
|
||||||
.react-cookie-consent .content-wrapper {
|
padding: 0.75rem 1.5rem;
|
||||||
flex: 1;
|
border-radius: 8px;
|
||||||
margin-right: 1rem;
|
font-weight: 500;
|
||||||
}
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
.react-cookie-consent .button-wrapper {
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.react-cookie-consent .button-wrapper {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition-underline {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-underline::after {
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
top: 0;
|
||||||
bottom: -2px;
|
left: -100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 100%;
|
||||||
background-color: currentColor;
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Hover Effects */
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Animation */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade In Animation */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: translateY(30px);
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
.transition-underline:hover::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
opacity: 1 !important;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-out {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flyIn {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fly-in {
|
.fade-in-up {
|
||||||
animation: flyIn 1s ease-in-out;
|
animation: fadeInUp 0.6s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeOut {
|
/* Responsive Design */
|
||||||
0% {
|
@media (max-width: 768px) {
|
||||||
opacity: 1;
|
.markdown h1 {
|
||||||
}
|
font-size: 2rem;
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-out {
|
.markdown h2 {
|
||||||
animation: fadeOut 3s forwards;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card {
|
.markdown h3 {
|
||||||
display: flex;
|
font-size: 1.25rem;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: rgba(255, 255, 255, 0.45);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// app/layout.tsx
|
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
import {Roboto} from "next/font/google";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
//import ClientCookieConsentBanner from "./components/ClientCookieConsentBanner";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
|
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||||
|
import { PerformanceDashboard } from "@/components/PerformanceDashboard";
|
||||||
|
|
||||||
const roboto = Roboto({
|
const inter = Inter({
|
||||||
variable: "--font-roboto",
|
variable: "--font-inter",
|
||||||
weight: "400",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,15 +19,18 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script defer src="https://analytics.dk0.dev/script.js" data-website-id="b3665829-927a-4ada-b9bb-fcf24171061e"></script>
|
||||||
defer
|
|
||||||
src="https://umami.denshooter.de/script.js"
|
|
||||||
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
|
|
||||||
></script>
|
|
||||||
<meta charSet="utf-8"/>
|
<meta charSet="utf-8"/>
|
||||||
<title>Dennis Konkol's Portfolio</title>
|
<title>Dennis Konkol's Portfolio</title>
|
||||||
</head>
|
</head>
|
||||||
<body className={roboto.variable}>{children}</body>
|
<body className={inter.variable}>
|
||||||
|
<AnalyticsProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
{children}
|
||||||
|
<PerformanceDashboard />
|
||||||
|
</ToastProvider>
|
||||||
|
</AnalyticsProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer_Back from "../components/Footer_Back";
|
import Footer from "../components/Footer";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function LegalNotice() {
|
export default function LegalNotice() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-radiant-animated">
|
<div className="min-h-screen animated-bg">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="h-10"></div>
|
<main className="max-w-4xl mx-auto px-4 py-20">
|
||||||
<main className="flex-grow p-10">
|
<motion.div
|
||||||
<h1 className="text-3xl font-bold">Impressum</h1>
|
initial={{ opacity: 0, y: 30 }}
|
||||||
<p className="mt-4">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<strong>
|
transition={{ duration: 0.8 }}
|
||||||
Verantwortlicher für die Inhalte dieser Website (auch Redaktionell):{" "}
|
className="mb-8"
|
||||||
<br />
|
>
|
||||||
</strong>
|
<Link
|
||||||
<strong>Name:</strong> Dennis Konkol
|
href="/"
|
||||||
<br />
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück,
|
>
|
||||||
Deutschland
|
<ArrowLeft size={20} />
|
||||||
<br />
|
<span>Back to Home</span>
|
||||||
<strong>E-Mail:</strong>{" "}
|
|
||||||
<Link href={"mailto:info@dki.one"} className="transition-underline">
|
|
||||||
info@dki.one
|
|
||||||
</Link>{" "}
|
|
||||||
<br />
|
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link href={"https://www.dki.one"} className="transition-underline">
|
|
||||||
{" "}
|
|
||||||
dki.one{" "}
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Haftung für Links</h2>
|
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||||
<p className="mt-2">
|
Impressum
|
||||||
Meine Website enthält Links auf externe Websites. Ich habe keinen
|
</h1>
|
||||||
Einfluss auf die Inhalte dieser Websites und kann daher keine Gewähr
|
</motion.div>
|
||||||
übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
|
|
||||||
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe
|
|
||||||
ich die verlinkten Seiten zum Zeitpunkt der Verlinkung auf mögliche
|
|
||||||
Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich
|
|
||||||
derartige Links umgehend entfernen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Urheberrecht</h2>
|
<motion.div
|
||||||
<p className="mt-2">
|
initial={{ opacity: 0, y: 30 }}
|
||||||
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs,
|
animate={{ opacity: 1, y: 0 }}
|
||||||
stehen unter Urheberrechtsschutz. Jegliche Nutzung ohne vorherige
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
schriftliche Zustimmung des Urhebers ist verboten.
|
className="glass-card p-8 rounded-2xl space-y-6"
|
||||||
</p>
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||||
|
Verantwortlicher für die Inhalte dieser Website
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 text-gray-300">
|
||||||
|
<p><strong>Name:</strong> Dennis Konkol</p>
|
||||||
|
<p><strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland</p>
|
||||||
|
<p><strong>E-Mail:</strong> <Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">info@dki.one</Link></p>
|
||||||
|
<p><strong>Website:</strong> <Link href="https://www.dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">dki.one</Link></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Gewährleistung</h2>
|
<div>
|
||||||
<p className="mt-2">
|
<h2 className="text-2xl font-semibold text-white mb-4">Haftung für Links</h2>
|
||||||
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als
|
<p className="text-gray-300 leading-relaxed">
|
||||||
Diensteanbieter kann ich keine Gewähr übernehmen für Schäden, die
|
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser Websites
|
||||||
entstehen können, durch den Zugriff oder die Nutzung dieser Website.
|
und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der Betreiber oder
|
||||||
|
Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum Zeitpunkt der Verlinkung
|
||||||
|
auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold mt-6">Letzte Aktualisierung: 12.02.2025</p>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">Urheberrecht</h2>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter Urheberrechtsschutz.
|
||||||
|
Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist verboten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">Gewährleistung</h2>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine
|
||||||
|
Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser Website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-700">
|
||||||
|
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</main>
|
</main>
|
||||||
<Footer_Back />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// app/page.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
@@ -10,7 +9,7 @@ import Script from "next/script";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-radiant-animated">
|
<div className="min-h-screen animated-bg">
|
||||||
<Script
|
<Script
|
||||||
id={"structured-data"}
|
id={"structured-data"}
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
@@ -34,13 +33,12 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Header />
|
<Header />
|
||||||
<div className="h-10"></div>
|
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Projects />
|
<Projects />
|
||||||
<Contact />
|
<Contact />
|
||||||
<Footer />
|
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer_Back from "../components/Footer_Back";
|
import Footer from "../components/Footer";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVisible(true);
|
|
||||||
}, 350);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen animated-bg">
|
||||||
className={`min-h-screen flex flex-col bg-radiant-animated ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
|
||||||
>
|
|
||||||
<Header />
|
<Header />
|
||||||
<div className="h-10"></div>
|
<main className="max-w-4xl mx-auto px-4 py-20">
|
||||||
<main className="flex-grow p-10">
|
<motion.div
|
||||||
<h1 className="text-3xl font-bold">Datenschutzerklärung</h1>
|
initial={{ opacity: 0, y: 30 }}
|
||||||
<p className="mt-4">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser
|
transition={{ duration: 0.8 }}
|
||||||
Datenschutzerklärung informiere ich Sie über die Verarbeitung
|
className="mb-8"
|
||||||
personenbezogener Daten im Rahmen meines Internet-Angebots.
|
>
|
||||||
|
<motion.a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
<span>Back to Home</span>
|
||||||
|
</motion.a>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
|
className="glass-card p-8 rounded-2xl space-y-6 text-white"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
|
||||||
|
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||||
Verantwortlicher für die Datenverarbeitung
|
Verantwortlicher für die Datenverarbeitung
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2">
|
<div className="space-y-2 text-gray-300">
|
||||||
<strong>Name:</strong> Dennis Konkol <br />
|
<p><strong>Name:</strong> Dennis Konkol</p>
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück,
|
<p><strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland</p>
|
||||||
Deutschland <br />
|
<p><strong>E-Mail:</strong> <Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dki.one">info@dki.one</Link></p>
|
||||||
<strong>E-Mail:</strong>{" "}
|
<p><strong>Website:</strong> <Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dki.one">dki.one</Link></p>
|
||||||
<Link className="transition-underline" href={"mailto:info@dki.one"}>
|
</div>
|
||||||
info@dki.one
|
<p className="text-gray-300 leading-relaxed mt-4">
|
||||||
</Link>{" "}
|
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.
|
||||||
<br />
|
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link className="transition-underline" href={"https://www.dki.one"}>
|
|
||||||
{" "}
|
|
||||||
dki.one{" "}
|
|
||||||
</Link>{" "}
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener
|
|
||||||
Daten durch den oben genannten Verantwortlichen.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
<h2 className="text-2xl font-semibold mt-6">
|
||||||
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
||||||
</h2>
|
</h2>
|
||||||
@@ -221,9 +230,12 @@ export default function PrivacyPolicy() {
|
|||||||
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
|
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
|
||||||
auf meiner Website.
|
auf meiner Website.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-6 font-bold">Letzte Aktualisierung: 12.02.2025</p>
|
<div className="pt-4 border-t border-gray-700">
|
||||||
|
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</main>
|
</main>
|
||||||
<Footer_Back />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,185 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { motion } from 'framer-motion';
|
||||||
useRouter,
|
import { ExternalLink, Calendar, Tag, ArrowLeft, Github as GithubIcon } from 'lucide-react';
|
||||||
useSearchParams,
|
import Link from 'next/link';
|
||||||
useParams,
|
import { useParams } from 'next/navigation';
|
||||||
usePathname,
|
import { useState, useEffect } from 'react';
|
||||||
} from "next/navigation";
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import Footer_Back from "@/app/components/Footer_Back";
|
|
||||||
import Header from "@/app/components/Header";
|
|
||||||
import Image from "next/image";
|
|
||||||
import "@/app/styles/ghostContent.css"; // Import the global styles
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
slug: string;
|
id: number;
|
||||||
id: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
feature_image: string;
|
description: string;
|
||||||
visibility: string;
|
content: string;
|
||||||
published_at: string;
|
tags: string[];
|
||||||
updated_at: string;
|
featured: boolean;
|
||||||
html: string;
|
category: string;
|
||||||
reading_time: number;
|
date: string;
|
||||||
meta_description: string;
|
github?: string;
|
||||||
|
live?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectDetails = () => {
|
const ProjectDetail = () => {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const pathname = usePathname();
|
const slug = params.slug as string;
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
|
// Load project from API by slug
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
const loadProject = async () => {
|
||||||
setIsVisible(true);
|
try {
|
||||||
}, 150); // Delay to start the animation
|
const response = await fetch(`/api/projects/search?slug=${slug}`);
|
||||||
}, []);
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
useEffect(() => {
|
if (data.projects && data.projects.length > 0) {
|
||||||
const projectData = searchParams.get("project");
|
setProject(data.projects[0]);
|
||||||
if (projectData) {
|
|
||||||
setProject(JSON.parse(projectData as string));
|
|
||||||
// Remove the project data from the URL without reloading the page
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete("project");
|
|
||||||
window.history.replaceState({}, "", url.toString());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fetch project data based on slug from URL
|
console.error('Failed to fetch project from API');
|
||||||
const slug = params.slug as string;
|
}
|
||||||
try {
|
|
||||||
fetchProjectData(slug);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Error loading project:', error);
|
||||||
setError("Failed to fetch project data");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, router, params, pathname]);
|
|
||||||
|
|
||||||
const fetchProjectData = async (slug: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/fetchProject?slug=${slug}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
setError("Failed to fetch project Data");
|
|
||||||
}
|
|
||||||
const projectData = (await response.json()) as { posts: Project[] };
|
|
||||||
if (
|
|
||||||
!projectData ||
|
|
||||||
!projectData.posts ||
|
|
||||||
projectData.posts.length === 0
|
|
||||||
) {
|
|
||||||
setError("Project not found");
|
|
||||||
}
|
|
||||||
setProject(projectData.posts[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch project data:", error);
|
|
||||||
setError("Project not found");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
loadProject();
|
||||||
return (
|
}, [slug]);
|
||||||
<div className="min-h-screen flex flex-col bg-radiant">
|
|
||||||
<Header />
|
|
||||||
<div className="flex-grow flex items-center justify-center">
|
|
||||||
<div className="text-center p-10 bg-white dark:bg-gray-700 rounded shadow-md">
|
|
||||||
<h1 className="text-6xl font-bold text-gray-800 dark:text-white">
|
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-xl text-gray-600 dark:text-gray-300">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="mt-6 inline-block text-blue-500 hover:underline"
|
|
||||||
>
|
|
||||||
Go Back Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Footer_Back />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-radiant">
|
<div className="min-h-screen animated-bg flex items-center justify-center">
|
||||||
<Header />
|
<div className="text-center">
|
||||||
<div className="flex-grow flex items-center justify-center">
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32"></div>
|
<p className="text-gray-400">Loading project...</p>
|
||||||
</div>
|
</div>
|
||||||
<Footer_Back />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureImageUrl = project.feature_image
|
|
||||||
? `/api/fetchImage?url=${encodeURIComponent(project.feature_image)}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen animated-bg">
|
||||||
className={`min-h-screen flex flex-col bg-radiant ${isVisible ? "animate-fly-in" : "opacity-0"}`}
|
<div className="max-w-4xl mx-auto px-4 py-20">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="mb-12"
|
||||||
>
|
>
|
||||||
<Header />
|
<Link
|
||||||
<div className="flex-grow">
|
href="/projects"
|
||||||
<div className="flex justify-center mt-14 md:mt-28 px-4 md:px-0">
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||||
{featureImageUrl && (
|
>
|
||||||
<div className="relative w-full max-w-4xl h-0 pb-[56.25%] rounded-2xl overflow-hidden">
|
<ArrowLeft size={20} />
|
||||||
<Image
|
<span>Back to Projects</span>
|
||||||
src={featureImageUrl}
|
</Link>
|
||||||
alt={project.title}
|
|
||||||
fill
|
<div className="flex items-center justify-between mb-6">
|
||||||
style={{ objectFit: "cover" }}
|
<h1 className="text-4xl md:text-5xl font-bold gradient-text">
|
||||||
className="rounded-2xl"
|
|
||||||
priority={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center mt-4">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-600">
|
|
||||||
{project.title}
|
{project.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
{project.featured && (
|
||||||
|
<span className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-sm font-semibold rounded-full">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-400 mb-6">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Project Meta */}
|
||||||
|
<div className="flex flex-wrap items-center gap-6 text-gray-400 mb-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Calendar size={20} />
|
||||||
|
<span>{project.date}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Tag size={20} />
|
||||||
|
<span>{project.category}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-8">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-4 py-2 bg-gray-800/50 text-gray-300 rounded-full border border-gray-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<motion.a
|
||||||
|
href={project.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="inline-flex items-center space-x-2 px-6 py-3 bg-gray-800/50 hover:bg-gray-700/50 text-white rounded-lg transition-colors border border-gray-700"
|
||||||
|
>
|
||||||
|
<GithubIcon size={20} />
|
||||||
|
<span>View Code</span>
|
||||||
|
</motion.a>
|
||||||
|
|
||||||
|
{project.live !== "#" && (
|
||||||
|
<motion.a
|
||||||
|
href={project.live}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="inline-flex items-center space-x-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={20} />
|
||||||
|
<span>Live Demo</span>
|
||||||
|
</motion.a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Project Content */}
|
{/* Project Content */}
|
||||||
<div className="p-10 pt-12">
|
<motion.div
|
||||||
<div className="flex flex-col p-8 bg-gradient-to-br from-white/60 to-white/30 backdrop-blur-lg rounded-2xl shadow-xl">
|
initial={{ opacity: 0, y: 30 }}
|
||||||
<div
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="content mt-4 text-gray-600 text-lg leading-relaxed"
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
dangerouslySetInnerHTML={{ __html: project.html }}
|
className="glass-card p-8 rounded-2xl"
|
||||||
></div>
|
>
|
||||||
|
<div className="markdown prose prose-invert max-w-none text-white">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
h1: ({children}) => <h1 className="text-3xl font-bold text-white mb-4">{children}</h1>,
|
||||||
|
h2: ({children}) => <h2 className="text-2xl font-semibold text-white mb-3">{children}</h2>,
|
||||||
|
h3: ({children}) => <h3 className="text-xl font-semibold text-white mb-2">{children}</h3>,
|
||||||
|
p: ({children}) => <p className="text-gray-300 mb-3 leading-relaxed">{children}</p>,
|
||||||
|
ul: ({children}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1">{children}</ul>,
|
||||||
|
ol: ({children}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1">{children}</ol>,
|
||||||
|
li: ({children}) => <li className="text-gray-300">{children}</li>,
|
||||||
|
a: ({href, children}) => (
|
||||||
|
<a href={href} className="text-blue-400 hover:text-blue-300 underline transition-colors" target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
code: ({children}) => <code className="bg-gray-800 text-blue-400 px-2 py-1 rounded text-sm">{children}</code>,
|
||||||
|
pre: ({children}) => <pre className="bg-gray-800 p-4 rounded-lg overflow-x-auto mb-3">{children}</pre>,
|
||||||
|
blockquote: ({children}) => <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-300 mb-3">{children}</blockquote>,
|
||||||
|
strong: ({children}) => <strong className="font-semibold text-white">{children}</strong>,
|
||||||
|
em: ({children}) => <em className="italic text-gray-300">{children}</em>
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.content}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer_Back />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectDetails;
|
export default ProjectDetail;
|
||||||
|
|||||||
218
app/projects/page.tsx
Normal file
218
app/projects/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ExternalLink, Github, Calendar, ArrowLeft } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
featured: boolean;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
github?: string;
|
||||||
|
live?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectsPage = () => {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
||||||
|
// Load projects from API
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects?published=true');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch projects from API');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = ["All", "Web Development", "Full-Stack", "Web Application", "Mobile App"];
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredProjects = selectedCategory === "All"
|
||||||
|
? projects
|
||||||
|
: projects.filter(project => project.category === selectedCategory);
|
||||||
|
|
||||||
|
console.log('Selected category:', selectedCategory);
|
||||||
|
console.log('Filtered projects:', filteredProjects);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen animated-bg">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-20">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="mb-12"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
<span>Back to Home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold mb-6 gradient-text">
|
||||||
|
My Projects
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-400 max-w-3xl">
|
||||||
|
Explore my portfolio of projects, from web applications to mobile apps.
|
||||||
|
Each project showcases different skills and technologies.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
|
className="mb-12"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||||
|
selectedCategory === category
|
||||||
|
? 'bg-blue-600 text-white shadow-lg'
|
||||||
|
: 'bg-gray-800/50 text-gray-300 hover:bg-gray-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{filteredProjects.map((project, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={project.id}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -10 }}
|
||||||
|
className="group relative overflow-hidden rounded-2xl glass-card card-hover"
|
||||||
|
>
|
||||||
|
<div className="relative h-48 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20" />
|
||||||
|
<div className="absolute inset-0 bg-gray-800/50 flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center mb-2">
|
||||||
|
<span className="text-2xl font-bold text-white">
|
||||||
|
{project.title.split(' ').map(word => word[0]).join('').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-400 text-center leading-tight">
|
||||||
|
{project.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.featured && (
|
||||||
|
<div className="absolute top-4 right-4 px-3 py-1 bg-gradient-to-r from-blue-500 to-purple-500 text-white text-xs font-semibold rounded-full">
|
||||||
|
Featured
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center space-x-4">
|
||||||
|
{project.github && (
|
||||||
|
<motion.a
|
||||||
|
href={project.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-gray-800/80 rounded-lg text-white hover:bg-gray-700/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Github size={20} />
|
||||||
|
</motion.a>
|
||||||
|
)}
|
||||||
|
{project.live && project.live !== "#" && (
|
||||||
|
<motion.a
|
||||||
|
href={project.live}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className="p-3 bg-blue-600/80 rounded-lg text-white hover:bg-blue-500/80 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={20} />
|
||||||
|
</motion.a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2 text-gray-400">
|
||||||
|
<Calendar size={16} />
|
||||||
|
<span className="text-sm">{project.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-300 mb-4 leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-3 py-1 bg-gray-800/50 text-gray-300 text-sm rounded-full border border-gray-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
|
||||||
|
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<span>View Project</span>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectsPage;
|
||||||
582
components/AdminDashboard.tsx
Normal file
582
components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
BarChart3,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
FolderOpen,
|
||||||
|
Calendar,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { projectService } from '@/lib/prisma';
|
||||||
|
import { useToast } from './Toast';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
github?: string | null;
|
||||||
|
liveUrl?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
category: string;
|
||||||
|
difficulty: string;
|
||||||
|
featured: boolean;
|
||||||
|
published: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
_count?: {
|
||||||
|
pageViews: number;
|
||||||
|
userInteractions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminDashboardProps {
|
||||||
|
onProjectSelect: (project: Project) => void;
|
||||||
|
onNewProject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||||
|
const [sortBy, setSortBy] = useState<'date' | 'title' | 'difficulty' | 'views'>('date');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [selectedProjects, setSelectedProjects] = useState<Set<number>>(new Set());
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
const { showImportSuccess, showImportError, showError } = useToast();
|
||||||
|
|
||||||
|
// Load projects from database
|
||||||
|
useEffect(() => {
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await projectService.getAllProjects();
|
||||||
|
setProjects(data.projects);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
// Fallback to localStorage if database fails
|
||||||
|
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||||
|
if (savedProjects) {
|
||||||
|
setProjects(JSON.parse(savedProjects));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort projects
|
||||||
|
const filteredProjects = projects
|
||||||
|
.filter(project => {
|
||||||
|
const matchesSearch = project.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
project.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
project.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
const matchesCategory = !selectedCategory || project.category === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aValue: any, bValue: any;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'date':
|
||||||
|
aValue = new Date(a.createdAt);
|
||||||
|
bValue = new Date(b.createdAt);
|
||||||
|
break;
|
||||||
|
case 'title':
|
||||||
|
aValue = a.title.toLowerCase();
|
||||||
|
bValue = b.title.toLowerCase();
|
||||||
|
break;
|
||||||
|
case 'difficulty':
|
||||||
|
const difficultyOrder = { 'Beginner': 1, 'Intermediate': 2, 'Advanced': 3, 'Expert': 4 };
|
||||||
|
aValue = difficultyOrder[a.difficulty as keyof typeof difficultyOrder];
|
||||||
|
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
|
||||||
|
break;
|
||||||
|
case 'views':
|
||||||
|
aValue = a._count?.pageViews || 0;
|
||||||
|
bValue = b._count?.pageViews || 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = a.createdAt;
|
||||||
|
bValue = b.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const stats = {
|
||||||
|
total: projects.length,
|
||||||
|
published: projects.filter(p => p.published).length,
|
||||||
|
featured: projects.filter(p => p.featured).length,
|
||||||
|
categories: new Set(projects.map(p => p.category)).size,
|
||||||
|
totalViews: projects.reduce((sum, p) => sum + (p._count?.pageViews || 0), 0),
|
||||||
|
totalLikes: projects.reduce((sum, p) => sum + (p._count?.userInteractions || 0), 0),
|
||||||
|
avgLighthouse: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedProjects.size === 0) return;
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to delete ${selectedProjects.size} projects?`)) {
|
||||||
|
try {
|
||||||
|
for (const id of selectedProjects) {
|
||||||
|
await projectService.deleteProject(id);
|
||||||
|
}
|
||||||
|
setSelectedProjects(new Set());
|
||||||
|
await loadProjects();
|
||||||
|
showImportSuccess(selectedProjects.size); // Reuse for success message
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting projects:', error);
|
||||||
|
showError('Fehler beim Löschen', 'Einige Projekte konnten nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkPublish = async (published: boolean) => {
|
||||||
|
if (selectedProjects.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const id of selectedProjects) {
|
||||||
|
await projectService.updateProject(id, { published });
|
||||||
|
}
|
||||||
|
setSelectedProjects(new Set());
|
||||||
|
await loadProjects();
|
||||||
|
showImportSuccess(selectedProjects.size); // Reuse for success message
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating projects:', error);
|
||||||
|
showError('Fehler beim Aktualisieren', 'Einige Projekte konnten nicht aktualisiert werden.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export/Import
|
||||||
|
const exportProjects = () => {
|
||||||
|
const dataStr = JSON.stringify(projects, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importProjects = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const importedProjects = JSON.parse(e.target?.result as string);
|
||||||
|
// Validate and import projects
|
||||||
|
let importedCount = 0;
|
||||||
|
for (const project of importedProjects) {
|
||||||
|
if (project.id) delete project.id; // Remove ID for new import
|
||||||
|
await projectService.createProject(project);
|
||||||
|
importedCount++;
|
||||||
|
}
|
||||||
|
await loadProjects();
|
||||||
|
showImportSuccess(importedCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing projects:', error);
|
||||||
|
showImportError('Bitte überprüfe das Dateiformat und versuche es erneut.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(projects.map(p => p.category)));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with Stats */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="glass-card p-6 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center">
|
||||||
|
<Database className="mr-3 text-blue-400" />
|
||||||
|
Project Database
|
||||||
|
</h2>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStats(!showStats)}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors flex items-center space-x-2 ${
|
||||||
|
showStats ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 size={20} />
|
||||||
|
<span>Stats</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={exportProjects}
|
||||||
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
<label className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center space-x-2 cursor-pointer">
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Import</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={importProjects}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gradient-to-br from-blue-500/20 to-blue-600/20 p-4 rounded-xl border border-blue-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-blue-300">Total Projects</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<FolderOpen className="text-blue-400" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-green-500/20 to-green-600/20 p-4 rounded-xl border border-green-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-green-300">Published</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.published}</p>
|
||||||
|
</div>
|
||||||
|
<Eye className="text-green-400" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-600/20 p-4 rounded-xl border border-yellow-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-yellow-300">Featured</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.featured}</p>
|
||||||
|
</div>
|
||||||
|
<Star className="text-yellow-400" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-purple-500/20 to-purple-600/20 p-4 rounded-xl border border-purple-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-purple-300">Categories</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.categories}</p>
|
||||||
|
</div>
|
||||||
|
<Tag className="text-purple-400" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extended Stats */}
|
||||||
|
{showStats && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<div className="bg-gradient-to-br from-indigo-500/20 to-indigo-600/20 p-4 rounded-xl border border-indigo-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-indigo-300">Total Views</p>
|
||||||
|
<p className="text-xl font-bold text-white">{stats.totalViews.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="text-indigo-400" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-pink-500/20 to-pink-600/20 p-4 rounded-xl border border-pink-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-pink-300">Total Likes</p>
|
||||||
|
<p className="text-xl font-bold text-white">{stats.totalLikes.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="text-pink-400" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-orange-500/20 to-orange-600/20 p-4 rounded-xl border border-orange-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-orange-300">Avg Lighthouse</p>
|
||||||
|
<p className="text-xl font-bold text-white">{stats.avgLighthouse}/100</p>
|
||||||
|
</div>
|
||||||
|
<Activity className="text-orange-400" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="glass-card p-6 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="date">Sort by Date</option>
|
||||||
|
<option value="title">Sort by Title</option>
|
||||||
|
<option value="difficulty">Sort by Difficulty</option>
|
||||||
|
<option value="views">Sort by Views</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
|
||||||
|
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="desc">Descending</option>
|
||||||
|
<option value="asc">Ascending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Actions */}
|
||||||
|
{selectedProjects.size > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center space-x-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="text-blue-300 font-medium">
|
||||||
|
{selectedProjects.size} project(s) selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBulkPublish(true)}
|
||||||
|
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Publish All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBulkPublish(false)}
|
||||||
|
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Unpublish All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Delete All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedProjects(new Set())}
|
||||||
|
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Projects List */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="glass-card p-6 rounded-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-white">
|
||||||
|
Projects ({filteredProjects.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onNewProject}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
<span>New Project</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<motion.div
|
||||||
|
key={project.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className={`p-4 rounded-lg cursor-pointer transition-all border ${
|
||||||
|
selectedProjects.has(project.id)
|
||||||
|
? 'bg-blue-600/20 border-blue-500/50'
|
||||||
|
: 'bg-gray-800/30 hover:bg-gray-700/30 border-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4 flex-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedProjects.has(project.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSelected = new Set(selectedProjects);
|
||||||
|
if (e.target.checked) {
|
||||||
|
newSelected.add(project.id);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(project.id);
|
||||||
|
}
|
||||||
|
setSelectedProjects(newSelected);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-800 border-gray-700 rounded focus:ring-blue-500 focus:ring-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<h4 className="font-medium text-white">{project.title}</h4>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
project.difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
project.difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
project.difficulty === 'Advanced' ? 'bg-orange-500/20 text-orange-400' :
|
||||||
|
'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{project.difficulty}
|
||||||
|
</span>
|
||||||
|
{project.featured && (
|
||||||
|
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{project.published ? (
|
||||||
|
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium">
|
||||||
|
Published
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-gray-500/20 text-gray-400 rounded text-xs font-medium">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-400 mb-2">{project.description}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Tag className="mr-1" size={14} />
|
||||||
|
{project.category}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Calendar className="mr-1" size={14} />
|
||||||
|
{new Date(project.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Eye className="mr-1" size={14} />
|
||||||
|
{project._count?.pageViews || 0} views
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Activity className="mr-1" size={14} />
|
||||||
|
N/A
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onProjectSelect(project)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit Project"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/projects/${project.id}`, '_blank')}
|
||||||
|
className="p-2 text-gray-400 hover:text-green-400 transition-colors"
|
||||||
|
title="View Project"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredProjects.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<FolderOpen className="mx-auto mb-4" size={48} />
|
||||||
|
<p className="text-lg font-medium">No projects found</p>
|
||||||
|
<p className="text-sm">Try adjusting your search or filters</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
components/AnalyticsProvider.tsx
Normal file
130
components/AnalyticsProvider.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useWebVitals } from '@/lib/useWebVitals';
|
||||||
|
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface AnalyticsProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
||||||
|
// Initialize Web Vitals tracking
|
||||||
|
useWebVitals();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
const trackPageView = () => {
|
||||||
|
trackEvent('page-view', {
|
||||||
|
url: window.location.pathname,
|
||||||
|
referrer: document.referrer,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
trackPageLoad();
|
||||||
|
|
||||||
|
// Track initial page view
|
||||||
|
trackPageView();
|
||||||
|
|
||||||
|
// Track route changes (for SPA navigation)
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
trackPageView();
|
||||||
|
trackPageLoad();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for popstate events (back/forward navigation)
|
||||||
|
window.addEventListener('popstate', handleRouteChange);
|
||||||
|
|
||||||
|
// Track user interactions
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const element = target.tagName.toLowerCase();
|
||||||
|
const className = target.className;
|
||||||
|
const id = target.id;
|
||||||
|
|
||||||
|
trackEvent('click', {
|
||||||
|
element,
|
||||||
|
className: className ? className.split(' ')[0] : undefined,
|
||||||
|
id: id || undefined,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track form submissions
|
||||||
|
const handleSubmit = (event: SubmitEvent) => {
|
||||||
|
const form = event.target as HTMLFormElement;
|
||||||
|
trackEvent('form-submit', {
|
||||||
|
formId: form.id || undefined,
|
||||||
|
formClass: form.className || undefined,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track scroll depth
|
||||||
|
let maxScrollDepth = 0;
|
||||||
|
const handleScroll = () => {
|
||||||
|
const scrollDepth = Math.round(
|
||||||
|
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scrollDepth > maxScrollDepth) {
|
||||||
|
maxScrollDepth = scrollDepth;
|
||||||
|
|
||||||
|
// Track scroll milestones
|
||||||
|
if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) {
|
||||||
|
trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) {
|
||||||
|
trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) {
|
||||||
|
trackEvent('scroll-depth', { depth: 75, url: window.location.pathname });
|
||||||
|
} else if (scrollDepth >= 90 && maxScrollDepth >= 90) {
|
||||||
|
trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('submit', handleSubmit);
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Track errors
|
||||||
|
const handleError = (event: ErrorEvent) => {
|
||||||
|
trackEvent('error', {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
trackEvent('unhandled-rejection', {
|
||||||
|
reason: event.reason?.toString(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', handleError);
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handleRouteChange);
|
||||||
|
document.removeEventListener('click', handleClick);
|
||||||
|
document.removeEventListener('submit', handleSubmit);
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('error', handleError);
|
||||||
|
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
139
components/PerformanceDashboard.tsx
Normal file
139
components/PerformanceDashboard.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { trackEvent } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface PerformanceData {
|
||||||
|
timestamp: string;
|
||||||
|
url: string;
|
||||||
|
metrics: {
|
||||||
|
LCP?: number;
|
||||||
|
FID?: number;
|
||||||
|
CLS?: number;
|
||||||
|
FCP?: number;
|
||||||
|
TTFB?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PerformanceDashboard: React.FC = () => {
|
||||||
|
const [performanceData, setPerformanceData] = useState<PerformanceData[]>([]);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This would typically fetch from your Umami instance or database
|
||||||
|
// For now, we'll show a placeholder
|
||||||
|
const mockData: PerformanceData[] = [
|
||||||
|
{
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: '/',
|
||||||
|
metrics: {
|
||||||
|
LCP: 1200,
|
||||||
|
FID: 45,
|
||||||
|
CLS: 0.1,
|
||||||
|
FCP: 800,
|
||||||
|
TTFB: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setPerformanceData(mockData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPerformanceGrade = (metric: string, value: number): string => {
|
||||||
|
switch (metric) {
|
||||||
|
case 'LCP':
|
||||||
|
return value <= 2500 ? 'Good' : value <= 4000 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'FID':
|
||||||
|
return value <= 100 ? 'Good' : value <= 300 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'CLS':
|
||||||
|
return value <= 0.1 ? 'Good' : value <= 0.25 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'FCP':
|
||||||
|
return value <= 1800 ? 'Good' : value <= 3000 ? 'Needs Improvement' : 'Poor';
|
||||||
|
case 'TTFB':
|
||||||
|
return value <= 800 ? 'Good' : value <= 1800 ? 'Needs Improvement' : 'Poor';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradeColor = (grade: string): string => {
|
||||||
|
switch (grade) {
|
||||||
|
case 'Good':
|
||||||
|
return 'text-green-600 bg-green-100';
|
||||||
|
case 'Needs Improvement':
|
||||||
|
return 'text-yellow-600 bg-yellow-100';
|
||||||
|
case 'Poor':
|
||||||
|
return 'text-red-600 bg-red-100';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
trackEvent('dashboard-toggle', { action: 'show' });
|
||||||
|
}}
|
||||||
|
className="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-blue-700 transition-colors z-50"
|
||||||
|
>
|
||||||
|
📊 Performance
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-6 w-96 max-h-96 overflow-y-auto z-50">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">Performance Dashboard</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
trackEvent('dashboard-toggle', { action: 'hide' });
|
||||||
|
}}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{performanceData.map((data, index) => (
|
||||||
|
<div key={index} className="border-b border-gray-100 pb-4">
|
||||||
|
<div className="text-sm text-gray-600 mb-2">
|
||||||
|
{new Date(data.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-800 mb-2">
|
||||||
|
{data.url}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data.metrics).map(([metric, value]) => {
|
||||||
|
const grade = getPerformanceGrade(metric, value);
|
||||||
|
return (
|
||||||
|
<div key={metric} className="flex justify-between items-center">
|
||||||
|
<span className="text-xs font-medium text-gray-600">{metric}:</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs font-mono">{value}ms</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${getGradeColor(grade)}`}>
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<div>🟢 Good: Meets recommended thresholds</div>
|
||||||
|
<div>🟡 Needs Improvement: Below recommended thresholds</div>
|
||||||
|
<div>🔴 Poor: Significantly below thresholds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
301
components/Toast.tsx
Normal file
301
components/Toast.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
Mail,
|
||||||
|
Database,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Download
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
toast: Toast;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastItem = ({ toast, onRemove }: ToastProps) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.duration !== 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}, toast.duration || 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [toast.duration, toast.id, onRemove]);
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (toast.type) {
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-400" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-5 h-5 text-red-400" />;
|
||||||
|
case 'warning':
|
||||||
|
return <AlertTriangle className="w-5 h-5 text-yellow-400" />;
|
||||||
|
case 'info':
|
||||||
|
return <Info className="w-5 h-5 text-blue-400" />;
|
||||||
|
default:
|
||||||
|
return <Info className="w-5 h-5 text-blue-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColors = () => {
|
||||||
|
switch (toast.type) {
|
||||||
|
case 'success':
|
||||||
|
return 'bg-white border-green-300 text-green-900 shadow-lg';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-white border-red-300 text-red-900 shadow-lg';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-white border-yellow-300 text-yellow-900 shadow-lg';
|
||||||
|
case 'info':
|
||||||
|
return 'bg-white border-blue-300 text-blue-900 shadow-lg';
|
||||||
|
default:
|
||||||
|
return 'bg-white border-gray-300 text-gray-900 shadow-lg';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className={`relative p-4 rounded-xl border ${getColors()} shadow-xl hover:shadow-2xl transition-all duration-300 max-w-sm`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-semibold mb-1">{toast.title}</h4>
|
||||||
|
<p className="text-sm opacity-90">{toast.message}</p>
|
||||||
|
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
onClick={toast.action.onClick}
|
||||||
|
className="mt-2 text-xs font-medium underline hover:no-underline transition-all"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{toast.duration !== 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '100%' }}
|
||||||
|
animate={{ width: '0%' }}
|
||||||
|
transition={{ duration: (toast.duration || 5000) / 1000, ease: "linear" }}
|
||||||
|
className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-blue-400 to-green-400 rounded-b-xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toast context and provider
|
||||||
|
import { createContext, useContext, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
|
showSuccess: (title: string, message?: string) => void;
|
||||||
|
showError: (title: string, message?: string) => void;
|
||||||
|
showWarning: (title: string, message?: string) => void;
|
||||||
|
showInfo: (title: string, message?: string) => void;
|
||||||
|
showEmailSent: (email: string) => void;
|
||||||
|
showEmailError: (error: string) => void;
|
||||||
|
showProjectSaved: (title: string) => void;
|
||||||
|
showProjectDeleted: (title: string) => void;
|
||||||
|
showImportSuccess: (count: number) => void;
|
||||||
|
showImportError: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
const newToast = { ...toast, id };
|
||||||
|
setToasts(prev => [...prev, newToast]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||||
|
addToast(toast);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showSuccess = useCallback((title: string, message?: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title,
|
||||||
|
message: message || '',
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showError = useCallback((title: string, message?: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
title,
|
||||||
|
message: message || '',
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showWarning = useCallback((title: string, message?: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'warning',
|
||||||
|
title,
|
||||||
|
message: message || '',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showInfo = useCallback((title: string, message?: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'info',
|
||||||
|
title,
|
||||||
|
message: message || '',
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showEmailSent = useCallback((email: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'E-Mail gesendet! 📧',
|
||||||
|
message: `Deine Nachricht an ${email} wurde erfolgreich versendet.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showEmailError = useCallback((error: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
title: 'E-Mail Fehler! ❌',
|
||||||
|
message: `Fehler beim Senden: ${error}`,
|
||||||
|
duration: 8000
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showProjectSaved = useCallback((title: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Projekt gespeichert! 💾',
|
||||||
|
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showProjectDeleted = useCallback((title: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Projekt gelöscht! 🗑️',
|
||||||
|
message: `"${title}" wurde aus der Datenbank entfernt.`,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showImportSuccess = useCallback((count: number) => {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Import erfolgreich! 📥',
|
||||||
|
message: `${count} Projekte wurden erfolgreich importiert.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showImportError = useCallback((error: string) => {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Import Fehler! ❌',
|
||||||
|
message: `Fehler beim Importieren: ${error}`,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const contextValue: ToastContextType = {
|
||||||
|
showToast,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showWarning,
|
||||||
|
showInfo,
|
||||||
|
showEmailSent,
|
||||||
|
showEmailError,
|
||||||
|
showProjectSaved,
|
||||||
|
showProjectDeleted,
|
||||||
|
showImportSuccess,
|
||||||
|
showImportError
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Toast Container */}
|
||||||
|
<div className="fixed top-4 right-4 z-50 space-y-3 max-w-sm">
|
||||||
|
<AnimatePresence>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onRemove={removeToast}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastItem;
|
||||||
69
docker-compose.prod.yml
Normal file
69
docker-compose.prod.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
portfolio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: portfolio-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
|
- GHOST_API_URL=${GHOST_API_URL}
|
||||||
|
- GHOST_API_KEY=${GHOST_API_KEY}
|
||||||
|
- MY_EMAIL=${MY_EMAIL}
|
||||||
|
- MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
||||||
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
|
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- portfolio_data:/app/.next/cache
|
||||||
|
networks:
|
||||||
|
- portfolio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: portfolio-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
- nginx_cache:/var/cache/nginx
|
||||||
|
depends_on:
|
||||||
|
- portfolio
|
||||||
|
networks:
|
||||||
|
- portfolio-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nginx", "-t"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
portfolio_data:
|
||||||
|
driver: local
|
||||||
|
nginx_cache:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portfolio-network:
|
||||||
|
driver: bridge
|
||||||
31
env.example
Normal file
31
env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Portfolio Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://dki.one
|
||||||
|
|
||||||
|
# Ghost CMS
|
||||||
|
GHOST_API_URL=https://your-ghost-instance.com
|
||||||
|
GHOST_API_KEY=your-ghost-api-key
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
MY_EMAIL=your-email@example.com
|
||||||
|
MY_INFO_EMAIL=your-info-email@example.com
|
||||||
|
MY_PASSWORD=your-email-password
|
||||||
|
MY_INFO_PASSWORD=your-info-email-password
|
||||||
|
|
||||||
|
# Database (if using external database)
|
||||||
|
# DATABASE_URL=postgresql://username:password@localhost:5432/portfolio
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
# NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
||||||
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# JWT_SECRET=your-jwt-secret
|
||||||
|
# ENCRYPTION_KEY=your-encryption-key
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
# SENTRY_DSN=your-sentry-dsn
|
||||||
|
# LOG_LEVEL=info
|
||||||
112
lib/analytics.ts
Normal file
112
lib/analytics.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Analytics utilities for Umami with Performance Tracking
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (event: string, data?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceMetric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebVitalsMetric {
|
||||||
|
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track custom events to Umami
|
||||||
|
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||||
|
if (typeof window !== 'undefined' && window.umami) {
|
||||||
|
window.umami.track(event, {
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track performance metrics
|
||||||
|
export const trackPerformance = (metric: PerformanceMetric) => {
|
||||||
|
trackEvent('performance', {
|
||||||
|
metric: metric.name,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
url: metric.url,
|
||||||
|
userAgent: metric.userAgent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track Web Vitals
|
||||||
|
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
||||||
|
trackEvent('web-vitals', {
|
||||||
|
name: metric.name,
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
delta: Math.round(metric.delta),
|
||||||
|
id: metric.id,
|
||||||
|
url: metric.url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
export const trackPageLoad = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||||
|
|
||||||
|
if (navigation) {
|
||||||
|
trackPerformance({
|
||||||
|
name: 'page-load',
|
||||||
|
value: navigation.loadEventEnd - navigation.fetchStart,
|
||||||
|
url: window.location.pathname,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track individual timing phases
|
||||||
|
trackEvent('page-timing', {
|
||||||
|
dns: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart),
|
||||||
|
tcp: Math.round(navigation.connectEnd - navigation.connectStart),
|
||||||
|
request: Math.round(navigation.responseStart - navigation.requestStart),
|
||||||
|
response: Math.round(navigation.responseEnd - navigation.responseStart),
|
||||||
|
dom: Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd),
|
||||||
|
load: Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd),
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track API response times
|
||||||
|
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
||||||
|
trackEvent('api-call', {
|
||||||
|
endpoint,
|
||||||
|
duration: Math.round(duration),
|
||||||
|
status,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track user interactions
|
||||||
|
export const trackInteraction = (action: string, element?: string) => {
|
||||||
|
trackEvent('interaction', {
|
||||||
|
action,
|
||||||
|
element,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track errors
|
||||||
|
export const trackError = (error: string, context?: string) => {
|
||||||
|
trackEvent('error', {
|
||||||
|
error,
|
||||||
|
context,
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
};
|
||||||
220
lib/prisma.ts
Normal file
220
lib/prisma.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
|
|
||||||
|
// Database service functions
|
||||||
|
export const projectService = {
|
||||||
|
// Get all projects with pagination and filtering
|
||||||
|
async getAllProjects(options: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
category?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
published?: boolean;
|
||||||
|
difficulty?: string;
|
||||||
|
search?: string;
|
||||||
|
} = {}) {
|
||||||
|
const { page = 1, limit = 50, category, featured, published, difficulty, search } = options;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (featured !== undefined) where.featured = featured;
|
||||||
|
if (published !== undefined) where.published = published;
|
||||||
|
if (difficulty) where.difficulty = difficulty;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ tags: { hasSome: [search] } },
|
||||||
|
{ content: { contains: search, mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
currentPage: page
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get project by ID
|
||||||
|
async getProjectById(id: number) {
|
||||||
|
return prisma.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create new project
|
||||||
|
async createProject(data: any) {
|
||||||
|
return prisma.project.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
performance: data.performance || { lighthouse: 90, bundleSize: '50KB', loadTime: '1.5s' },
|
||||||
|
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
async updateProject(id: number, data: any) {
|
||||||
|
return prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...data, updatedAt: new Date() }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
async deleteProject(id: number) {
|
||||||
|
return prisma.project.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get featured projects
|
||||||
|
async getFeaturedProjects(limit: number = 6) {
|
||||||
|
return prisma.project.findMany({
|
||||||
|
where: { featured: true, published: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get projects by category
|
||||||
|
async getProjectsByCategory(category: string, limit: number = 10) {
|
||||||
|
return prisma.project.findMany({
|
||||||
|
where: { category, published: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search projects
|
||||||
|
async searchProjects(query: string, limit: number = 20) {
|
||||||
|
return prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ tags: { hasSome: [query] } },
|
||||||
|
{ content: { contains: query, mode: 'insensitive' } }
|
||||||
|
],
|
||||||
|
published: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
async trackPageView(projectId: number | null, page: string, ip?: string, userAgent?: string, referrer?: string) {
|
||||||
|
return prisma.pageView.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
page,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
referrer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Track user interaction
|
||||||
|
async trackUserInteraction(projectId: number, type: string, ip?: string, userAgent?: string) {
|
||||||
|
return prisma.userInteraction.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
type: type as any,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get analytics
|
||||||
|
async getAnalytics(projectId: number) {
|
||||||
|
const [pageViews, interactions] = await Promise.all([
|
||||||
|
prisma.pageView.count({ where: { projectId } }),
|
||||||
|
prisma.userInteraction.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: { projectId },
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const analytics: any = { views: pageViews, likes: 0, shares: 0 };
|
||||||
|
|
||||||
|
interactions.forEach(interaction => {
|
||||||
|
if (interaction.type === 'LIKE') analytics.likes = 0;
|
||||||
|
if (interaction.type === 'SHARE') analytics.shares = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return analytics;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get performance stats
|
||||||
|
async getPerformanceStats() {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
select: {
|
||||||
|
performance: true,
|
||||||
|
analytics: true,
|
||||||
|
category: true,
|
||||||
|
difficulty: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalProjects: projects.length,
|
||||||
|
avgLighthouse: 0,
|
||||||
|
totalViews: 0,
|
||||||
|
totalLikes: 0,
|
||||||
|
totalShares: 0,
|
||||||
|
byCategory: {} as any,
|
||||||
|
byDifficulty: {} as any
|
||||||
|
};
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
const perf = project.performance as any;
|
||||||
|
const analytics = project.analytics as any;
|
||||||
|
|
||||||
|
stats.avgLighthouse += perf?.lighthouse || 0;
|
||||||
|
stats.totalViews += analytics?.views || 0;
|
||||||
|
stats.totalLikes += analytics?.likes || 0;
|
||||||
|
stats.totalShares += analytics?.shares || 0;
|
||||||
|
|
||||||
|
// Category stats
|
||||||
|
if (!stats.byCategory[project.category]) stats.byCategory[project.category] = 0;
|
||||||
|
stats.byCategory[project.category]++;
|
||||||
|
|
||||||
|
// Difficulty stats
|
||||||
|
if (!stats.byDifficulty[project.difficulty]) stats.byDifficulty[project.difficulty] = 0;
|
||||||
|
stats.byDifficulty[project.difficulty]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stats.totalProjects > 0) {
|
||||||
|
stats.avgLighthouse = Math.round(stats.avgLighthouse / stats.totalProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
};
|
||||||
185
lib/useWebVitals.ts
Normal file
185
lib/useWebVitals.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { trackWebVitals, trackPerformance } from './analytics';
|
||||||
|
|
||||||
|
// Web Vitals types
|
||||||
|
interface Metric {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
||||||
|
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
let clsValue = 0;
|
||||||
|
let sessionValue = 0;
|
||||||
|
let sessionEntries: PerformanceEntry[] = [];
|
||||||
|
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (!(entry as any).hadRecentInput) {
|
||||||
|
const firstSessionEntry = sessionEntries[0];
|
||||||
|
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
||||||
|
|
||||||
|
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
||||||
|
sessionValue += (entry as any).value;
|
||||||
|
sessionEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
sessionValue = (entry as any).value;
|
||||||
|
sessionEntries = [entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionValue > clsValue) {
|
||||||
|
clsValue = sessionValue;
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'CLS',
|
||||||
|
value: clsValue,
|
||||||
|
delta: clsValue,
|
||||||
|
id: `cls-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'layout-shift', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'FID',
|
||||||
|
value: (entry as any).processingStart - entry.startTime,
|
||||||
|
delta: (entry as any).processingStart - entry.startTime,
|
||||||
|
id: `fid-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'first-input', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (entry.name === 'first-contentful-paint') {
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'FCP',
|
||||||
|
value: entry.startTime,
|
||||||
|
delta: entry.startTime,
|
||||||
|
id: `fcp-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'paint', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
const entries = list.getEntries();
|
||||||
|
const lastEntry = entries[entries.length - 1];
|
||||||
|
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'LCP',
|
||||||
|
value: lastEntry.startTime,
|
||||||
|
delta: lastEntry.startTime,
|
||||||
|
id: `lcp-${Date.now()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (entry.entryType === 'navigation') {
|
||||||
|
const navEntry = entry as PerformanceNavigationTiming;
|
||||||
|
onPerfEntry({
|
||||||
|
name: 'TTFB',
|
||||||
|
value: navEntry.responseStart - navEntry.fetchStart,
|
||||||
|
delta: navEntry.responseStart - navEntry.fetchStart,
|
||||||
|
id: `ttfb-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe({ type: 'navigation', buffered: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom hook for Web Vitals tracking
|
||||||
|
export const useWebVitals = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Track Core Web Vitals
|
||||||
|
getCLS((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getFID((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getFCP((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getLCP((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
getTTFB((metric) => {
|
||||||
|
trackWebVitals({
|
||||||
|
...metric,
|
||||||
|
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
||||||
|
url: window.location.pathname,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track page load performance
|
||||||
|
const handleLoad = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
trackPerformance({
|
||||||
|
name: 'page-load-complete',
|
||||||
|
value: performance.now(),
|
||||||
|
url: window.location.pathname,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
handleLoad();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', handleLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('load', handleLoad);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
27
middleware.ts
Normal file
27
middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
// Allow email and projects API routes without authentication
|
||||||
|
if (request.nextUrl.pathname.startsWith('/api/email/') ||
|
||||||
|
request.nextUrl.pathname.startsWith('/api/projects/')) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other routes, continue with normal processing
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api/email (email API routes)
|
||||||
|
* - api/projects (projects API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api/email|api/projects|_next/static|_next/image|favicon.ico).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -6,6 +6,19 @@ import path from "path";
|
|||||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Enable standalone output for Docker
|
||||||
|
output: 'standalone',
|
||||||
|
|
||||||
|
// Optimize for production
|
||||||
|
compress: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
// Disable ESLint during build for Docker
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL
|
||||||
},
|
},
|
||||||
@@ -17,6 +30,17 @@ const nextConfig: NextConfig = {
|
|||||||
MY_PASSWORD: process.env.MY_PASSWORD,
|
MY_PASSWORD: process.env.MY_PASSWORD,
|
||||||
MY_INFO_PASSWORD: process.env.MY_INFO_PASSWORD
|
MY_INFO_PASSWORD: process.env.MY_INFO_PASSWORD
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', 'framer-motion'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image optimization
|
||||||
|
images: {
|
||||||
|
formats: ['image/webp', 'image/avif'],
|
||||||
|
minimumCacheTTL: 60,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||||
|
|||||||
139
nginx.conf
Normal file
139
nginx.conf
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# Basic Settings
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 16M;
|
||||||
|
|
||||||
|
# Gzip Settings
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
|
||||||
|
# Cache Settings
|
||||||
|
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=portfolio_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||||
|
|
||||||
|
# Upstream for load balancing
|
||||||
|
upstream portfolio_backend {
|
||||||
|
least_conn;
|
||||||
|
server portfolio:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
# Add more instances here for scaling
|
||||||
|
# server portfolio-2:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
# server portfolio-3:3000 max_fails=3 fail_timeout=30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP Server (redirect to HTTPS)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS Server
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name dki.one www.dki.one;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Cache-Status "STATIC";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API routes with rate limiting
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_pragma $http_authorization;
|
||||||
|
proxy_cache_revalidate on;
|
||||||
|
proxy_cache_min_uses 1;
|
||||||
|
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /api/health {
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://portfolio_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Enable caching for static pages
|
||||||
|
proxy_cache portfolio_cache;
|
||||||
|
proxy_cache_valid 200 302 10m;
|
||||||
|
proxy_cache_valid 404 1m;
|
||||||
|
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
|
||||||
|
# Add cache status header
|
||||||
|
add_header X-Cache-Status $upstream_cache_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error pages
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2093
package-lock.json
generated
2093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -8,22 +8,48 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"buildAnalyze": "cross-env ANALYZE=true next build",
|
"buildAnalyze": "cross-env ANALYZE=true next build",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"db:setup": "chmod +x scripts/setup-db.sh && ./scripts/setup-db.sh",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:reset": "prisma db push --force-reset",
|
||||||
|
"docker:build": "docker build -t portfolio-app .",
|
||||||
|
"docker:run": "docker run -p 3000:3000 portfolio-app",
|
||||||
|
"docker:compose": "docker-compose -f docker-compose.prod.yml up -d",
|
||||||
|
"docker:down": "docker-compose -f docker-compose.prod.yml down",
|
||||||
|
"deploy": "./scripts/deploy.sh",
|
||||||
|
"auto-deploy": "./scripts/auto-deploy.sh",
|
||||||
|
"quick-deploy": "./scripts/quick-deploy.sh",
|
||||||
|
"monitor": "./scripts/monitor.sh",
|
||||||
|
"health": "curl -f http://localhost:3000/api/health"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/bundle-analyzer": "^15.1.7",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@prisma/client": "^6.3.1",
|
"@prisma/client": "^5.7.1",
|
||||||
"@tryghost/content-api": "^1.11.21",
|
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"framer-motion": "^11.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.10.0",
|
"nodemailer": "^6.10.0",
|
||||||
|
"prisma": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-responsive-masonry": "^2.7.1"
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-responsive-masonry": "^2.7.1",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"tailwind-merge": "^2.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -31,11 +57,13 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22",
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-responsive-masonry": "^2.6.0",
|
"@types/react-responsive-masonry": "^2.6.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.7",
|
"eslint-config-next": "15.1.7",
|
||||||
@@ -46,6 +74,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"whatwg-fetch": "^3.6.20"
|
"whatwg-fetch": "^3.6.20"
|
||||||
}
|
}
|
||||||
|
|||||||
103
prisma/schema.prisma
Normal file
103
prisma/schema.prisma
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
description String @db.Text
|
||||||
|
content String @db.Text
|
||||||
|
tags String[] @default([])
|
||||||
|
featured Boolean @default(false)
|
||||||
|
category String @db.VarChar(100)
|
||||||
|
date String @db.VarChar(10)
|
||||||
|
github String? @db.VarChar(500)
|
||||||
|
live String? @db.VarChar(500)
|
||||||
|
published Boolean @default(true)
|
||||||
|
imageUrl String? @db.VarChar(500)
|
||||||
|
metaDescription String? @db.Text
|
||||||
|
keywords String? @db.Text
|
||||||
|
ogImage String? @db.VarChar(500)
|
||||||
|
schema Json?
|
||||||
|
|
||||||
|
// Advanced features
|
||||||
|
difficulty Difficulty @default(INTERMEDIATE)
|
||||||
|
timeToComplete String? @db.VarChar(100)
|
||||||
|
technologies String[] @default([])
|
||||||
|
challenges String[] @default([])
|
||||||
|
lessonsLearned String[] @default([])
|
||||||
|
futureImprovements String[] @default([])
|
||||||
|
demoVideo String? @db.VarChar(500)
|
||||||
|
screenshots String[] @default([])
|
||||||
|
colorScheme String @db.VarChar(100) @default("Dark")
|
||||||
|
accessibility Boolean @default(true)
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
performance Json @default("{\"lighthouse\": 90, \"bundleSize\": \"50KB\", \"loadTime\": \"1.5s\"}")
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
analytics Json @default("{\"views\": 0, \"likes\": 0, \"shares\": 0}")
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// Indexes for performance
|
||||||
|
@@index([category])
|
||||||
|
@@index([featured])
|
||||||
|
@@index([published])
|
||||||
|
@@index([difficulty])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([tags])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Difficulty {
|
||||||
|
BEGINNER
|
||||||
|
INTERMEDIATE
|
||||||
|
ADVANCED
|
||||||
|
EXPERT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics tracking
|
||||||
|
model PageView {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int? @map("project_id")
|
||||||
|
page String @db.VarChar(100)
|
||||||
|
ip String? @db.VarChar(45)
|
||||||
|
userAgent String? @db.Text @map("user_agent")
|
||||||
|
referrer String? @db.VarChar(500)
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([timestamp])
|
||||||
|
@@index([page])
|
||||||
|
}
|
||||||
|
|
||||||
|
// User interactions
|
||||||
|
model UserInteraction {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
projectId Int @map("project_id")
|
||||||
|
type InteractionType
|
||||||
|
ip String? @db.VarChar(45)
|
||||||
|
userAgent String? @db.Text @map("user_agent")
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([type])
|
||||||
|
@@index([timestamp])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InteractionType {
|
||||||
|
LIKE
|
||||||
|
SHARE
|
||||||
|
BOOKMARK
|
||||||
|
COMMENT
|
||||||
|
}
|
||||||
332
prisma/seed.ts
Normal file
332
prisma/seed.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
await prisma.userInteraction.deleteMany();
|
||||||
|
await prisma.pageView.deleteMany();
|
||||||
|
await prisma.project.deleteMany();
|
||||||
|
|
||||||
|
// Create sample projects
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
title: "Portfolio Website 2.0",
|
||||||
|
description: "A cutting-edge portfolio website showcasing modern web development techniques with advanced features and stunning design.",
|
||||||
|
content: `# Portfolio Website 2.0
|
||||||
|
|
||||||
|
This is my personal portfolio website built with cutting-edge web technologies. The site features a dark theme with glassmorphism effects, smooth animations, and advanced interactive elements.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Responsive Design**: Works perfectly on all devices
|
||||||
|
- **Dark Theme**: Modern dark mode with glassmorphism effects
|
||||||
|
- **Animations**: Smooth animations powered by Framer Motion
|
||||||
|
- **Markdown Support**: Projects are written in Markdown for easy editing
|
||||||
|
- **Performance**: Optimized for speed and SEO
|
||||||
|
- **Interactive Elements**: Advanced UI components and micro-interactions
|
||||||
|
- **Accessibility**: WCAG 2.1 AA compliant
|
||||||
|
- **Analytics**: Built-in performance and user analytics
|
||||||
|
|
||||||
|
## 🛠️ Technologies Used
|
||||||
|
|
||||||
|
- Next.js 15
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Framer Motion
|
||||||
|
- React Markdown
|
||||||
|
- Advanced CSS (Grid, Flexbox, Custom Properties)
|
||||||
|
- Performance optimization techniques
|
||||||
|
|
||||||
|
## 📈 Development Process
|
||||||
|
|
||||||
|
The website was designed with a focus on user experience, performance, and accessibility. I used modern CSS techniques and best practices to create a responsive, fast, and beautiful layout.
|
||||||
|
|
||||||
|
## 🔮 Future Improvements
|
||||||
|
|
||||||
|
- AI-powered content suggestions
|
||||||
|
- Advanced project filtering and search
|
||||||
|
- Interactive project demos
|
||||||
|
- Real-time collaboration features
|
||||||
|
- Advanced analytics dashboard
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- [Live Demo](https://dki.one)
|
||||||
|
- [GitHub Repository](https://github.com/Denshooter/portfolio)`,
|
||||||
|
tags: ["Next.js", "TypeScript", "Tailwind CSS", "Framer Motion", "Advanced CSS", "Performance"],
|
||||||
|
featured: true,
|
||||||
|
category: "Web Development",
|
||||||
|
date: "2024",
|
||||||
|
published: true,
|
||||||
|
difficulty: "ADVANCED",
|
||||||
|
timeToComplete: "3-4 weeks",
|
||||||
|
technologies: ["Next.js 15", "TypeScript", "Tailwind CSS", "Framer Motion", "React Markdown"],
|
||||||
|
challenges: ["Complex state management", "Performance optimization", "Responsive design across devices"],
|
||||||
|
lessonsLearned: ["Advanced CSS techniques", "Performance optimization", "User experience design"],
|
||||||
|
futureImprovements: ["AI integration", "Advanced analytics", "Real-time features"],
|
||||||
|
demoVideo: "",
|
||||||
|
screenshots: [],
|
||||||
|
colorScheme: "Dark with glassmorphism",
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 95,
|
||||||
|
bundleSize: "45KB",
|
||||||
|
loadTime: "1.2s"
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 1250,
|
||||||
|
likes: 89,
|
||||||
|
shares: 23
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "E-Commerce Platform",
|
||||||
|
description: "A full-stack e-commerce solution with advanced features like real-time inventory, payment processing, and admin dashboard.",
|
||||||
|
content: `# E-Commerce Platform
|
||||||
|
|
||||||
|
A comprehensive e-commerce solution built with modern web technologies, featuring a robust backend, secure payment processing, and an intuitive user interface.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **User Authentication**: Secure login and registration
|
||||||
|
- **Product Management**: Add, edit, and delete products
|
||||||
|
- **Shopping Cart**: Persistent cart with real-time updates
|
||||||
|
- **Payment Processing**: Stripe integration for secure payments
|
||||||
|
- **Order Management**: Complete order lifecycle tracking
|
||||||
|
- **Admin Dashboard**: Comprehensive admin interface
|
||||||
|
- **Inventory Management**: Real-time stock tracking
|
||||||
|
- **Responsive Design**: Mobile-first approach
|
||||||
|
|
||||||
|
## 🛠️ Technologies Used
|
||||||
|
|
||||||
|
- Frontend: React, TypeScript, Tailwind CSS
|
||||||
|
- Backend: Node.js, Express, Prisma
|
||||||
|
- Database: PostgreSQL
|
||||||
|
- Payment: Stripe API
|
||||||
|
- Authentication: JWT, bcrypt
|
||||||
|
- Deployment: Docker, AWS
|
||||||
|
|
||||||
|
## 📈 Development Process
|
||||||
|
|
||||||
|
Built with a focus on scalability and user experience. Implemented proper error handling, input validation, and security measures throughout the development process.
|
||||||
|
|
||||||
|
## 🔮 Future Improvements
|
||||||
|
|
||||||
|
- Multi-language support
|
||||||
|
- Advanced analytics dashboard
|
||||||
|
- AI-powered product recommendations
|
||||||
|
- Mobile app development
|
||||||
|
- Advanced search and filtering`,
|
||||||
|
tags: ["React", "Node.js", "PostgreSQL", "Stripe", "E-commerce", "Full-Stack"],
|
||||||
|
featured: true,
|
||||||
|
category: "Full-Stack",
|
||||||
|
date: "2024",
|
||||||
|
published: true,
|
||||||
|
difficulty: "EXPERT",
|
||||||
|
timeToComplete: "8-10 weeks",
|
||||||
|
technologies: ["React", "Node.js", "PostgreSQL", "Stripe", "Docker", "AWS"],
|
||||||
|
challenges: ["Payment integration", "Real-time updates", "Scalability", "Security"],
|
||||||
|
lessonsLearned: ["Payment processing", "Real-time systems", "Security best practices", "Scalable architecture"],
|
||||||
|
futureImprovements: ["AI recommendations", "Mobile app", "Multi-language", "Advanced analytics"],
|
||||||
|
demoVideo: "",
|
||||||
|
screenshots: [],
|
||||||
|
colorScheme: "Professional and clean",
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 92,
|
||||||
|
bundleSize: "78KB",
|
||||||
|
loadTime: "1.8s"
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 890,
|
||||||
|
likes: 67,
|
||||||
|
shares: 18
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Task Management App",
|
||||||
|
description: "A collaborative task management application with real-time updates, team collaboration, and progress tracking.",
|
||||||
|
content: `# Task Management App
|
||||||
|
|
||||||
|
A collaborative task management application designed for teams to organize, track, and complete projects efficiently.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Task Creation**: Easy task creation with descriptions and deadlines
|
||||||
|
- **Team Collaboration**: Assign tasks to team members
|
||||||
|
- **Real-time Updates**: Live updates across all connected clients
|
||||||
|
- **Progress Tracking**: Visual progress indicators and analytics
|
||||||
|
- **File Attachments**: Support for documents and images
|
||||||
|
- **Notifications**: Email and push notifications for updates
|
||||||
|
- **Mobile Responsive**: Works perfectly on all devices
|
||||||
|
- **Dark/Light Theme**: User preference support
|
||||||
|
|
||||||
|
## 🛠️ Technologies Used
|
||||||
|
|
||||||
|
- Frontend: React, TypeScript, Tailwind CSS
|
||||||
|
- Backend: Node.js, Express, Socket.io
|
||||||
|
- Database: MongoDB
|
||||||
|
- Real-time: WebSockets
|
||||||
|
- Authentication: JWT
|
||||||
|
- File Storage: AWS S3
|
||||||
|
- Deployment: Heroku
|
||||||
|
|
||||||
|
## 📈 Development Process
|
||||||
|
|
||||||
|
Focused on creating an intuitive user interface and seamless real-time collaboration. Implemented proper error handling and user feedback throughout the development.
|
||||||
|
|
||||||
|
## 🔮 Future Improvements
|
||||||
|
|
||||||
|
- Advanced reporting and analytics
|
||||||
|
- Integration with external tools
|
||||||
|
- Mobile app development
|
||||||
|
- AI-powered task suggestions
|
||||||
|
- Advanced automation features`,
|
||||||
|
tags: ["React", "Node.js", "MongoDB", "WebSockets", "Collaboration", "Real-time"],
|
||||||
|
featured: false,
|
||||||
|
category: "Web Application",
|
||||||
|
date: "2024",
|
||||||
|
published: true,
|
||||||
|
difficulty: "INTERMEDIATE",
|
||||||
|
timeToComplete: "6-8 weeks",
|
||||||
|
technologies: ["React", "Node.js", "MongoDB", "Socket.io", "AWS S3", "Heroku"],
|
||||||
|
challenges: ["Real-time synchronization", "Team collaboration", "File management", "Mobile responsiveness"],
|
||||||
|
lessonsLearned: ["WebSocket implementation", "Real-time systems", "File upload handling", "Team collaboration features"],
|
||||||
|
futureImprovements: ["Advanced analytics", "Mobile app", "AI integration", "Automation"],
|
||||||
|
demoVideo: "",
|
||||||
|
screenshots: [],
|
||||||
|
colorScheme: "Modern and clean",
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 88,
|
||||||
|
bundleSize: "65KB",
|
||||||
|
loadTime: "1.5s"
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 567,
|
||||||
|
likes: 34,
|
||||||
|
shares: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Weather Dashboard",
|
||||||
|
description: "A beautiful weather application with real-time data, forecasts, and interactive maps.",
|
||||||
|
content: `# Weather Dashboard
|
||||||
|
|
||||||
|
A beautiful and functional weather application that provides real-time weather data, forecasts, and interactive maps.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Current Weather**: Real-time weather conditions
|
||||||
|
- **Forecast**: 7-day weather predictions
|
||||||
|
- **Interactive Maps**: Visual weather maps with overlays
|
||||||
|
- **Location Search**: Find weather for any location
|
||||||
|
- **Weather Alerts**: Severe weather notifications
|
||||||
|
- **Historical Data**: Past weather information
|
||||||
|
- **Responsive Design**: Works on all devices
|
||||||
|
- **Offline Support**: Basic functionality without internet
|
||||||
|
|
||||||
|
## 🛠️ Technologies Used
|
||||||
|
|
||||||
|
- Frontend: React, TypeScript, Tailwind CSS
|
||||||
|
- Maps: Mapbox GL JS
|
||||||
|
- Weather API: OpenWeatherMap
|
||||||
|
- State Management: Zustand
|
||||||
|
- Charts: Chart.js
|
||||||
|
- Icons: Weather Icons
|
||||||
|
- Deployment: Vercel
|
||||||
|
|
||||||
|
## 📈 Development Process
|
||||||
|
|
||||||
|
Built with a focus on user experience and visual appeal. Implemented proper error handling for API failures and created an intuitive interface for weather information.
|
||||||
|
|
||||||
|
## 🔮 Future Improvements
|
||||||
|
|
||||||
|
- Weather widgets for other websites
|
||||||
|
- Advanced forecasting algorithms
|
||||||
|
- Weather-based recommendations
|
||||||
|
- Social sharing features
|
||||||
|
- Weather photography integration`,
|
||||||
|
tags: ["React", "TypeScript", "Weather API", "Maps", "Real-time", "UI/UX"],
|
||||||
|
featured: false,
|
||||||
|
category: "Web Application",
|
||||||
|
date: "2024",
|
||||||
|
published: true,
|
||||||
|
difficulty: "BEGINNER",
|
||||||
|
timeToComplete: "3-4 weeks",
|
||||||
|
technologies: ["React", "TypeScript", "Tailwind CSS", "Mapbox", "OpenWeatherMap", "Chart.js"],
|
||||||
|
challenges: ["API integration", "Map implementation", "Responsive design", "Error handling"],
|
||||||
|
lessonsLearned: ["External API integration", "Map libraries", "Responsive design", "Error handling"],
|
||||||
|
futureImprovements: ["Advanced forecasting", "Weather widgets", "Social features", "Mobile app"],
|
||||||
|
demoVideo: "",
|
||||||
|
screenshots: [],
|
||||||
|
colorScheme: "Light and colorful",
|
||||||
|
accessibility: true,
|
||||||
|
performance: {
|
||||||
|
lighthouse: 91,
|
||||||
|
bundleSize: "52KB",
|
||||||
|
loadTime: "1.3s"
|
||||||
|
},
|
||||||
|
analytics: {
|
||||||
|
views: 423,
|
||||||
|
likes: 28,
|
||||||
|
shares: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
...project,
|
||||||
|
difficulty: project.difficulty as any,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Created ${projects.length} sample projects`);
|
||||||
|
|
||||||
|
// Create some sample analytics data
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
// Create page views
|
||||||
|
for (let j = 0; j < Math.floor(Math.random() * 100) + 50; j++) {
|
||||||
|
await prisma.pageView.create({
|
||||||
|
data: {
|
||||||
|
projectId: i,
|
||||||
|
page: `/projects/${i}`,
|
||||||
|
ip: `192.168.1.${Math.floor(Math.random() * 255)}`,
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
referrer: 'https://google.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user interactions
|
||||||
|
for (let j = 0; j < Math.floor(Math.random() * 20) + 10; j++) {
|
||||||
|
await prisma.userInteraction.create({
|
||||||
|
data: {
|
||||||
|
projectId: i,
|
||||||
|
type: Math.random() > 0.5 ? 'LIKE' : 'SHARE',
|
||||||
|
ip: `192.168.1.${Math.floor(Math.random() * 255)}`,
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Created sample analytics data');
|
||||||
|
|
||||||
|
console.log('🎉 Database seeding completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error seeding database:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
221
scripts/auto-deploy.sh
Executable file
221
scripts/auto-deploy.sh
Executable file
@@ -0,0 +1,221 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Auto-Deploy Script für Portfolio
|
||||||
|
# Führt automatisch Tests, Build und Deployment durch
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_NAME="portfolio"
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
IMAGE_NAME="portfolio-app"
|
||||||
|
PORT=3000
|
||||||
|
BACKUP_PORT=3001
|
||||||
|
LOG_FILE="/var/log/portfolio-deploy.log"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
error "This script should not be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
error "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "package.json" ] || [ ! -f "Dockerfile" ]; then
|
||||||
|
error "Please run this script from the project root directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "🚀 Starting automatic deployment for $PROJECT_NAME"
|
||||||
|
|
||||||
|
# Step 1: Code Quality Checks
|
||||||
|
log "📋 Step 1: Running code quality checks..."
|
||||||
|
|
||||||
|
# Check for uncommitted changes
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
warning "You have uncommitted changes. Committing them..."
|
||||||
|
git add .
|
||||||
|
git commit -m "Auto-commit before deployment $(date)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pull latest changes
|
||||||
|
log "📥 Pulling latest changes..."
|
||||||
|
git pull origin main || {
|
||||||
|
error "Failed to pull latest changes"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
log "🔍 Running ESLint..."
|
||||||
|
npm run lint || {
|
||||||
|
error "ESLint failed. Please fix the issues before deploying."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
log "🧪 Running tests..."
|
||||||
|
npm run test || {
|
||||||
|
error "Tests failed. Please fix the issues before deploying."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
success "✅ Code quality checks passed"
|
||||||
|
|
||||||
|
# Step 2: Build Application
|
||||||
|
log "🔨 Step 2: Building application..."
|
||||||
|
|
||||||
|
# Build Next.js application
|
||||||
|
log "📦 Building Next.js application..."
|
||||||
|
npm run build || {
|
||||||
|
error "Build failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
success "✅ Application built successfully"
|
||||||
|
|
||||||
|
# Step 3: Docker Operations
|
||||||
|
log "🐳 Step 3: Docker operations..."
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
log "🏗️ Building Docker image..."
|
||||||
|
docker build -t "$IMAGE_NAME:latest" . || {
|
||||||
|
error "Docker build failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tag with timestamp
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
docker tag "$IMAGE_NAME:latest" "$IMAGE_NAME:$TIMESTAMP"
|
||||||
|
|
||||||
|
success "✅ Docker image built successfully"
|
||||||
|
|
||||||
|
# Step 4: Deployment
|
||||||
|
log "🚀 Step 4: Deploying application..."
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
||||||
|
log "📦 Stopping existing container..."
|
||||||
|
docker stop "$CONTAINER_NAME" || true
|
||||||
|
docker rm "$CONTAINER_NAME" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if port is available
|
||||||
|
if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then
|
||||||
|
warning "Port $PORT is in use. Trying backup port $BACKUP_PORT"
|
||||||
|
DEPLOY_PORT=$BACKUP_PORT
|
||||||
|
else
|
||||||
|
DEPLOY_PORT=$PORT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
log "🚀 Starting new container on port $DEPLOY_PORT..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "$DEPLOY_PORT:3000" \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
"$IMAGE_NAME:latest" || {
|
||||||
|
error "Failed to start container"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for container to be ready
|
||||||
|
log "⏳ Waiting for container to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
log "🏥 Performing health check..."
|
||||||
|
HEALTH_CHECK_TIMEOUT=60
|
||||||
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
||||||
|
if curl -f "http://localhost:$DEPLOY_PORT/api/health" > /dev/null 2>&1; then
|
||||||
|
success "✅ Application is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
||||||
|
error "Health check timeout. Application may not be running properly."
|
||||||
|
log "Container logs:"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Verification
|
||||||
|
log "✅ Step 5: Verifying deployment..."
|
||||||
|
|
||||||
|
# Test main page
|
||||||
|
if curl -f "http://localhost:$DEPLOY_PORT/" > /dev/null 2>&1; then
|
||||||
|
success "✅ Main page is accessible"
|
||||||
|
else
|
||||||
|
error "❌ Main page is not accessible"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
log "📊 Container status:"
|
||||||
|
docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
|
# Show resource usage
|
||||||
|
log "📈 Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" "$CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Step 6: Cleanup
|
||||||
|
log "🧹 Step 6: Cleaning up old images..."
|
||||||
|
|
||||||
|
# Remove old images (keep last 3 versions)
|
||||||
|
docker images "$IMAGE_NAME" --format "table {{.Tag}}\t{{.ID}}" | tail -n +2 | head -n -3 | awk '{print $2}' | xargs -r docker rmi || {
|
||||||
|
warning "No old images to remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up unused Docker resources
|
||||||
|
docker system prune -f --volumes || {
|
||||||
|
warning "Failed to clean up Docker resources"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final success message
|
||||||
|
success "🎉 Deployment completed successfully!"
|
||||||
|
log "🌐 Application is available at: http://localhost:$DEPLOY_PORT"
|
||||||
|
log "🏥 Health check endpoint: http://localhost:$DEPLOY_PORT/api/health"
|
||||||
|
log "📊 Container name: $CONTAINER_NAME"
|
||||||
|
log "📝 Logs: docker logs $CONTAINER_NAME"
|
||||||
|
|
||||||
|
# Update deployment log
|
||||||
|
echo "$(date): Deployment successful - Port: $DEPLOY_PORT - Image: $IMAGE_NAME:$TIMESTAMP" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
exit 0
|
||||||
160
scripts/deploy.sh
Executable file
160
scripts/deploy.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Portfolio Deployment Script
|
||||||
|
# Usage: ./scripts/deploy.sh [environment]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ENVIRONMENT=${1:-production}
|
||||||
|
REGISTRY="ghcr.io"
|
||||||
|
IMAGE_NAME="dennis-konkol/my_portfolio"
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
error "This script should not be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
error "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if docker-compose is available
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
error "docker-compose is not installed. Please install docker-compose and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
error ".env file not found. Please create it with the required environment variables."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting deployment for environment: $ENVIRONMENT"
|
||||||
|
|
||||||
|
# Set image tag based on environment
|
||||||
|
if [ "$ENVIRONMENT" = "production" ]; then
|
||||||
|
IMAGE_TAG="production"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$IMAGE_TAG"
|
||||||
|
|
||||||
|
log "Using image: $FULL_IMAGE_NAME"
|
||||||
|
|
||||||
|
# Login to registry (if needed)
|
||||||
|
log "Logging in to container registry..."
|
||||||
|
echo "$GITHUB_TOKEN" | docker login $REGISTRY -u $GITHUB_ACTOR --password-stdin || {
|
||||||
|
warning "Failed to login to registry. Make sure GITHUB_TOKEN and GITHUB_ACTOR are set."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
log "Pulling latest image..."
|
||||||
|
docker pull $FULL_IMAGE_NAME || {
|
||||||
|
error "Failed to pull image $FULL_IMAGE_NAME"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop and remove old containers
|
||||||
|
log "Stopping old containers..."
|
||||||
|
docker-compose -f $COMPOSE_FILE down || {
|
||||||
|
warning "No old containers to stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove old images (keep last 3 versions)
|
||||||
|
log "Cleaning up old images..."
|
||||||
|
docker images $REGISTRY/$IMAGE_NAME --format "table {{.Tag}}\t{{.ID}}" | tail -n +2 | head -n -3 | awk '{print $2}' | xargs -r docker rmi || {
|
||||||
|
warning "No old images to remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start new containers
|
||||||
|
log "Starting new containers..."
|
||||||
|
docker-compose -f $COMPOSE_FILE up -d || {
|
||||||
|
error "Failed to start containers"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
log "Waiting for application to be healthy..."
|
||||||
|
HEALTH_CHECK_TIMEOUT=60
|
||||||
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Application is healthy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
||||||
|
error "Health check timeout. Application may not be running properly."
|
||||||
|
log "Container logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
log "Verifying deployment..."
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Deployment successful!"
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
log "Container status:"
|
||||||
|
docker-compose -f $COMPOSE_FILE ps
|
||||||
|
|
||||||
|
# Show resource usage
|
||||||
|
log "Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
||||||
|
|
||||||
|
else
|
||||||
|
error "Deployment verification failed!"
|
||||||
|
log "Container logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
log "Cleaning up unused Docker resources..."
|
||||||
|
docker system prune -f --volumes || {
|
||||||
|
warning "Failed to clean up Docker resources"
|
||||||
|
}
|
||||||
|
|
||||||
|
success "Deployment completed successfully!"
|
||||||
|
log "Application is available at: http://localhost:3000"
|
||||||
|
log "Health check endpoint: http://localhost:3000/api/health"
|
||||||
167
scripts/monitor.sh
Executable file
167
scripts/monitor.sh
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Portfolio Monitoring Script
|
||||||
|
# Usage: ./scripts/monitor.sh [action]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging function
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check container health
|
||||||
|
check_health() {
|
||||||
|
log "Checking application health..."
|
||||||
|
|
||||||
|
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||||
|
success "Application is healthy"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
error "Application is unhealthy"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show container status
|
||||||
|
show_status() {
|
||||||
|
log "Container status:"
|
||||||
|
docker-compose -f $COMPOSE_FILE ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Resource usage:"
|
||||||
|
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log "Container logs (last 20 lines):"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=20
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show detailed metrics
|
||||||
|
show_metrics() {
|
||||||
|
log "Detailed metrics:"
|
||||||
|
|
||||||
|
# Container info
|
||||||
|
echo "=== Container Information ==="
|
||||||
|
docker inspect $CONTAINER_NAME --format='{{.State.Status}} - {{.State.StartedAt}}' 2>/dev/null || echo "Container not found"
|
||||||
|
|
||||||
|
# Memory usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Memory Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.MemUsage}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# CPU usage
|
||||||
|
echo ""
|
||||||
|
echo "=== CPU Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.CPUPerc}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# Network usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Network Usage ==="
|
||||||
|
docker stats --no-stream --format "{{.NetIO}}" $CONTAINER_NAME 2>/dev/null || echo "Container not running"
|
||||||
|
|
||||||
|
# Disk usage
|
||||||
|
echo ""
|
||||||
|
echo "=== Disk Usage ==="
|
||||||
|
docker system df
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
restart_container() {
|
||||||
|
log "Restarting container..."
|
||||||
|
docker-compose -f $COMPOSE_FILE restart
|
||||||
|
|
||||||
|
# Wait for health check
|
||||||
|
log "Waiting for container to be healthy..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
if check_health; then
|
||||||
|
success "Container restarted successfully"
|
||||||
|
else
|
||||||
|
error "Container restart failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show logs
|
||||||
|
show_logs() {
|
||||||
|
local lines=${1:-50}
|
||||||
|
log "Showing last $lines lines of logs:"
|
||||||
|
docker-compose -f $COMPOSE_FILE logs --tail=$lines -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
cleanup() {
|
||||||
|
log "Cleaning up Docker resources..."
|
||||||
|
|
||||||
|
# Remove unused containers
|
||||||
|
docker container prune -f
|
||||||
|
|
||||||
|
# Remove unused images
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# Remove unused volumes
|
||||||
|
docker volume prune -f
|
||||||
|
|
||||||
|
# Remove unused networks
|
||||||
|
docker network prune -f
|
||||||
|
|
||||||
|
success "Cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script logic
|
||||||
|
case "${1:-status}" in
|
||||||
|
"health")
|
||||||
|
check_health
|
||||||
|
;;
|
||||||
|
"status")
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
"metrics")
|
||||||
|
show_metrics
|
||||||
|
;;
|
||||||
|
"restart")
|
||||||
|
restart_container
|
||||||
|
;;
|
||||||
|
"logs")
|
||||||
|
show_logs $2
|
||||||
|
;;
|
||||||
|
"cleanup")
|
||||||
|
cleanup
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {health|status|metrics|restart|logs|cleanup}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " health - Check application health"
|
||||||
|
echo " status - Show container status and resource usage"
|
||||||
|
echo " metrics - Show detailed metrics"
|
||||||
|
echo " restart - Restart the container"
|
||||||
|
echo " logs - Show container logs (optional: number of lines)"
|
||||||
|
echo " cleanup - Clean up unused Docker resources"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
63
scripts/quick-deploy.sh
Executable file
63
scripts/quick-deploy.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Quick Deploy Script für lokale Entwicklung
|
||||||
|
# Schnelles Deployment ohne umfangreiche Tests
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="portfolio-app"
|
||||||
|
IMAGE_NAME="portfolio-app"
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "${BLUE}[$(date +'%H:%M:%S')]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "🚀 Quick deployment starting..."
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
log "🏗️ Building Docker image..."
|
||||||
|
docker build -t "$IMAGE_NAME:latest" .
|
||||||
|
|
||||||
|
# Stop existing container
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
|
||||||
|
log "🛑 Stopping existing container..."
|
||||||
|
docker stop "$CONTAINER_NAME"
|
||||||
|
docker rm "$CONTAINER_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start new container
|
||||||
|
log "🚀 Starting new container..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "$PORT:3000" \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
"$IMAGE_NAME:latest"
|
||||||
|
|
||||||
|
# Wait and check health
|
||||||
|
log "⏳ Waiting for container to be ready..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
if curl -f "http://localhost:$PORT/api/health" > /dev/null 2>&1; then
|
||||||
|
success "✅ Application is running at http://localhost:$PORT"
|
||||||
|
else
|
||||||
|
error "❌ Health check failed"
|
||||||
|
docker logs "$CONTAINER_NAME" --tail=20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
105
scripts/setup-db.sh
Executable file
105
scripts/setup-db.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Setting up local PostgreSQL database for Portfolio..."
|
||||||
|
|
||||||
|
# Check if PostgreSQL is installed
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
echo "📦 PostgreSQL not found. Installing..."
|
||||||
|
|
||||||
|
# Detect OS and install PostgreSQL
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
# Ubuntu/Debian
|
||||||
|
if command -v apt-get &> /dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql postgresql-contrib
|
||||||
|
# CentOS/RHEL
|
||||||
|
elif command -v yum &> /dev/null; then
|
||||||
|
sudo yum install -y postgresql postgresql-server postgresql-contrib
|
||||||
|
sudo postgresql-setup initdb
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
# Arch Linux
|
||||||
|
elif command -v pacman &> /dev/null; then
|
||||||
|
sudo pacman -S postgresql
|
||||||
|
sudo -u postgres initdb -D /var/lib/postgres/data
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
else
|
||||||
|
echo "❌ Unsupported Linux distribution. Please install PostgreSQL manually."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# macOS
|
||||||
|
if command -v brew &> /dev/null; then
|
||||||
|
brew install postgresql
|
||||||
|
brew services start postgresql
|
||||||
|
else
|
||||||
|
echo "❌ Homebrew not found. Please install Homebrew first: https://brew.sh/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Unsupported OS. Please install PostgreSQL manually."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✅ PostgreSQL already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start PostgreSQL service
|
||||||
|
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
brew services start postgresql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database and user
|
||||||
|
echo "🔧 Setting up database..."
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE portfolio_db;" 2>/dev/null || echo "Database already exists"
|
||||||
|
sudo -u postgres psql -c "CREATE USER portfolio_user WITH PASSWORD 'portfolio_pass';" 2>/dev/null || echo "User already exists"
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE portfolio_db TO portfolio_user;" 2>/dev/null || echo "Privileges already granted"
|
||||||
|
sudo -u postgres psql -c "ALTER USER portfolio_user WITH SUPERUSER;" 2>/dev/null || echo "Superuser already granted"
|
||||||
|
|
||||||
|
# Create .env.local file
|
||||||
|
echo "📝 Creating environment file..."
|
||||||
|
cat > .env.local << EOF
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL="postgresql://portfolio_user:portfolio_pass@localhost:5432/portfolio_db?schema=public"
|
||||||
|
|
||||||
|
# Next.js Configuration
|
||||||
|
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Optional: Analytics
|
||||||
|
GOOGLE_ANALYTICS_ID=""
|
||||||
|
GOOGLE_TAG_MANAGER_ID=""
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Environment file created: .env.local"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
echo "🔧 Generating Prisma client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
echo "🗄️ Running database migrations..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# Seed database with sample data
|
||||||
|
echo "🌱 Seeding database with sample data..."
|
||||||
|
npx prisma db seed
|
||||||
|
|
||||||
|
echo "🎉 Database setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Next steps:"
|
||||||
|
echo "1. Start your development server: npm run dev"
|
||||||
|
echo "2. Visit http://localhost:3000/admin to manage projects"
|
||||||
|
echo "3. Your database is running at localhost:5432"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 Database commands:"
|
||||||
|
echo "- View database: npx prisma studio"
|
||||||
|
echo "- Reset database: npx prisma db push --force-reset"
|
||||||
|
echo "- Generate client: npx prisma generate"
|
||||||
Reference in New Issue
Block a user