🔀 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:
Dennis Konkol
2025-09-05 19:48:23 +00:00
52 changed files with 10119 additions and 1573 deletions

8
.eslintrc.build.json Normal file
View 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"
}
}

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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`

View File

@@ -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
View 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
View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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(),
});
}

View File

@@ -4,70 +4,168 @@ 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) {
const body = (await request.json()) as { try {
email: string; const body = (await request.json()) as {
name: string; email: string;
message: string; name: string;
}; subject: string;
const { email, name, message } = body; message: string;
};
const { email, name, subject, message } = body;
const user = process.env.MY_EMAIL ?? ""; console.log('📧 Email request received:', { email, name, subject, messageLength: message.length });
const pass = process.env.MY_PASSWORD ?? "";
if (!user || !pass) { // Validate input
console.error("Missing email/password environment variables"); if (!email || !name || !subject || !message) {
return NextResponse.json( console.error('❌ Validation failed: Missing required fields');
{ error: "Missing EMAIL or PASSWORD" }, return NextResponse.json(
{ status: 500 }, { error: "Alle Felder sind erforderlich" },
); { status: 400 },
} );
}
if (!email || !name || !message) { // Validate email format
console.error("Invalid request body"); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return NextResponse.json( if (!emailRegex.test(email)) {
{ error: "Invalid request body" }, console.error('❌ Validation failed: Invalid email format');
{ status: 400 }, return NextResponse.json(
); { error: "Ungültige E-Mail-Adresse" },
} { status: 400 },
);
}
const transportOptions: SMTPTransport.Options = { // Validate message length
host: "smtp.ionos.de", if (message.length < 10) {
port: 587, console.error('❌ Validation failed: Message too short');
secure: false, return NextResponse.json(
requireTLS: true, { error: "Nachricht muss mindestens 10 Zeichen lang sein" },
auth: { { status: 400 },
type: "login", );
user, }
pass,
},
};
const transport = nodemailer.createTransport(transportOptions); const user = process.env.MY_EMAIL ?? "";
const pass = process.env.MY_PASSWORD ?? "";
const mailOptions: Mail.Options = { console.log('🔑 Environment check:', {
from: user, hasEmail: !!user,
to: user, // Ensure this is the correct email address hasPassword: !!pass,
subject: `Message from ${name} (${email})`, emailHost: user.split('@')[1] || 'unknown'
text: message + "\n\n" + email, });
};
if (!user || !pass) {
const sendMailPromise = () => console.error("❌ Missing email/password environment variables");
new Promise<string>((resolve, reject) => { return NextResponse.json(
transport.sendMail(mailOptions, function (err, info) { { error: "E-Mail-Server nicht konfiguriert" },
if (!err) { { status: 500 },
resolve(info.response); );
} else { }
console.error("Error sending email:", err);
reject(err.message); const transportOptions: SMTPTransport.Options = {
} host: "smtp.ionos.de",
}); port: 587,
secure: false,
requireTLS: true,
auth: {
type: "login",
user,
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);
// 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 = {
from: `"Portfolio Contact" <${user}>`,
to: "contact@dki.one", // Send to your contact email
replyTo: 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 = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
console.log('✅ Email sent successfully:', info.response);
resolve(info.response);
} else {
console.error("❌ Error sending email:", err);
reject(err.message);
}
});
});
const result = await sendMailPromise();
console.log('🎉 Email process completed successfully');
return NextResponse.json({
message: "E-Mail erfolgreich gesendet",
messageId: result
}); });
try {
await sendMailPromise();
return NextResponse.json({ message: "Email sent" });
} 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
View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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",
});
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
return;
}
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 submitButton = form.querySelector("button[type='submit']");
if (submitButton) {
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({ } catch (error) {
show: true, console.error('Error sending email:', error);
message: response.message, showEmailError('Netzwerkfehler beim Senden der E-Mail');
type: response.success ? "success" : "error", } finally {
}); setIsSubmitting(false);
setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000);
} }
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
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
<h2 className="text-4xl font-bold text-center text-gray-900 mb-8"> initial={{ opacity: 0, y: 30 }}
Get in Touch whileInView={{ opacity: 1, y: 0 }}
</h2> viewport={{ once: true }}
<div className="bg-white/30 p-8 rounded-3xl shadow-xl max-w-lg mx-auto"> transition={{ duration: 0.8 }}
{banner.show && ( className="text-center mb-16"
<div >
className={`mb-4 text-center rounded-full py-2 px-4 text-white ${ <h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
banner.type === "success" ? "bg-green-500" : "bg-red-500" Get In Touch
}`} </h2>
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
Have a project in mind or want to collaborate? I would love to hear from you!
</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Information */}
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="space-y-8"
> >
{banner.message} <div>
</div> <h3 className="text-2xl font-bold text-white mb-6">
)} Let&apos;s Connect
<form className="space-y-6" onSubmit={onSubmit}> </h3>
{/* Honeypot field */} <p className="text-gray-400 leading-relaxed">
<input I&apos;m always open to discussing new opportunities, interesting projects,
type="text" or just having a chat about technology and innovation.
name="hp-field" </p>
style={{ display: "none" }} </div>
autoComplete="off"
/>
{/* Hidden timestamp field */}
<input
type="hidden"
name="timestamp"
value={formLoadedTimestamp.toString()}
/>
<div> {/* Contact Details */}
<label <div className="space-y-4">
htmlFor="name" {contactInfo.map((info, index) => (
className="block text-sm font-medium text-gray-700 dark:text-gray-300" <motion.a
> key={info.title}
Name href={info.href}
</label> initial={{ opacity: 0, x: -20 }}
<input whileInView={{ opacity: 1, x: 0 }}
type="text" viewport={{ once: true }}
name="name" transition={{ duration: 0.6, delay: index * 0.1 }}
id="name" whileHover={{ x: 5 }}
placeholder="Your Name" className="flex items-center space-x-4 p-4 rounded-lg glass-card hover:bg-gray-800/30 transition-colors group"
required >
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm" <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>
<div>
<h4 className="font-semibold text-white">{info.title}</h4>
<p className="text-gray-400">{info.value}</p>
</div>
</motion.a>
))}
</div>
<div> {/* Social Links */}
<label <div>
htmlFor="email" <h4 className="text-lg font-semibold text-white mb-4">Follow Me</h4>
className="block text-sm font-medium text-gray-700 dark:text-gray-300" <div className="flex space-x-4">
> {socialLinks.map((social) => (
Email <motion.a
</label> key={social.label}
<input href={social.href}
type="email" target="_blank"
name="email" rel="noopener noreferrer"
id="email" whileHover={{ scale: 1.1, y: -2 }}
placeholder="you@example.com" whileTap={{ scale: 0.95 }}
required className="p-3 bg-gray-800/50 hover:bg-gray-700/50 rounded-lg text-gray-300 hover:text-white transition-colors"
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm" >
/> <social.icon size={20} />
</div> </motion.a>
))}
</div>
</div>
</motion.div>
<div> {/* Contact Form */}
<label <motion.div
htmlFor="message" initial={{ opacity: 0, x: 30 }}
className="block text-sm font-medium text-gray-700 dark:text-gray-300" whileInView={{ opacity: 1, x: 0 }}
> viewport={{ once: true }}
Message transition={{ duration: 0.8 }}
</label> className="glass-card p-8 rounded-2xl"
<textarea
name="message"
id="message"
placeholder="Your Message..."
rows={5}
required
className="mt-1 bg-white/60 block w-full p-3 border border-gray-300 rounded-lg shadow-sm "
></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>
<button
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"
> >
Send Message <h3 className="text-2xl font-bold text-white mb-6">Send Message</h3>
</button>
</form> <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
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
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="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
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="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>
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-2">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
rows={5}
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"
placeholder="Tell me more about your project..."
/>
</div>
<motion.button
type="submit"
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"
>
{isSubmitting ? (
<>
<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>
</motion.div>
</div>
</div> </div>
</section> </section>
); );
} };
export default Contact;

View File

@@ -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={`flex flex-col md:flex-row items-center justify-between`}> <div className="relative z-10 max-w-7xl mx-auto">
<div className={`flex-col items-center`}> <div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
<h1 className="md:text-xl font-bold">Thank You for Visiting</h1> <div className="md:col-span-2">
<p className="md:mt-1 text-lg"> <motion.div
Connect with me on social platforms: initial={{ opacity: 0, y: 20 }}
</p> whileInView={{ opacity: 1, y: 0 }}
<div className="flex justify-center items-center space-x-4 mt-4"> viewport={{ once: true }}
<Link transition={{ duration: 0.6 }}
aria-label={"Dennis Github"}
href="https://github.com/Denshooter"
target="_blank"
> >
<svg <Link href="/" className="text-3xl font-bold gradient-text mb-4 inline-block">
className="w-10 h-10" Dennis Konkol
fill="currentColor" </Link>
viewBox="0 0 24 24" <p className="text-gray-400 mb-6 max-w-md leading-relaxed">
> A passionate software engineer and student based in Osnabrück, Germany.
<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" /> Creating innovative solutions that make a difference in the digital world.
</svg> </p>
</Link>
<Link <div className="flex space-x-4">
aria-label={"Dennis Linked In"} {socialLinks.map((social) => (
href="https://linkedin.com/in/dkonkol" <motion.a
target="_blank" key={social.label}
> href={social.href}
<svg target="_blank"
className="w-10 h-10" rel="noopener noreferrer"
fill="currentColor" whileHover={{ scale: 1.1, y: -2 }}
viewBox="0 0 24 24" 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"
<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> <social.icon size={20} />
</Link> </motion.a>
</div> ))}
</div> </div>
<div className="mt-4 md:absolute md:left-1/2 md:transform md:-translate-x-1/2"> </motion.div>
<button
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"
>
Back to Top
</button>
</div>
<div className="flex-col">
<div className="mt-4">
<Link
href="/privacy-policy"
className="text-blue-800 transition-underline"
>
Privacy Policy
</Link>
<Link
href="/legal-notice"
className="ml-4 text-blue-800 transition-underline"
>
Legal Notice
</Link>
</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.1 }}
>
<h3 className="text-lg font-semibold text-white mb-4">Quick Links</h3>
<ul className="space-y-2">
{quickLinks.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-gray-400 hover:text-white transition-colors duration-200"
>
{link.name}
</Link>
</li>
))}
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h3 className="text-lg font-semibold text-white mb-4">Legal</h3>
<ul className="space-y-2">
<li>
<Link
href="/legal-notice"
className="text-gray-400 hover:text-white transition-colors duration-200"
>
Impressum
</Link>
</li>
<li>
<Link
href="/privacy-policy"
className="text-gray-400 hover:text-white transition-colors duration-200"
>
Privacy Policy
</Link>
</li>
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<h3 className="text-lg font-semibold text-white mb-4">Contact</h3>
<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>
<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>
</motion.div>
</div> </div>
</footer> </footer>
); );
} };
export default Footer;

View File

@@ -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 = () => {
setScrolled(window.scrollY > 50);
};
const toggleSidebar = () => { window.addEventListener('scroll', handleScroll);
setIsSidebarOpen(!isSidebarOpen); return () => window.removeEventListener('scroll', handleScroll);
}; }, []);
const scrollToSection = (id: string) => { const navItems = [
const element = document.getElementById(id); { name: 'Home', href: '/' },
if (element) { { name: 'Projects', href: '/projects' },
element.scrollIntoView({ behavior: "smooth" }); { name: 'About', href: '#about' },
} else { { name: 'Contact', href: '#contact' },
/*go to main page and scroll*/ ];
window.location.href = `/#${id}`;
} 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 <div className="particles">
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" : ""}`} {[...Array(20)].map((_, i) => (
<div
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 }}
</Link> className="flex items-center space-x-2"
<div className="flex-grow"></div>
<button
className="text-gray-700 hover:text-gray-900 md:hidden"
onClick={toggleSidebar}
aria-label={"Open menu"}
> >
<svg <Link href="/" className="text-2xl font-bold gradient-text">
className="w-6 h-6" DK
fill="none" </Link>
stroke="currentColor" </motion.div>
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" <nav className="hidden md:flex items-center space-x-8">
> {navItems.map((item) => (
<path <motion.div
strokeLinecap="round" key={item.name}
strokeLinejoin="round" whileHover={{ y: -2 }}
strokeWidth="2" whileTap={{ scale: 0.95 }}
d="M4 6h16M4 12h16M4 18h16" >
/> <Link
</svg> href={item.href}
</button> className="text-gray-300 hover:text-white transition-colors duration-200 font-medium relative group"
<div className="hidden md:flex space-x-4 md:space-x-6"> >
<button {item.name}
onClick={() => scrollToSection("about")} <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>
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group" </Link>
> </motion.div>
About ))}
</button> </nav>
<button
onClick={() => scrollToSection("projects")} <div className="hidden md:flex items-center space-x-4">
className="relative px-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group" {socialLinks.map((social) => (
> <motion.a
Projects key={social.label}
</button> href={social.href}
<button target="_blank"
onClick={() => scrollToSection("contact")} rel="noopener noreferrer"
className="relative pl-4 py-2 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group" whileHover={{ scale: 1.1, y: -2 }}
> whileTap={{ scale: 0.95 }}
Contact className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors duration-200 text-gray-300 hover:text-white"
</button> >
<social.icon size={20} />
</motion.a>
))}
</div> </div>
</nav>
</header>
</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"
>
<div {isOpen ? <X size={24} /> : <Menu size={24} />}
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"}`} </motion.button>
> </div>
<button
aria-label={"Close menu"}
className="absolute top-4 right-4 text-gray-700 hover:text-gray-900"
onClick={toggleSidebar}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div className="pt-8 space-y-4 flex-grow">
<button
onClick={() => scrollToSection("about")}
className="w-full px-4 py-2 pt-8 text-gray-700 hover:text-gray-900 text-xl md:text-2xl group"
>
About
</button>
<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> </div>
<p className="text-center text-xs text-gray-500 p-4">© 2025 Dennis</p>
</div> <AnimatePresence>
</div> {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"
>
<div className="px-4 py-6 space-y-4">
{navItems.map((item) => (
<motion.div
key={item.name}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: navItems.indexOf(item) * 0.1 }}
>
<Link
href={item.href}
onClick={() => setIsOpen(false)}
className="block text-gray-300 hover:text-white transition-colors duration-200 font-medium py-2"
>
{item.name}
</Link>
</motion.div>
))}
<div className="pt-4 border-t border-gray-700">
<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 }}
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"
>
<social.icon size={20} />
</motion.a>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.header>
</>
); );
} };
export default Header;

