🚀 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
|
# Multi-stage build for optimized production image
|
||||||
FROM node:20-alpine AS base
|
FROM node:20 AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies based on the preferred package manager
|
# Install dependencies based on the preferred package manager
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Database,
|
Database,
|
||||||
BarChart3
|
BarChart3,
|
||||||
|
TrendingUp
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
@@ -65,6 +66,8 @@ const apiService = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
import AdminDashboard from '@/components/AdminDashboard';
|
import AdminDashboard from '@/components/AdminDashboard';
|
||||||
|
import ImportExport from '@/components/ImportExport';
|
||||||
|
import AnalyticsDashboard from '@/components/AnalyticsDashboard';
|
||||||
import { useToast } from '@/components/Toast';
|
import { useToast } from '@/components/Toast';
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -136,6 +139,8 @@ const AdminPage = () => {
|
|||||||
const [isPreview, setIsPreview] = useState(false);
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false);
|
const [isProjectsCollapsed, setIsProjectsCollapsed] = useState(false);
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
const [showImportExport, setShowImportExport] = useState(false);
|
||||||
|
const [showAnalytics, setShowAnalytics] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -667,8 +672,8 @@ DELETE /api/users/:id
|
|||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Projects Toggle Button - Always Visible */}
|
{/* Control Buttons */}
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center gap-4 mb-6">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => setIsProjectsCollapsed(!isProjectsCollapsed)}
|
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"
|
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>
|
||||||
|
|
||||||
|
<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>
|
</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'}`}>
|
<div className={`grid gap-8 ${isProjectsCollapsed ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-3'}`}>
|
||||||
{/* Projects List */}
|
{/* Projects List */}
|
||||||
<div className={`${isProjectsCollapsed ? 'hidden' : 'lg:col-span-1'}`}>
|
<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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { apiCache } from '@/lib/cache';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,6 +13,15 @@ export async function GET(request: NextRequest) {
|
|||||||
const difficulty = searchParams.get('difficulty');
|
const difficulty = searchParams.get('difficulty');
|
||||||
const search = searchParams.get('search');
|
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 skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -40,12 +50,19 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.project.count({ where })
|
prisma.project.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
const result = {
|
||||||
projects,
|
projects,
|
||||||
total,
|
total,
|
||||||
pages: Math.ceil(total / limit),
|
pages: Math.ceil(total / limit),
|
||||||
currentPage: page
|
currentPage: page
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Cache the result (only for non-search queries)
|
||||||
|
if (!search) {
|
||||||
|
await apiCache.setProjects(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -67,6 +84,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await apiCache.invalidateAll();
|
||||||
|
|
||||||
return NextResponse.json(project);
|
return NextResponse.json(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating project:', 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';
|
import { createContext, useContext, useCallback } from 'react';
|
||||||
|
|
||||||
interface ToastContextType {
|
interface ToastContextType {
|
||||||
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
showSuccess: (title: string, message?: string) => void;
|
showSuccess: (title: string, message?: string) => void;
|
||||||
showError: (title: string, message?: string) => void;
|
showError: (title: string, message?: string) => void;
|
||||||
@@ -265,6 +266,7 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
const contextValue: ToastContextType = {
|
const contextValue: ToastContextType = {
|
||||||
|
addToast,
|
||||||
showToast,
|
showToast,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
showError,
|
showError,
|
||||||
|
|||||||
@@ -8,20 +8,26 @@ services:
|
|||||||
container_name: portfolio-app
|
container_name: portfolio-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "4000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- 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}
|
- 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_EMAIL=${MY_EMAIL}
|
||||||
- MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
- MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
||||||
- MY_PASSWORD=${MY_PASSWORD}
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||||
|
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH}
|
||||||
volumes:
|
volumes:
|
||||||
- portfolio_data:/app/.next/cache
|
- portfolio_data:/app/.next/cache
|
||||||
networks:
|
networks:
|
||||||
- portfolio-network
|
- portfolio-network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -37,31 +43,64 @@ services:
|
|||||||
memory: 256M
|
memory: 256M
|
||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
|
|
||||||
nginx:
|
postgres:
|
||||||
image: nginx:alpine
|
image: postgres:16-alpine
|
||||||
container_name: portfolio-nginx
|
container_name: portfolio-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
environment:
|
||||||
- "80:80"
|
- POSTGRES_DB=portfolio_db
|
||||||
- "443:443"
|
- POSTGRES_USER=portfolio_user
|
||||||
|
- POSTGRES_PASSWORD=portfolio_pass
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./ssl:/etc/nginx/ssl:ro
|
|
||||||
- nginx_cache:/var/cache/nginx
|
|
||||||
depends_on:
|
|
||||||
- portfolio
|
|
||||||
networks:
|
networks:
|
||||||
- portfolio-network
|
- portfolio-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nginx", "-t"]
|
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_db"]
|
||||||
interval: 30s
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 3
|
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:
|
volumes:
|
||||||
portfolio_data:
|
portfolio_data:
|
||||||
driver: local
|
driver: local
|
||||||
nginx_cache:
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
24
env.example
24
env.example
@@ -5,27 +5,31 @@
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_BASE_URL=https://dki.one
|
NEXT_PUBLIC_BASE_URL=https://dki.one
|
||||||
|
|
||||||
# Ghost CMS
|
# Ghost CMS (removed - using built-in project management)
|
||||||
GHOST_API_URL=https://your-ghost-instance.com
|
# GHOST_API_URL=https://your-ghost-instance.com
|
||||||
GHOST_API_KEY=your-ghost-api-key
|
# GHOST_API_KEY=your-ghost-api-key
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration (optional - for contact form)
|
||||||
MY_EMAIL=your-email@example.com
|
MY_EMAIL=your-email@example.com
|
||||||
MY_INFO_EMAIL=your-info-email@example.com
|
MY_INFO_EMAIL=your-info-email@example.com
|
||||||
MY_PASSWORD=your-email-password
|
MY_PASSWORD=your-email-password
|
||||||
MY_INFO_PASSWORD=your-info-email-password
|
MY_INFO_PASSWORD=your-info-email-password
|
||||||
|
|
||||||
# Database (if using external database)
|
# Database (automatically set by Docker Compose)
|
||||||
# DATABASE_URL=postgresql://username:password@localhost:5432/portfolio
|
# 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
|
# Analytics
|
||||||
# NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
||||||
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
# JWT_SECRET=your-jwt-secret
|
# JWT_SECRET=your-jwt-secret
|
||||||
# ENCRYPTION_KEY=your-encryption-key
|
# ENCRYPTION_KEY=your-encryption-key
|
||||||
|
ADMIN_BASIC_AUTH=admin:your_secure_password_here
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring (optional)
|
||||||
# SENTRY_DSN=your-sentry-dsn
|
# 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 🚀 Starting automatic deployment for portfolio
|
||||||
[0;34m[2025-09-05 20:43:55][0m 📋 Step 1: Running code quality checks...
|
[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...
|
[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) {
|
export function middleware(request: NextRequest) {
|
||||||
// Allow email and projects API routes without authentication
|
// Allow email and projects API routes without authentication
|
||||||
if (request.nextUrl.pathname.startsWith('/api/email/') ||
|
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();
|
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
|
// For all other routes, continue with normal processing
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -18,10 +52,12 @@ export const config = {
|
|||||||
* Match all request paths except for the ones starting with:
|
* Match all request paths except for the ones starting with:
|
||||||
* - api/email (email API routes)
|
* - api/email (email API routes)
|
||||||
* - api/projects (projects API routes)
|
* - api/projects (projects API routes)
|
||||||
|
* - api/analytics (analytics API routes)
|
||||||
|
* - api/health (health check)
|
||||||
* - _next/static (static files)
|
* - _next/static (static files)
|
||||||
* - _next/image (image optimization files)
|
* - _next/image (image optimization files)
|
||||||
* - favicon.ico (favicon file)
|
* - 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": {
|
"dependencies": {
|
||||||
"@next/bundle-analyzer": "^15.1.7",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@prisma/client": "^5.7.1",
|
"@prisma/client": "^5.7.1",
|
||||||
|
"@types/redis": "^4.0.11",
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"redis": "^5.8.2",
|
||||||
"tailwind-merge": "^2.2.1"
|
"tailwind-merge": "^2.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2500,6 +2502,61 @@
|
|||||||
"@prisma/debug": "5.22.0"
|
"@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": {
|
"node_modules/@resvg/resvg-wasm": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.4.0.tgz",
|
||||||
@@ -2980,6 +3037,15 @@
|
|||||||
"@types/react": "*"
|
"@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": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@@ -4207,6 +4273,14 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -10562,6 +10636,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/bundle-analyzer": "^15.1.7",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@prisma/client": "^5.7.1",
|
"@prisma/client": "^5.7.1",
|
||||||
|
"@types/redis": "^4.0.11",
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
"react-responsive-masonry": "^2.7.1",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"redis": "^5.8.2",
|
||||||
"tailwind-merge": "^2.2.1"
|
"tailwind-merge": "^2.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ log "🚀 Quick deployment starting..."
|
|||||||
|
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
log "🏗️ Building Docker image..."
|
log "🏗️ Building Docker image..."
|
||||||
docker build -t "$IMAGE_NAME:latest" .
|
sudo docker build -t "$IMAGE_NAME:latest" .
|
||||||
|
|
||||||
# Stop existing container
|
# 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..."
|
log "🛑 Stopping existing container..."
|
||||||
docker stop "$CONTAINER_NAME"
|
sudo docker stop "$CONTAINER_NAME"
|
||||||
docker rm "$CONTAINER_NAME"
|
sudo docker rm "$CONTAINER_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start new container
|
# Start new container
|
||||||
log "🚀 Starting new container..."
|
log "🚀 Starting new container..."
|
||||||
docker run -d \
|
sudo docker run -d \
|
||||||
--name "$CONTAINER_NAME" \
|
--name "$CONTAINER_NAME" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-p "$PORT:3000" \
|
-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"
|
success "✅ Application is running at http://localhost:$PORT"
|
||||||
else
|
else
|
||||||
error "❌ Health check failed"
|
error "❌ Health check failed"
|
||||||
docker logs "$CONTAINER_NAME" --tail=20
|
sudo docker logs "$CONTAINER_NAME" --tail=20
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user