diff --git a/Dockerfile b/Dockerfile index 93f5baa..81eb497 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # Multi-stage build for optimized production image -FROM node:20-alpine AS base +FROM node:20 AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Install dependencies based on the preferred package manager diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9132f28..92b6bdc 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -24,7 +24,8 @@ import { FileText, Settings, Database, - BarChart3 + BarChart3, + TrendingUp } from 'lucide-react'; import Link from 'next/link'; import ReactMarkdown from 'react-markdown'; @@ -65,6 +66,8 @@ const apiService = { } }; import AdminDashboard from '@/components/AdminDashboard'; +import ImportExport from '@/components/ImportExport'; +import AnalyticsDashboard from '@/components/AnalyticsDashboard'; import { useToast } from '@/components/Toast'; interface Project { @@ -136,6 +139,8 @@ const AdminPage = () => { const [isPreview, setIsPreview] = useState(false); const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false); const [showTemplates, setShowTemplates] = useState(false); + const [showImportExport, setShowImportExport] = useState(false); + const [showAnalytics, setShowAnalytics] = useState(false); const [formData, setFormData] = useState({ title: '', description: '', @@ -667,8 +672,8 @@ DELETE /api/users/:id

- {/* Projects Toggle Button - Always Visible */} -
+ {/* Control Buttons */} +
setIsProjectsCollapsed(!isProjectsCollapsed)} className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-gray-700 to-gray-800 hover:from-gray-600 hover:to-gray-700 rounded-xl text-white transition-all duration-200 hover:scale-105 border border-gray-600/50 shadow-lg" @@ -688,8 +693,54 @@ DELETE /api/users/:id )} + + setShowImportExport(!showImportExport)} + className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 rounded-xl text-white transition-all duration-200 hover:scale-105 border border-blue-500/50 shadow-lg" + title="Import & Export Projects" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + Import/Export + + + setShowAnalytics(!showAnalytics)} + className="flex items-center space-x-2 px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-500 hover:to-green-600 rounded-xl text-white transition-all duration-200 hover:scale-105 border border-green-500/50 shadow-lg" + title="Analytics Dashboard" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + Analytics +
+ {/* Import/Export Section */} + {showImportExport && ( + + + + )} + + {/* Analytics Section */} + {showAnalytics && ( + + + + )} +
{/* Projects List */}
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]) => ( +
+ {category} +
+
+
+
+ + {count} + +
+
+ ))} +
+
+ +
+

+ Projects by Difficulty +

+
+ {Object.entries(analyticsData.difficulties) + .sort(([,a], [,b]) => b - a) + .map(([difficulty, count]) => ( +
+ {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 @@ [2025-09-05 20:43:55] 🚀 Starting automatic deployment for portfolio [2025-09-05 20:43:55] 📋 Step 1: Running code quality checks... [WARNING] You have uncommitted changes. Committing them... +[2025-09-05 20:43:55] 📥 Pulling latest changes... +[2025-09-05 20:43:56] 🔍 Running ESLint... +[ERROR] 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