View File

@@ -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
}, []); }, []);
return ( const features = [
<div { icon: Code, text: 'Full-Stack Development' },
id="about" { icon: Zap, text: 'Modern Technologies' },
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"}`} { icon: Rocket, text: 'Innovative Solutions' },
];
if (!mounted) {
return null;
}
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 animated-bg"></div>
{/* Floating Elements */}
<div className="absolute inset-0 overflow-hidden">
<motion.div
className="absolute top-20 left-20 w-32 h-32 bg-blue-500/10 rounded-full blur-xl"
initial={{ scale: 1, opacity: 0.3 }}
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute top-40 right-32 w-24 h-24 bg-purple-500/10 rounded-full blur-xl"
initial={{ scale: 1.2, opacity: 0.6 }}
animate={{
scale: [1.2, 1, 1.2],
opacity: [0.6, 0.3, 0.6],
}}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
className="absolute bottom-32 left-1/3 w-40 h-40 bg-cyan-500/10 rounded-full blur-xl"
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 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 <div className="relative group">
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"> {/* Glowing border effect */}
<h1 className="text-4xl md:text-5xl font-extrabold text-gray-900"> <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>
Hi, Im Dennis
</h1> {/* Profile image container */}
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-gray-700"> <div className="relative bg-gray-900 rounded-full p-1">
Student & Software Engineer <motion.div
</h2> whileHover={{ scale: 1.05, rotateY: 5 }}
<h3 className="mt-1 text-lg md:text-xl text-gray-600"> transition={{ duration: 0.3 }}
Based in Osnabrück, Germany 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"
</h3> >
<p className="mt-6 text-gray-800 text-lg leading-relaxed">
Passionate about technology, coding, and solving real-world problems.
I enjoy building innovative solutions and continuously expanding my
knowledge.
</p>
<p className="mt-4 text-gray-700 text-base">
Currently working on exciting projects that merge creativity with
functionality. Always eager to learn and collaborate!
</p>
</div>
<div className="flex mt-8 md:mt-0 md:ml-12">
<Image <Image
src="/images/me.jpg" src="/images/me.jpg"
alt="Image of Dennis" alt="Dennis Konkol - Software Engineer"
width={400} fill
height={400} className="object-cover"
className="rounded-2xl shadow-lg shadow-gray-700 object-cover" priority
loading="lazy" // Lazy Loading
style={{width: "auto", height: "400px"}}
sizes="(max-width: 640px) 640px, 828px" // Definiere, welche Bildgröße bei welcher Bildschirmgröße geladen wird
/> />
{/* 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>
</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;

View File

@@ -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 }}
<h2 className="text-4xl font-bold text-center text-gray-900">Projects</h2> whileInView={{ opacity: 1, y: 0 }}
<div className="mt-6"> viewport={{ once: true }}
{isVisible && ( transition={{ duration: 0.8 }}
<ResponsiveMasonry className="text-center mb-16"
columnsCountBreakPoints={{ 350: 1, 750: 2, 900: 3 }} >
> <h2 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
<Masonry gutter="16px"> Featured Projects
{projects.map((project, index) => ( </h2>
<Link <p className="text-xl text-gray-400 max-w-2xl mx-auto">
key={project.id} Here are some of my recent projects that showcase my skills and passion for creating innovative solutions.
href={{ </p>
pathname: `/projects/${project.slug}`, </motion.div>
query: { project: JSON.stringify(project) },
}} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
className="cursor-pointer" {projects.map((project, index) => (
> <motion.div
<div key={project.id}
className="project-card" initial={{ opacity: 0, y: 30 }}
style={{ animationDelay: `${index * 0.1}s` }} whileInView={{ opacity: 1, y: 0 }}
> viewport={{ once: true }}
<h3 className="text-2xl font-bold text-gray-800"> transition={{ duration: 0.6, delay: index * 0.1 }}
{project.title} whileHover={{ y: -10 }}
</h3> className={`group relative overflow-hidden rounded-2xl glass-card card-hover ${
<p className="mt-2 text-gray-500"> project.featured ? 'ring-2 ring-blue-500/50' : ''
{project.meta_description} }`}
</p> >
<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> </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"
>
<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}
</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> </Link>
))} </div>
</Masonry> </motion.div>
</ResponsiveMasonry> ))}
)} </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;

View File

@@ -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 {
margin: 0; background-color: hsl(var(--background));
padding: 0; color: hsl(var(--foreground));
position: relative; font-family: 'Inter', sans-serif;
min-height: 100vh; margin: 0;
padding: 0;
position: relative;
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;
font-style: italic; border-radius: 8px;
background-color: #f9f9f9; font-style: italic;
padding: 1rem; color: #e5e7eb !important;
border-radius: 4px;
} }
.bg-radiant-animated { .markdown code {
background: radial-gradient(circle at 20% 20%, #ff8185, transparent 25%), background: rgba(59, 130, 246, 0.1);
radial-gradient(circle at 80% 80%, #ffaa91, transparent 25%), color: #3b82f6 !important;
radial-gradient(circle at 50% 50%, #fb7fd9, transparent 25%), padding: 0.2rem 0.4rem;
radial-gradient(circle at 30% 70%, #9b6fff, transparent 25%), border-radius: 4px;
radial-gradient(circle at 70% 30%, #ff8edf, transparent 25%); font-size: 0.9em;
background-size: 200% 200%;
animation: backgroundAnimation 60s ease infinite alternate;
} }
.bg-radiant { .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: cover; margin: 1.5rem 0;
} }
@keyframes backgroundAnimation { .markdown pre code {
0% { background: none;
background-position: 0 0; color: #ffffff !important;
} padding: 0;
100% {
background-position: 100% 100%;
}
} }
.min-h-screen { .markdown a {
min-height: 100vh; color: #3b82f6 !important;
text-decoration: underline;
transition: color 0.2s ease;
} }
.flex { .markdown a:hover {
display: flex; color: #1d4ed8 !important;
} }
.flex-col { .markdown strong {
flex-direction: column; color: #ffffff !important;
font-weight: 600;
} }
.flex-grow { .markdown em {
flex-grow: 1; color: #e5e7eb !important;
font-style: italic;
} }
.react-cookie-consent .content-wrapper { /* Button Styles */
flex: 1; .btn-primary {
margin-right: 1rem; background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
border: none;
cursor: pointer;
position: relative;
overflow: hidden;
} }
.react-cookie-consent .button-wrapper { .btn-primary:hover {
display: flex; transform: translateY(-2px);
flex-direction: column; box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
align-items: center;
} }
@media (min-width: 768px) { .btn-primary::before {
.react-cookie-consent .button-wrapper { content: '';
flex-direction: row; position: absolute;
} top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
} }
.transition-underline { .btn-primary:hover::before {
position: relative; left: 100%;
display: inline-block;
} }
.transition-underline::after { /* Card Hover Effects */
content: ''; .card-hover {
position: absolute; transition: all 0.3s ease;
left: 0; cursor: pointer;
bottom: -2px; }
width: 100%;
height: 2px; .card-hover:hover {
background-color: currentColor; 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; opacity: 1;
transform: translateY(0); transform: translateY(0);
}
} }
.fade-in { .fade-in-up {
opacity: 1 !important; animation: fadeInUp 0.6s ease-out;
transition: opacity 0.5s ease;
} }
.fade-out { /* Responsive Design */
opacity: 0; @media (max-width: 768px) {
transition: opacity 0.5s ease; .markdown h1 {
} font-size: 2rem;
}
@keyframes flyIn { .markdown h2 {
0% { font-size: 1.75rem;
opacity: 0; }
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fly-in { .markdown h3 {
animation: flyIn 1s ease-in-out; font-size: 1.25rem;
} }
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-fade-out {
animation: fadeOut 3s forwards;
}
.project-card {
display: flex;
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);
} }

View File

@@ -1,64 +1,64 @@
// 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"],
}); });
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
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 <meta charSet="utf-8"/>
src="https://umami.denshooter.de/script.js" <title>Dennis Konkol&#39;s Portfolio</title>
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142" </head>
></script> <body className={inter.variable}>
<meta charSet="utf-8"/> <AnalyticsProvider>
<title>Dennis Konkol&#39;s Portfolio</title> <ToastProvider>
</head> {children}
<body className={roboto.variable}>{children}</body> <PerformanceDashboard />
</html> </ToastProvider>
); </AnalyticsProvider>
</body>
</html>
);
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Dennis Konkol | Portfolio",
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
authors: [{name: "Dennis Konkol", url: "https://dki.one"}],
openGraph: {
title: "Dennis Konkol | Portfolio", title: "Dennis Konkol | Portfolio",
description: "Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.", description: "Explore my projects and get in touch!",
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"], url: "https://dki.one",
authors: [{name: "Dennis Konkol", url: "https://dki.one"}], siteName: "Dennis Konkol Portfolio",
openGraph: { images: [
title: "Dennis Konkol | Portfolio", {
description: "Explore my projects and get in touch!", url: "https://dki.one/api/og",
url: "https://dki.one", width: 1200,
siteName: "Dennis Konkol Portfolio", height: 630,
images: [ alt: "Dennis Konkol Portfolio",
{ },
url: "https://dki.one/api/og", ],
width: 1200, type: "website",
height: 630, },
alt: "Dennis Konkol Portfolio", twitter: {
}, card: "summary_large_image",
], title: "Dennis Konkol | Portfolio",
type: "website", description: "Student & Software Engineer based in Osnabrück, Germany.",
}, images: ["https://dki.one/api/og"],
twitter: { },
card: "summary_large_image",
title: "Dennis Konkol | Portfolio",
description: "Student & Software Engineer based in Osnabrück, Germany.",
images: ["https://dki.one/api/og"],
},
}; };

