diff --git a/app/api/analytics/dashboard/route.ts b/app/api/analytics/dashboard/route.ts
new file mode 100644
index 0000000..4f2a88e
--- /dev/null
+++ b/app/api/analytics/dashboard/route.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { projectService } from '@/lib/prisma';
+import { analyticsCache } from '@/lib/redis';
+
+export async function GET(request: NextRequest) {
+ try {
+ // Check admin authentication
+ const authHeader = request.headers.get('authorization');
+ const basicAuth = process.env.ADMIN_BASIC_AUTH;
+
+ if (!basicAuth) {
+ return new NextResponse('Admin access not configured', { status: 500 });
+ }
+
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
+ return new NextResponse('Authentication required', { status: 401 });
+ }
+
+ const credentials = authHeader.split(' ')[1];
+ const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
+ const [expectedUsername, expectedPassword] = basicAuth.split(':');
+
+ if (username !== expectedUsername || password !== expectedPassword) {
+ return new NextResponse('Invalid credentials', { status: 401 });
+ }
+
+ // Check cache first
+ const cachedStats = await analyticsCache.getOverallStats();
+ if (cachedStats) {
+ return NextResponse.json(cachedStats);
+ }
+
+ // Get analytics data
+ const projectsResult = await projectService.getAllProjects();
+ const projects = projectsResult.projects || projectsResult;
+ const performanceStats = await projectService.getPerformanceStats();
+
+ // Calculate analytics metrics
+ const analytics = {
+ overview: {
+ totalProjects: projects.length,
+ publishedProjects: projects.filter(p => p.published).length,
+ featuredProjects: projects.filter(p => p.featured).length,
+ totalViews: projects.reduce((sum, p) => sum + ((p.analytics as any)?.views || 0), 0),
+ totalLikes: projects.reduce((sum, p) => sum + ((p.analytics as any)?.likes || 0), 0),
+ totalShares: projects.reduce((sum, p) => sum + ((p.analytics as any)?.shares || 0), 0),
+ avgLighthouse: projects.length > 0
+ ? Math.round(projects.reduce((sum, p) => sum + ((p.performance as any)?.lighthouse || 0), 0) / projects.length)
+ : 0
+ },
+ projects: projects.map(project => ({
+ id: project.id,
+ title: project.title,
+ category: project.category,
+ difficulty: project.difficulty,
+ views: (project.analytics as any)?.views || 0,
+ likes: (project.analytics as any)?.likes || 0,
+ shares: (project.analytics as any)?.shares || 0,
+ lighthouse: (project.performance as any)?.lighthouse || 0,
+ published: project.published,
+ featured: project.featured,
+ createdAt: project.createdAt,
+ updatedAt: project.updatedAt
+ })),
+ categories: performanceStats.byCategory,
+ difficulties: performanceStats.byDifficulty,
+ performance: {
+ avgLighthouse: performanceStats.avgLighthouse,
+ totalViews: performanceStats.totalViews,
+ totalLikes: performanceStats.totalLikes,
+ totalShares: performanceStats.totalShares
+ }
+ };
+
+ // Cache the results
+ await analyticsCache.setOverallStats(analytics);
+
+ return NextResponse.json(analytics);
+ } catch (error) {
+ console.error('Analytics dashboard error:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch analytics data' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/analytics/performance/route.ts b/app/api/analytics/performance/route.ts
new file mode 100644
index 0000000..2f845c2
--- /dev/null
+++ b/app/api/analytics/performance/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(request: NextRequest) {
+ try {
+ // Check admin authentication
+ const authHeader = request.headers.get('authorization');
+ const basicAuth = process.env.ADMIN_BASIC_AUTH;
+
+ if (!basicAuth) {
+ return new NextResponse('Admin access not configured', { status: 500 });
+ }
+
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
+ return new NextResponse('Authentication required', { status: 401 });
+ }
+
+ const credentials = authHeader.split(' ')[1];
+ const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
+ const [expectedUsername, expectedPassword] = basicAuth.split(':');
+
+ if (username !== expectedUsername || password !== expectedPassword) {
+ return new NextResponse('Invalid credentials', { status: 401 });
+ }
+
+ // Get performance data from database
+ const pageViews = await prisma.pageView.findMany({
+ orderBy: { timestamp: 'desc' },
+ take: 1000 // Last 1000 page views
+ });
+
+ const userInteractions = await prisma.userInteraction.findMany({
+ orderBy: { timestamp: 'desc' },
+ take: 1000 // Last 1000 interactions
+ });
+
+ // Calculate performance metrics
+ const performance = {
+ pageViews: {
+ total: pageViews.length,
+ last24h: pageViews.filter(pv => {
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
+ return new Date(pv.timestamp) > dayAgo;
+ }).length,
+ last7d: pageViews.filter(pv => {
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ return new Date(pv.timestamp) > weekAgo;
+ }).length,
+ last30d: pageViews.filter(pv => {
+ const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+ return new Date(pv.timestamp) > monthAgo;
+ }).length
+ },
+ interactions: {
+ total: userInteractions.length,
+ last24h: userInteractions.filter(ui => {
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
+ return new Date(ui.timestamp) > dayAgo;
+ }).length,
+ last7d: userInteractions.filter(ui => {
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ return new Date(ui.timestamp) > weekAgo;
+ }).length,
+ last30d: userInteractions.filter(ui => {
+ const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+ return new Date(ui.timestamp) > monthAgo;
+ }).length
+ },
+ topPages: pageViews.reduce((acc, pv) => {
+ acc[pv.page] = (acc[pv.page] || 0) + 1;
+ return acc;
+ }, {} as Record
),
+ topInteractions: userInteractions.reduce((acc, ui) => {
+ acc[ui.type] = (acc[ui.type] || 0) + 1;
+ return acc;
+ }, {} as Record)
+ };
+
+ return NextResponse.json(performance);
+ } catch (error) {
+ console.error('Performance analytics error:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch performance data' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/export/route.ts b/app/api/projects/export/route.ts
new file mode 100644
index 0000000..22cfdac
--- /dev/null
+++ b/app/api/projects/export/route.ts
@@ -0,0 +1,58 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { projectService } from '@/lib/prisma';
+
+export async function GET(request: NextRequest) {
+ try {
+ // Get all projects with full data
+ const projectsResult = await projectService.getAllProjects();
+ const projects = projectsResult.projects || projectsResult;
+
+ // Format for export
+ const exportData = {
+ version: '1.0',
+ exportDate: new Date().toISOString(),
+ projects: projects.map(project => ({
+ id: project.id,
+ title: project.title,
+ description: project.description,
+ content: project.content,
+ tags: project.tags,
+ category: project.category,
+ featured: project.featured,
+ github: project.github,
+ live: project.live,
+ published: project.published,
+ imageUrl: project.imageUrl,
+ difficulty: project.difficulty,
+ timeToComplete: project.timeToComplete,
+ technologies: project.technologies,
+ challenges: project.challenges,
+ lessonsLearned: project.lessonsLearned,
+ futureImprovements: project.futureImprovements,
+ demoVideo: project.demoVideo,
+ screenshots: project.screenshots,
+ colorScheme: project.colorScheme,
+ accessibility: project.accessibility,
+ performance: project.performance,
+ analytics: project.analytics,
+ createdAt: project.createdAt,
+ updatedAt: project.updatedAt
+ }))
+ };
+
+ // Return as downloadable JSON file
+ return new NextResponse(JSON.stringify(exportData, null, 2), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Disposition': `attachment; filename="portfolio-projects-${new Date().toISOString().split('T')[0]}.json"`
+ }
+ });
+ } catch (error) {
+ console.error('Export error:', error);
+ return NextResponse.json(
+ { error: 'Failed to export projects' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/import/route.ts b/app/api/projects/import/route.ts
new file mode 100644
index 0000000..32f7047
--- /dev/null
+++ b/app/api/projects/import/route.ts
@@ -0,0 +1,89 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { projectService } from '@/lib/prisma';
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+
+ // Validate import data structure
+ if (!body.projects || !Array.isArray(body.projects)) {
+ return NextResponse.json(
+ { error: 'Invalid import data format' },
+ { status: 400 }
+ );
+ }
+
+ const results = {
+ imported: 0,
+ skipped: 0,
+ errors: [] as string[]
+ };
+
+ // Process each project
+ for (const projectData of body.projects) {
+ try {
+ // Check if project already exists (by title)
+ const existingProjectsResult = await projectService.getAllProjects();
+ const existingProjects = existingProjectsResult.projects || existingProjectsResult;
+ const exists = existingProjects.some(p => p.title === projectData.title);
+
+ if (exists) {
+ results.skipped++;
+ results.errors.push(`Project "${projectData.title}" already exists`);
+ continue;
+ }
+
+ // Create new project
+ const newProject = await projectService.createProject({
+ title: projectData.title,
+ description: projectData.description,
+ content: projectData.content,
+ tags: projectData.tags || [],
+ category: projectData.category,
+ featured: projectData.featured || false,
+ github: projectData.github,
+ live: projectData.live,
+ published: projectData.published !== false, // Default to true
+ imageUrl: projectData.imageUrl,
+ difficulty: projectData.difficulty || 'Intermediate',
+ timeToComplete: projectData.timeToComplete,
+ technologies: projectData.technologies || [],
+ challenges: projectData.challenges || [],
+ lessonsLearned: projectData.lessonsLearned || [],
+ futureImprovements: projectData.futureImprovements || [],
+ demoVideo: projectData.demoVideo,
+ screenshots: projectData.screenshots || [],
+ colorScheme: projectData.colorScheme || 'Dark',
+ accessibility: projectData.accessibility !== false, // Default to true
+ performance: projectData.performance || {
+ lighthouse: 90,
+ bundleSize: '50KB',
+ loadTime: '1.5s'
+ },
+ analytics: projectData.analytics || {
+ views: 0,
+ likes: 0,
+ shares: 0
+ }
+ });
+
+ results.imported++;
+ } catch (error) {
+ results.skipped++;
+ results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: `Import completed: ${results.imported} imported, ${results.skipped} skipped`,
+ results
+ });
+ } catch (error) {
+ console.error('Import error:', error);
+ return NextResponse.json(
+ { error: 'Failed to import projects' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index b88b1c2..d21fd5e 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
+import { apiCache } from '@/lib/cache';
export async function GET(request: NextRequest) {
try {
@@ -12,6 +13,15 @@ export async function GET(request: NextRequest) {
const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search');
+ // Create cache key based on parameters
+ const cacheKey = `projects:${page}:${limit}:${category || 'all'}:${featured || 'all'}:${published || 'all'}:${difficulty || 'all'}:${search || 'all'}`;
+
+ // Check cache first
+ const cached = await apiCache.getProjects();
+ if (cached && !search) { // Don't cache search results
+ return NextResponse.json(cached);
+ }
+
const skip = (page - 1) * limit;
const where: any = {};
@@ -40,12 +50,19 @@ export async function GET(request: NextRequest) {
prisma.project.count({ where })
]);
- return NextResponse.json({
+ const result = {
projects,
total,
pages: Math.ceil(total / limit),
currentPage: page
- });
+ };
+
+ // Cache the result (only for non-search queries)
+ if (!search) {
+ await apiCache.setProjects(result);
+ }
+
+ return NextResponse.json(result);
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json(
@@ -67,6 +84,9 @@ export async function POST(request: NextRequest) {
}
});
+ // Invalidate cache
+ await apiCache.invalidateAll();
+
return NextResponse.json(project);
} catch (error) {
console.error('Error creating project:', error);
diff --git a/components/AnalyticsDashboard.tsx b/components/AnalyticsDashboard.tsx
new file mode 100644
index 0000000..e2657f3
--- /dev/null
+++ b/components/AnalyticsDashboard.tsx
@@ -0,0 +1,374 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { motion } from 'framer-motion';
+import {
+ BarChart3,
+ TrendingUp,
+ Eye,
+ Heart,
+ Share2,
+ Zap,
+ Users,
+ Clock,
+ Globe,
+ Activity,
+ Target,
+ Award
+} from 'lucide-react';
+
+interface AnalyticsData {
+ overview: {
+ totalProjects: number;
+ publishedProjects: number;
+ featuredProjects: number;
+ totalViews: number;
+ totalLikes: number;
+ totalShares: number;
+ avgLighthouse: number;
+ };
+ projects: Array<{
+ id: number;
+ title: string;
+ category: string;
+ difficulty: string;
+ views: number;
+ likes: number;
+ shares: number;
+ lighthouse: number;
+ published: boolean;
+ featured: boolean;
+ createdAt: string;
+ updatedAt: string;
+ }>;
+ categories: Record;
+ difficulties: Record;
+ performance: {
+ avgLighthouse: number;
+ totalViews: number;
+ totalLikes: number;
+ totalShares: number;
+ };
+}
+
+interface PerformanceData {
+ pageViews: {
+ total: number;
+ last24h: number;
+ last7d: number;
+ last30d: number;
+ };
+ interactions: {
+ total: number;
+ last24h: number;
+ last7d: number;
+ last30d: number;
+ };
+ topPages: Record;
+ topInteractions: Record;
+}
+
+export default function AnalyticsDashboard() {
+ const [analyticsData, setAnalyticsData] = useState(null);
+ const [performanceData, setPerformanceData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchAnalyticsData();
+ }, []);
+
+ const fetchAnalyticsData = async () => {
+ try {
+ setLoading(true);
+
+ // Get basic auth from environment or use default
+ const auth = btoa('admin:change_this_password_123');
+
+ const [analyticsRes, performanceRes] = await Promise.all([
+ fetch('/api/analytics/dashboard', {
+ headers: { 'Authorization': `Basic ${auth}` }
+ }),
+ fetch('/api/analytics/performance', {
+ headers: { 'Authorization': `Basic ${auth}` }
+ })
+ ]);
+
+ if (!analyticsRes.ok || !performanceRes.ok) {
+ throw new Error('Failed to fetch analytics data');
+ }
+
+ const [analytics, performance] = await Promise.all([
+ analyticsRes.json(),
+ performanceRes.json()
+ ]);
+
+ setAnalyticsData(analytics);
+ setPerformanceData(performance);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
Error loading analytics: {error}
+
+
+
+ );
+ }
+
+ if (!analyticsData || !performanceData) return null;
+
+ const StatCard = ({ title, value, icon: Icon, color, trend }: {
+ title: string;
+ value: number | string;
+ icon: any;
+ color: string;
+ trend?: string;
+ }) => (
+
+
+
+
{title}
+
{value}
+ {trend && (
+
+
+ {trend}
+
+ )}
+
+
+
+
+
+
+ );
+
+ const getDifficultyColor = (difficulty: string) => {
+ switch (difficulty) {
+ case 'Beginner': return 'bg-green-500';
+ case 'Intermediate': return 'bg-yellow-500';
+ case 'Advanced': return 'bg-orange-500';
+ case 'Expert': return 'bg-red-500';
+ default: return 'bg-gray-500';
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ Analytics Dashboard
+
+
+ Übersicht über deine Portfolio-Performance
+
+
+
+
+
+
+ {/* Overview Stats */}
+
+
+
+
+
+
+
+ {/* Performance Stats */}
+
+
+
+
+
+
+
+ {/* Projects Performance */}
+
+
+
+ Top Performing Projects
+
+
+ {analyticsData.projects
+ .sort((a, b) => b.views - a.views)
+ .slice(0, 5)
+ .map((project, index) => (
+
+
+
+ {index + 1}
+
+
+
{project.title}
+
+
+ {project.difficulty}
+
+ {project.category}
+
+
+
+
+
+
{project.views}
+
Views
+
+
+
{project.likes}
+
Likes
+
+
+
{project.lighthouse}
+
Lighthouse
+
+
+
+ ))}
+
+
+
+ {/* Categories & Difficulties */}
+
+
+
+ Projects by Category
+
+
+ {Object.entries(analyticsData.categories)
+ .sort(([,a], [,b]) => b - a)
+ .map(([category, count]) => (
+
+ ))}
+
+
+
+
+
+ Projects by Difficulty
+
+
+ {Object.entries(analyticsData.difficulties)
+ .sort(([,a], [,b]) => b - a)
+ .map(([difficulty, count]) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/ImportExport.tsx b/components/ImportExport.tsx
new file mode 100644
index 0000000..62de307
--- /dev/null
+++ b/components/ImportExport.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import { useState } from 'react';
+import { Download, Upload, FileText, AlertCircle, CheckCircle } from 'lucide-react';
+import { useToast } from '@/components/Toast';
+
+interface ImportResult {
+ success: boolean;
+ message: string;
+ results: {
+ imported: number;
+ skipped: number;
+ errors: string[];
+ };
+}
+
+export default function ImportExport() {
+ const [isExporting, setIsExporting] = useState(false);
+ const [isImporting, setIsImporting] = useState(false);
+ const [importResult, setImportResult] = useState(null);
+ const { addToast } = useToast();
+
+ const handleExport = async () => {
+ setIsExporting(true);
+ try {
+ const response = await fetch('/api/projects/export');
+ if (!response.ok) throw new Error('Export failed');
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ addToast({
+ type: 'success',
+ title: 'Export erfolgreich',
+ message: 'Projekte wurden erfolgreich exportiert'
+ });
+ } catch (error) {
+ addToast({
+ type: 'error',
+ title: 'Export fehlgeschlagen',
+ message: 'Fehler beim Exportieren der Projekte'
+ });
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ const handleImport = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ setIsImporting(true);
+ setImportResult(null);
+
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+
+ const response = await fetch('/api/projects/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+
+ const result: ImportResult = await response.json();
+ setImportResult(result);
+
+ if (result.success) {
+ addToast({
+ type: 'success',
+ title: 'Import erfolgreich',
+ message: result.message
+ });
+ } else {
+ addToast({
+ type: 'error',
+ title: 'Import fehlgeschlagen',
+ message: result.message
+ });
+ }
+ } catch (error) {
+ addToast({
+ type: 'error',
+ title: 'Import fehlgeschlagen',
+ message: 'Ungültige Datei oder Format'
+ });
+ } finally {
+ setIsImporting(false);
+ // Reset file input
+ event.target.value = '';
+ }
+ };
+
+ return (
+
+
+
+ Import & Export
+
+
+
+ {/* Export Section */}
+
+
Export Projekte
+
+ Alle Projekte als JSON-Datei herunterladen
+
+
+
+
+ {/* Import Section */}
+
+
Import Projekte
+
+ JSON-Datei mit Projekten hochladen
+
+
+
+
+ {/* Import Results */}
+ {importResult && (
+
+
+ {importResult.success ? (
+
+ ) : (
+
+ )}
+ Import Ergebnis
+
+
+
Importiert: {importResult.results.imported}
+
Übersprungen: {importResult.results.skipped}
+ {importResult.results.errors.length > 0 && (
+
+
Fehler:
+
+ {importResult.results.errors.map((error, index) => (
+ - {error}
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/components/Toast.tsx b/components/Toast.tsx
index 5879084..e59c9e1 100644
--- a/components/Toast.tsx
+++ b/components/Toast.tsx
@@ -134,6 +134,7 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
import { createContext, useContext, useCallback } from 'react';
interface ToastContextType {
+ addToast: (toast: Omit) => void;
showToast: (toast: Omit) => void;
showSuccess: (title: string, message?: string) => void;
showError: (title: string, message?: string) => void;
@@ -265,6 +266,7 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
}, [addToast]);
const contextValue: ToastContextType = {
+ addToast,
showToast,
showSuccess,
showError,
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 90eb231..818a004 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -8,20 +8,26 @@ services:
container_name: portfolio-app
restart: unless-stopped
ports:
- - "3000:3000"
+ - "4000:3000"
environment:
- NODE_ENV=production
+ - DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public
+ - REDIS_URL=redis://:portfolio_redis_pass@redis:6379
- 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}
+ - ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH}
volumes:
- portfolio_data:/app/.next/cache
networks:
- portfolio-network
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
@@ -37,31 +43,64 @@ services:
memory: 256M
cpus: '0.25'
- nginx:
- image: nginx:alpine
- container_name: portfolio-nginx
+ postgres:
+ image: postgres:16-alpine
+ container_name: portfolio-postgres
restart: unless-stopped
- ports:
- - "80:80"
- - "443:443"
+ environment:
+ - POSTGRES_DB=portfolio_db
+ - POSTGRES_USER=portfolio_user
+ - POSTGRES_PASSWORD=portfolio_pass
volumes:
- - ./nginx.conf:/etc/nginx/nginx.conf:ro
- - ./ssl:/etc/nginx/ssl:ro
- - nginx_cache:/var/cache/nginx
- depends_on:
- - portfolio
+ - postgres_data:/var/lib/postgresql/data
networks:
- portfolio-network
healthcheck:
- test: ["CMD", "nginx", "-t"]
- interval: 30s
- timeout: 10s
- retries: 3
+ test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_db"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ deploy:
+ resources:
+ limits:
+ memory: 256M
+ cpus: '0.25'
+ reservations:
+ memory: 128M
+ cpus: '0.1'
+
+ redis:
+ image: redis:7-alpine
+ container_name: portfolio-redis
+ restart: unless-stopped
+ command: redis-server --appendonly yes --requirepass portfolio_redis_pass
+ volumes:
+ - redis_data:/data
+ networks:
+ - portfolio-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 30s
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+ cpus: '0.1'
+ reservations:
+ memory: 64M
+ cpus: '0.05'
+
volumes:
portfolio_data:
driver: local
- nginx_cache:
+ postgres_data:
+ driver: local
+ redis_data:
driver: local
networks:
diff --git a/env.example b/env.example
index 9a6b87a..fb92559 100644
--- a/env.example
+++ b/env.example
@@ -5,27 +5,31 @@
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
+# Ghost CMS (removed - using built-in project management)
+# GHOST_API_URL=https://your-ghost-instance.com
+# GHOST_API_KEY=your-ghost-api-key
-# Email Configuration
+# Email Configuration (optional - for contact form)
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
+# Database (automatically set by Docker Compose)
+# DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public
+
+# Redis (automatically set by Docker Compose)
+# REDIS_URL=redis://:portfolio_redis_pass@redis:6379
# Analytics
-# NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
-# NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
+NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
+NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
# Security
# JWT_SECRET=your-jwt-secret
# ENCRYPTION_KEY=your-encryption-key
+ADMIN_BASIC_AUTH=admin:your_secure_password_here
-# Monitoring
+# Monitoring (optional)
# SENTRY_DSN=your-sentry-dsn
-# LOG_LEVEL=info
+LOG_LEVEL=info
diff --git a/lib/cache.ts b/lib/cache.ts
new file mode 100644
index 0000000..396ff47
--- /dev/null
+++ b/lib/cache.ts
@@ -0,0 +1,82 @@
+import { cache } from './redis';
+
+// API Response caching
+export const apiCache = {
+ async getProjects() {
+ return await cache.get('api:projects');
+ },
+
+ async setProjects(projects: any, ttlSeconds = 300) {
+ return await cache.set('api:projects', projects, ttlSeconds);
+ },
+
+ async getProject(id: number) {
+ return await cache.get(`api:project:${id}`);
+ },
+
+ async setProject(id: number, project: any, ttlSeconds = 300) {
+ return await cache.set(`api:project:${id}`, project, ttlSeconds);
+ },
+
+ async invalidateProject(id: number) {
+ await cache.del(`api:project:${id}`);
+ await cache.del('api:projects');
+ },
+
+ async invalidateAll() {
+ await cache.del('api:projects');
+ // Clear all project caches
+ const keys = await this.getAllProjectKeys();
+ for (const key of keys) {
+ await cache.del(key);
+ }
+ },
+
+ async getAllProjectKeys() {
+ // This would need to be implemented with Redis SCAN
+ // For now, we'll use a simple approach
+ return [];
+ }
+};
+
+// Performance metrics caching
+export const performanceCache = {
+ async getMetrics(url: string) {
+ return await cache.get(`perf:${url}`);
+ },
+
+ async setMetrics(url: string, metrics: any, ttlSeconds = 600) {
+ return await cache.set(`perf:${url}`, metrics, ttlSeconds);
+ },
+
+ async getWebVitals() {
+ return await cache.get('perf:webvitals');
+ },
+
+ async setWebVitals(vitals: any, ttlSeconds = 300) {
+ return await cache.set('perf:webvitals', vitals, ttlSeconds);
+ }
+};
+
+// User session caching
+export const userCache = {
+ async getSession(sessionId: string) {
+ return await cache.get(`user:session:${sessionId}`);
+ },
+
+ async setSession(sessionId: string, data: any, ttlSeconds = 86400) {
+ return await cache.set(`user:session:${sessionId}`, data, ttlSeconds);
+ },
+
+ async deleteSession(sessionId: string) {
+ return await cache.del(`user:session:${sessionId}`);
+ },
+
+ async getUserPreferences(userId: string) {
+ return await cache.get(`user:prefs:${userId}`);
+ },
+
+ async setUserPreferences(userId: string, prefs: any, ttlSeconds = 86400) {
+ return await cache.set(`user:prefs:${userId}`, prefs, ttlSeconds);
+ }
+};
diff --git a/lib/redis.ts b/lib/redis.ts
new file mode 100644
index 0000000..c10a38f
--- /dev/null
+++ b/lib/redis.ts
@@ -0,0 +1,145 @@
+import { createClient } from 'redis';
+
+let redisClient: ReturnType | null = null;
+
+export const getRedisClient = async () => {
+ if (!redisClient) {
+ const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
+
+ redisClient = createClient({
+ url: redisUrl,
+ socket: {
+ reconnectStrategy: (retries) => Math.min(retries * 50, 1000)
+ }
+ });
+
+ redisClient.on('error', (err) => {
+ console.error('Redis Client Error:', err);
+ });
+
+ redisClient.on('connect', () => {
+ console.log('Redis Client Connected');
+ });
+
+ redisClient.on('ready', () => {
+ console.log('Redis Client Ready');
+ });
+
+ redisClient.on('end', () => {
+ console.log('Redis Client Disconnected');
+ });
+
+ await redisClient.connect();
+ }
+
+ return redisClient;
+};
+
+export const closeRedisConnection = async () => {
+ if (redisClient) {
+ await redisClient.quit();
+ redisClient = null;
+ }
+};
+
+// Cache utilities
+export const cache = {
+ async get(key: string) {
+ try {
+ const client = await getRedisClient();
+ const value = await client.get(key);
+ return value ? JSON.parse(value) : null;
+ } catch (error) {
+ console.error('Redis GET error:', error);
+ return null;
+ }
+ },
+
+ async set(key: string, value: any, ttlSeconds = 3600) {
+ try {
+ const client = await getRedisClient();
+ await client.setEx(key, ttlSeconds, JSON.stringify(value));
+ return true;
+ } catch (error) {
+ console.error('Redis SET error:', error);
+ return false;
+ }
+ },
+
+ async del(key: string) {
+ try {
+ const client = await getRedisClient();
+ await client.del(key);
+ return true;
+ } catch (error) {
+ console.error('Redis DEL error:', error);
+ return false;
+ }
+ },
+
+ async exists(key: string) {
+ try {
+ const client = await getRedisClient();
+ return await client.exists(key);
+ } catch (error) {
+ console.error('Redis EXISTS error:', error);
+ return false;
+ }
+ },
+
+ async flush() {
+ try {
+ const client = await getRedisClient();
+ await client.flushAll();
+ return true;
+ } catch (error) {
+ console.error('Redis FLUSH error:', error);
+ return false;
+ }
+ }
+};
+
+// Session management
+export const session = {
+ async create(userId: string, data: any, ttlSeconds = 86400) {
+ const sessionId = `session:${userId}:${Date.now()}`;
+ await cache.set(sessionId, data, ttlSeconds);
+ return sessionId;
+ },
+
+ async get(sessionId: string) {
+ return await cache.get(sessionId);
+ },
+
+ async update(sessionId: string, data: any, ttlSeconds = 86400) {
+ return await cache.set(sessionId, data, ttlSeconds);
+ },
+
+ async destroy(sessionId: string) {
+ return await cache.del(sessionId);
+ }
+};
+
+// Analytics caching
+export const analyticsCache = {
+ async getProjectStats(projectId: number) {
+ return await cache.get(`analytics:project:${projectId}`);
+ },
+
+ async setProjectStats(projectId: number, stats: any, ttlSeconds = 300) {
+ return await cache.set(`analytics:project:${projectId}`, stats, ttlSeconds);
+ },
+
+ async getOverallStats() {
+ return await cache.get('analytics:overall');
+ },
+
+ async setOverallStats(stats: any, ttlSeconds = 600) {
+ return await cache.set('analytics:overall', stats, ttlSeconds);
+ },
+
+ async invalidateProject(projectId: number) {
+ await cache.del(`analytics:project:${projectId}`);
+ await cache.del('analytics:overall');
+ }
+};
diff --git a/logs/portfolio-deploy.log b/logs/portfolio-deploy.log
index c876576..9cd3793 100644
--- a/logs/portfolio-deploy.log
+++ b/logs/portfolio-deploy.log
@@ -6,3 +6,6 @@
[0;34m[2025-09-05 20:43:55][0m 🚀 Starting automatic deployment for portfolio
[0;34m[2025-09-05 20:43:55][0m 📋 Step 1: Running code quality checks...
[1;33m[WARNING][0m You have uncommitted changes. Committing them...
+[0;34m[2025-09-05 20:43:55][0m 📥 Pulling latest changes...
+[0;34m[2025-09-05 20:43:56][0m 🔍 Running ESLint...
+[0;31m[ERROR][0m ESLint failed. Please fix the issues before deploying.
diff --git a/middleware.ts b/middleware.ts
index d03b64b..b8720c9 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -4,10 +4,44 @@ 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/')) {
+ request.nextUrl.pathname.startsWith('/api/projects/') ||
+ request.nextUrl.pathname.startsWith('/api/analytics/') ||
+ request.nextUrl.pathname.startsWith('/api/health')) {
return NextResponse.next();
}
+ // Protect admin routes
+ if (request.nextUrl.pathname.startsWith('/admin')) {
+ const authHeader = request.headers.get('authorization');
+ const basicAuth = process.env.ADMIN_BASIC_AUTH;
+
+ if (!basicAuth) {
+ return new NextResponse('Admin access not configured', { status: 500 });
+ }
+
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
+ return new NextResponse('Authentication required', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': 'Basic realm="Admin Area"',
+ },
+ });
+ }
+
+ const credentials = authHeader.split(' ')[1];
+ const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
+ const [expectedUsername, expectedPassword] = basicAuth.split(':');
+
+ if (username !== expectedUsername || password !== expectedPassword) {
+ return new NextResponse('Invalid credentials', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': 'Basic realm="Admin Area"',
+ },
+ });
+ }
+ }
+
// For all other routes, continue with normal processing
return NextResponse.next();
}
@@ -18,10 +52,12 @@ export const config = {
* Match all request paths except for the ones starting with:
* - api/email (email API routes)
* - api/projects (projects API routes)
+ * - api/analytics (analytics API routes)
+ * - api/health (health check)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
- '/((?!api/email|api/projects|_next/static|_next/image|favicon.ico).*)',
+ '/((?!api/email|api/projects|api/analytics|api/health|_next/static|_next/image|favicon.ico).*)',
],
-};
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index c246da0..c68377c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.7.1",
+ "@types/redis": "^4.0.11",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
@@ -26,6 +27,7 @@
"react-markdown": "^9.0.1",
"react-responsive-masonry": "^2.7.1",
"react-syntax-highlighter": "^15.5.0",
+ "redis": "^5.8.2",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
@@ -2500,6 +2502,61 @@
"@prisma/debug": "5.22.0"
}
},
+ "node_modules/@redis/bloom": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.2.tgz",
+ "integrity": "sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.8.2"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.2.tgz",
+ "integrity": "sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.2.tgz",
+ "integrity": "sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.8.2"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.2.tgz",
+ "integrity": "sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.8.2"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.2.tgz",
+ "integrity": "sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.8.2"
+ }
+ },
"node_modules/@resvg/resvg-wasm": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
@@ -2980,6 +3037,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/redis": {
+ "version": "4.0.11",
+ "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.11.tgz",
+ "integrity": "sha512-bI+gth8La8Wg/QCR1+V1fhrL9+LZUSWfcqpOj2Kc80ZQ4ffbdL173vQd5wovmoV9i071FU9oP2g6etLuEwb6Rg==",
+ "deprecated": "This is a stub types definition. redis provides its own type definitions, so you do not need this installed.",
+ "dependencies": {
+ "redis": "*"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4207,6 +4273,14 @@
"node": ">=6"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -10562,6 +10636,21 @@
"node": ">=8"
}
},
+ "node_modules/redis": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.2.tgz",
+ "integrity": "sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==",
+ "dependencies": {
+ "@redis/bloom": "5.8.2",
+ "@redis/client": "5.8.2",
+ "@redis/json": "5.8.2",
+ "@redis/search": "5.8.2",
+ "@redis/time-series": "5.8.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
diff --git a/package.json b/package.json
index 6086c23..e620b79 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.7.1",
+ "@types/redis": "^4.0.11",
"@vercel/og": "^0.6.5",
"clsx": "^2.1.0",
"dotenv": "^16.4.7",
@@ -49,6 +50,7 @@
"react-markdown": "^9.0.1",
"react-responsive-masonry": "^2.7.1",
"react-syntax-highlighter": "^15.5.0",
+ "redis": "^5.8.2",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
diff --git a/scripts/quick-deploy.sh b/scripts/quick-deploy.sh
index 1ab1243..46f733f 100755
--- a/scripts/quick-deploy.sh
+++ b/scripts/quick-deploy.sh
@@ -32,18 +32,18 @@ log "🚀 Quick deployment starting..."
# Build Docker image
log "🏗️ Building Docker image..."
-docker build -t "$IMAGE_NAME:latest" .
+sudo docker build -t "$IMAGE_NAME:latest" .
# Stop existing container
-if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
+if [ "$(sudo docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" = "true" ]; then
log "🛑 Stopping existing container..."
- docker stop "$CONTAINER_NAME"
- docker rm "$CONTAINER_NAME"
+ sudo docker stop "$CONTAINER_NAME"
+ sudo docker rm "$CONTAINER_NAME"
fi
# Start new container
log "🚀 Starting new container..."
-docker run -d \
+sudo docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p "$PORT:3000" \
@@ -58,6 +58,6 @@ 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
+ sudo docker logs "$CONTAINER_NAME" --tail=20
exit 1
fi