🚀 Complete Production Setup
✨ Features: - Analytics Dashboard with real-time metrics - Redis caching for performance optimization - Import/Export functionality for projects - Complete admin system with security - Production-ready Docker setup 🔧 Technical: - Removed Ghost CMS dependencies - Added Redis container with caching - Implemented API response caching - Enhanced admin interface with analytics - Optimized for dk0.dev domain 🛡️ Security: - Admin authentication with Basic Auth - Protected analytics endpoints - Secure environment configuration 📊 Analytics: - Performance metrics dashboard - Project statistics visualization - Real-time data with caching - Umami integration for GDPR compliance 🎯 Production Ready: - Multi-container Docker setup - Health checks for all services - Automatic restart policies - Resource limits configured - Ready for Nginx Proxy Manager
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Projects Toggle Button - Always Visible */}
|
||||
<div className="flex justify-center mb-6">
|
||||
{/* Control Buttons */}
|
||||
<div className="flex justify-center gap-4 mb-6">
|
||||
<motion.button
|
||||
onClick={() => 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
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<FileText size={20} />
|
||||
<span>Import/Export</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<TrendingUp size={20} />
|
||||
<span>Analytics</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Import/Export Section */}
|
||||
{showImportExport && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<ImportExport />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Analytics Section */}
|
||||
{showAnalytics && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<AnalyticsDashboard />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-8 ${isProjectsCollapsed ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-3'}`}>
|
||||
{/* Projects List */}
|
||||
<div className={`${isProjectsCollapsed ? 'hidden' : 'lg:col-span-1'}`}>
|
||||
|
||||
86
app/api/analytics/dashboard/route.ts
Normal file
86
app/api/analytics/dashboard/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/api/analytics/performance/route.ts
Normal file
87
app/api/analytics/performance/route.ts
Normal file
@@ -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<string, number>),
|
||||
topInteractions: userInteractions.reduce((acc, ui) => {
|
||||
acc[ui.type] = (acc[ui.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
};
|
||||
|
||||
return NextResponse.json(performance);
|
||||
} catch (error) {
|
||||
console.error('Performance analytics error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch performance data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
58
app/api/projects/export/route.ts
Normal file
58
app/api/projects/export/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/projects/import/route.ts
Normal file
89
app/api/projects/import/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
374
components/AnalyticsDashboard.tsx
Normal file
374
components/AnalyticsDashboard.tsx
Normal file
@@ -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<string, number>;
|
||||
difficulties: Record<string, number>;
|
||||
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<string, number>;
|
||||
topInteractions: Record<string, number>;
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard() {
|
||||
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
|
||||
const [performanceData, setPerformanceData] = useState<PerformanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/4 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div className="text-center text-red-500">
|
||||
<p>Error loading analytics: {error}</p>
|
||||
<button
|
||||
onClick={fetchAnalyticsData}
|
||||
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!analyticsData || !performanceData) return null;
|
||||
|
||||
const StatCard = ({ title, value, icon: Icon, color, trend }: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: any;
|
||||
color: string;
|
||||
trend?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
{trend && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 flex items-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
{trend}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${color}`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<BarChart3 className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Analytics Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Übersicht über deine Portfolio-Performance
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchAnalyticsData}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Projects"
|
||||
value={analyticsData.overview.totalProjects}
|
||||
icon={Target}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Views"
|
||||
value={analyticsData.overview.totalViews.toLocaleString()}
|
||||
icon={Eye}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Likes"
|
||||
value={analyticsData.overview.totalLikes.toLocaleString()}
|
||||
icon={Heart}
|
||||
color="bg-red-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Avg Lighthouse"
|
||||
value={analyticsData.overview.avgLighthouse}
|
||||
icon={Zap}
|
||||
color="bg-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Views (24h)"
|
||||
value={performanceData.pageViews.last24h}
|
||||
icon={Activity}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Views (7d)"
|
||||
value={performanceData.pageViews.last7d}
|
||||
icon={Clock}
|
||||
color="bg-indigo-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Interactions (24h)"
|
||||
value={performanceData.interactions.last24h}
|
||||
icon={Users}
|
||||
color="bg-pink-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Interactions (7d)"
|
||||
value={performanceData.interactions.last7d}
|
||||
icon={Globe}
|
||||
color="bg-teal-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Projects Performance */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<Award className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
Top Performing Projects
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{analyticsData.projects
|
||||
.sort((a, b) => b.views - a.views)
|
||||
.slice(0, 5)
|
||||
.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{project.title}</h4>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className={`px-2 py-1 rounded text-xs text-white ${getDifficultyColor(project.difficulty)}`}>
|
||||
{project.difficulty}
|
||||
</span>
|
||||
<span>{project.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{project.views}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Views</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{project.likes}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Likes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{project.lighthouse}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Lighthouse</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories & Difficulties */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Projects by Category
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(analyticsData.categories)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([category, count]) => (
|
||||
<div key={category} className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">{category}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(count / analyticsData.overview.totalProjects) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Projects by Difficulty
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(analyticsData.difficulties)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([difficulty, count]) => (
|
||||
<div key={difficulty} className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">{difficulty}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getDifficultyColor(difficulty)}`}
|
||||
style={{ width: `${(count / analyticsData.overview.totalProjects) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
components/ImportExport.tsx
Normal file
174
components/ImportExport.tsx
Normal file
@@ -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<ImportResult | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
Import & Export
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Export Section */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Export Projekte</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Alle Projekte als JSON-Datei herunterladen
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? 'Exportiere...' : 'Exportieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Import Projekte</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
JSON-Datei mit Projekten hochladen
|
||||
</p>
|
||||
<label className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 cursor-pointer">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isImporting ? 'Importiere...' : 'Datei auswählen'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
disabled={isImporting}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import Results */}
|
||||
{importResult && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center">
|
||||
{importResult.success ? (
|
||||
<CheckCircle className="w-5 h-5 mr-2 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 mr-2 text-red-500" />
|
||||
)}
|
||||
Import Ergebnis
|
||||
</h4>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<p><strong>Importiert:</strong> {importResult.results.imported}</p>
|
||||
<p><strong>Übersprungen:</strong> {importResult.results.skipped}</p>
|
||||
{importResult.results.errors.length > 0 && (
|
||||
<div>
|
||||
<p><strong>Fehler:</strong></p>
|
||||
<ul className="list-disc list-inside ml-4">
|
||||
{importResult.results.errors.map((error, index) => (
|
||||
<li key={index} className="text-red-500">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,7 @@ const ToastItem = ({ toast, onRemove }: ToastProps) => {
|
||||
import { createContext, useContext, useCallback } from 'react';
|
||||
|
||||
interface ToastContextType {
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
showToast: (toast: Omit<Toast, 'id'>) => 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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
env.example
24
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
|
||||
|
||||
82
lib/cache.ts
Normal file
82
lib/cache.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
145
lib/redis.ts
Normal file
145
lib/redis.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
let redisClient: ReturnType<typeof createClient> | 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');
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
89
package-lock.json
generated
89
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user