View File

@@ -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
</p> Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum Zeitpunkt der Verlinkung
<p className="font-semibold mt-6">Letzte Aktualisierung: 12.02.2025</p> auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
</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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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. >
</p> <motion.a
<h2 className="text-2xl font-semibold mt-6"> href="/"
Verantwortlicher für die Datenverarbeitung className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
</h2> >
<p className="mt-2"> <ArrowLeft size={20} />
<strong>Name:</strong> Dennis Konkol <br /> <span>Back to Home</span>
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, </motion.a>
Deutschland <br />
<strong>E-Mail:</strong>{" "} <h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
<Link className="transition-underline" href={"mailto:info@dki.one"}> Datenschutzerklärung
info@dki.one </h1>
</Link>{" "} </motion.div>
<br />
<strong>Website:</strong>{" "} <motion.div
<Link className="transition-underline" href={"https://www.dki.one"}> initial={{ opacity: 0, y: 30 }}
{" "} animate={{ opacity: 1, y: 0 }}
dki.one{" "} transition={{ duration: 0.8, delay: 0.2 }}
</Link>{" "} className="glass-card p-8 rounded-2xl space-y-6 text-white"
<br /> >
<br /> <div>
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener <p className="text-gray-300 leading-relaxed">
Daten durch den oben genannten Verantwortlichen. Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
</p> über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
</p>
</div>
<div>
<h2 className="text-2xl font-semibold text-white mb-4">
Verantwortlicher für die Datenverarbeitung
</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 className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dki.one">info@dki.one</Link></p>
<p><strong>Website:</strong> <Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dki.one">dki.one</Link></p>
</div>
<p className="text-gray-300 leading-relaxed mt-4">
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.
</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>
); );
} }

View File

@@ -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);
}, 150); // Delay to start the animation
}, []);
useEffect(() => {
const projectData = searchParams.get("project");
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 {
// Fetch project data based on slug from URL
const slug = params.slug as string;
try { try {
fetchProjectData(slug); const response = await fetch(`/api/projects/search?slug=${slug}`);
if (response.ok) {
const data = await response.json();
if (data.projects && data.projects.length > 0) {
setProject(data.projects[0]);
}
} else {
console.error('Failed to fetch project from API');
}
} 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) => { loadProject();
try { }, [slug]);
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) {
return (
<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 */}
<Header /> <motion.div
<div className="flex-grow"> initial={{ opacity: 0, y: 30 }}
<div className="flex justify-center mt-14 md:mt-28 px-4 md:px-0"> animate={{ opacity: 1, y: 0 }}
{featureImageUrl && ( transition={{ duration: 0.8 }}
<div className="relative w-full max-w-4xl h-0 pb-[56.25%] rounded-2xl overflow-hidden"> className="mb-12"
<Image >
src={featureImageUrl} <Link
alt={project.title} href="/projects"
fill className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
style={{ objectFit: "cover" }} >
className="rounded-2xl" <ArrowLeft size={20} />
priority={true} <span>Back to Projects</span>
/> </Link>
<div className="flex items-center justify-between mb-6">
<h1 className="text-4xl md:text-5xl font-bold gradient-text">
{project.title}
</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>
<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>
)} <div className="flex items-center space-x-2">
</div> <Tag size={20} />
<div className="flex items-center justify-center mt-4"> <span>{project.category}</span>
<h1 className="text-4xl md:text-6xl font-bold text-gray-600"> </div>
{project.title} </div>
</h1>
</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>
</div> </motion.div>
</div> </div>
<Footer_Back />
</div> </div>
); );
}; };
export default ProjectDetails; export default ProjectDetail;

218
app/projects/page.tsx Normal file
View 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;

View 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>
);
}

View 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}</>;
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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).*)',
],
};

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"