🚀 Add automatic deployment system

- Add auto-deploy.sh script with full CI/CD pipeline
- Add quick-deploy.sh for fast development deployments
- Add Git post-receive hook for automatic deployment on push
- Add comprehensive deployment documentation
- Add npm scripts for easy deployment management
- Include health checks, logging, and cleanup
- Support for automatic rollback on failures
This commit is contained in:
Dennis Konkol
2025-09-05 19:47:53 +00:00
parent 203a332306
commit b9b3e5308d
32 changed files with 2490 additions and 441 deletions

View File

@@ -24,16 +24,37 @@ import {
Calendar,
Activity
} from 'lucide-react';
import { projectService, DatabaseProject } from '@/lib/prisma';
import { projectService } from '@/lib/prisma';
import { useToast } from './Toast';
interface Project {
id: number;
title: string;
description: string;
content: string;
imageUrl?: string | null;
github?: string | null;
liveUrl?: string | null;
tags: string[];
category: string;
difficulty: string;
featured: boolean;
published: boolean;
createdAt: Date;
updatedAt: Date;
_count?: {
pageViews: number;
userInteractions: number;
};
}
interface AdminDashboardProps {
onProjectSelect: (project: DatabaseProject) => void;
onProjectSelect: (project: Project) => void;
onNewProject: () => void;
}
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
const [projects, setProjects] = useState<DatabaseProject[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
@@ -52,7 +73,7 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
try {
setLoading(true);
const data = await projectService.getAllProjects();
setProjects(data);
setProjects(data.projects);
} catch (error) {
console.error('Error loading projects:', error);
// Fallback to localStorage if database fails
@@ -79,8 +100,8 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
switch (sortBy) {
case 'date':
aValue = new Date(a.created_at);
bValue = new Date(b.created_at);
aValue = new Date(a.createdAt);
bValue = new Date(b.createdAt);
break;
case 'title':
aValue = a.title.toLowerCase();
@@ -92,12 +113,12 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
break;
case 'views':
aValue = a.analytics.views;
bValue = b.analytics.views;
aValue = a._count?.pageViews || 0;
bValue = b._count?.pageViews || 0;
break;
default:
aValue = a.created_at;
bValue = b.created_at;
aValue = a.createdAt;
bValue = b.createdAt;
}
if (sortOrder === 'asc') {
@@ -113,10 +134,9 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
published: projects.filter(p => p.published).length,
featured: projects.filter(p => p.featured).length,
categories: new Set(projects.map(p => p.category)).size,
totalViews: projects.reduce((sum, p) => sum + p.analytics.views, 0),
totalLikes: projects.reduce((sum, p) => sum + p.analytics.likes, 0),
avgLighthouse: projects.length > 0 ?
Math.round(projects.reduce((sum, p) => sum + p.performance.lighthouse, 0) / projects.length) : 0
totalViews: projects.reduce((sum, p) => sum + (p._count?.pageViews || 0), 0),
totalLikes: projects.reduce((sum, p) => sum + (p._count?.userInteractions || 0), 0),
avgLighthouse: 0
};
// Bulk operations
@@ -514,15 +534,15 @@ export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminD
</span>
<span className="flex items-center">
<Calendar className="mr-1" size={14} />
{new Date(project.created_at).toLocaleDateString()}
{new Date(project.createdAt).toLocaleDateString()}
</span>
<span className="flex items-center">
<Eye className="mr-1" size={14} />
{project.analytics.views} views
{project._count?.pageViews || 0} views
</span>
<span className="flex items-center">
<Activity className="mr-1" size={14} />
{project.performance.lighthouse}/100
N/A
</span>
</div>
</div>

View File

@@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
import { useWebVitals } from '@/lib/useWebVitals';
import { trackEvent, trackPageLoad } from '@/lib/analytics';
interface AnalyticsProviderProps {
children: React.ReactNode;
}
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
// Initialize Web Vitals tracking
useWebVitals();
useEffect(() => {
if (typeof window === 'undefined') return;
// Track page view
const trackPageView = () => {
trackEvent('page-view', {
url: window.location.pathname,
referrer: document.referrer,
timestamp: Date.now(),
});
};
// Track page load performance
trackPageLoad();
// Track initial page view
trackPageView();
// Track route changes (for SPA navigation)
const handleRouteChange = () => {
setTimeout(() => {
trackPageView();
trackPageLoad();
}, 100);
};
// Listen for popstate events (back/forward navigation)
window.addEventListener('popstate', handleRouteChange);
// Track user interactions
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const element = target.tagName.toLowerCase();
const className = target.className;
const id = target.id;
trackEvent('click', {
element,
className: className ? className.split(' ')[0] : undefined,
id: id || undefined,
url: window.location.pathname,
});
};
// Track form submissions
const handleSubmit = (event: SubmitEvent) => {
const form = event.target as HTMLFormElement;
trackEvent('form-submit', {
formId: form.id || undefined,
formClass: form.className || undefined,
url: window.location.pathname,
});
};
// Track scroll depth
let maxScrollDepth = 0;
const handleScroll = () => {
const scrollDepth = Math.round(
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
);
if (scrollDepth > maxScrollDepth) {
maxScrollDepth = scrollDepth;
// Track scroll milestones
if (scrollDepth >= 25 && scrollDepth < 50 && maxScrollDepth >= 25) {
trackEvent('scroll-depth', { depth: 25, url: window.location.pathname });
} else if (scrollDepth >= 50 && scrollDepth < 75 && maxScrollDepth >= 50) {
trackEvent('scroll-depth', { depth: 50, url: window.location.pathname });
} else if (scrollDepth >= 75 && scrollDepth < 90 && maxScrollDepth >= 75) {
trackEvent('scroll-depth', { depth: 75, url: window.location.pathname });
} else if (scrollDepth >= 90 && maxScrollDepth >= 90) {
trackEvent('scroll-depth', { depth: 90, url: window.location.pathname });
}
}
};
// Add event listeners
document.addEventListener('click', handleClick);
document.addEventListener('submit', handleSubmit);
window.addEventListener('scroll', handleScroll, { passive: true });
// Track errors
const handleError = (event: ErrorEvent) => {
trackEvent('error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
url: window.location.pathname,
});
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
trackEvent('unhandled-rejection', {
reason: event.reason?.toString(),
url: window.location.pathname,
});
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
// Cleanup
return () => {
window.removeEventListener('popstate', handleRouteChange);
document.removeEventListener('click', handleClick);
document.removeEventListener('submit', handleSubmit);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
return <>{children}</>;
};

View File

@@ -0,0 +1,139 @@
'use client';
import { useState, useEffect } from 'react';
import { trackEvent } from '@/lib/analytics';
interface PerformanceData {
timestamp: string;
url: string;
metrics: {
LCP?: number;
FID?: number;
CLS?: number;
FCP?: number;
TTFB?: number;
};
}
export const PerformanceDashboard: React.FC = () => {
const [performanceData, setPerformanceData] = useState<PerformanceData[]>([]);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// This would typically fetch from your Umami instance or database
// For now, we'll show a placeholder
const mockData: PerformanceData[] = [
{
timestamp: new Date().toISOString(),
url: '/',
metrics: {
LCP: 1200,
FID: 45,
CLS: 0.1,
FCP: 800,
TTFB: 200,
},
},
];
setPerformanceData(mockData);
}, []);
const getPerformanceGrade = (metric: string, value: number): string => {
switch (metric) {
case 'LCP':
return value <= 2500 ? 'Good' : value <= 4000 ? 'Needs Improvement' : 'Poor';
case 'FID':
return value <= 100 ? 'Good' : value <= 300 ? 'Needs Improvement' : 'Poor';
case 'CLS':
return value <= 0.1 ? 'Good' : value <= 0.25 ? 'Needs Improvement' : 'Poor';
case 'FCP':
return value <= 1800 ? 'Good' : value <= 3000 ? 'Needs Improvement' : 'Poor';
case 'TTFB':
return value <= 800 ? 'Good' : value <= 1800 ? 'Needs Improvement' : 'Poor';
default:
return 'Unknown';
}
};
const getGradeColor = (grade: string): string => {
switch (grade) {
case 'Good':
return 'text-green-600 bg-green-100';
case 'Needs Improvement':
return 'text-yellow-600 bg-yellow-100';
case 'Poor':
return 'text-red-600 bg-red-100';
default:
return 'text-gray-600 bg-gray-100';
}
};
if (!isVisible) {
return (
<button
onClick={() => {
setIsVisible(true);
trackEvent('dashboard-toggle', { action: 'show' });
}}
className="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-blue-700 transition-colors z-50"
>
📊 Performance
</button>
);
}
return (
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-6 w-96 max-h-96 overflow-y-auto z-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-800">Performance Dashboard</h3>
<button
onClick={() => {
setIsVisible(false);
trackEvent('dashboard-toggle', { action: 'hide' });
}}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="space-y-4">
{performanceData.map((data, index) => (
<div key={index} className="border-b border-gray-100 pb-4">
<div className="text-sm text-gray-600 mb-2">
{new Date(data.timestamp).toLocaleString()}
</div>
<div className="text-sm font-medium text-gray-800 mb-2">
{data.url}
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data.metrics).map(([metric, value]) => {
const grade = getPerformanceGrade(metric, value);
return (
<div key={metric} className="flex justify-between items-center">
<span className="text-xs font-medium text-gray-600">{metric}:</span>
<div className="flex items-center space-x-2">
<span className="text-xs font-mono">{value}ms</span>
<span className={`text-xs px-2 py-1 rounded ${getGradeColor(grade)}`}>
{grade}
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="text-xs text-gray-500">
<div>🟢 Good: Meets recommended thresholds</div>
<div>🟡 Needs Improvement: Below recommended thresholds</div>
<div>🔴 Poor: Significantly below thresholds</div>
</div>
</div>
</div>
);
};

View File

@@ -216,7 +216,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
title: 'E-Mail gesendet! 📧',
message: `Deine Nachricht an ${email} wurde erfolgreich versendet.`,
duration: 5000,
icon: <Mail className="w-5 h-5 text-green-400" />
});
}, [addToast]);
@@ -235,7 +234,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
title: 'Projekt gespeichert! 💾',
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
duration: 4000,
icon: <Save className="w-5 h-5 text-green-400" />
});
}, [addToast]);
@@ -245,7 +243,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
title: 'Projekt gelöscht! 🗑️',
message: `"${title}" wurde aus der Datenbank entfernt.`,
duration: 4000,
icon: <Trash2 className="w-5 h-5 text-yellow-400" />
});
}, [addToast]);
@@ -255,7 +252,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
title: 'Import erfolgreich! 📥',
message: `${count} Projekte wurden erfolgreich importiert.`,
duration: 5000,
icon: <Upload className="w-5 h-5 text-green-400" />
});
}, [addToast]);
@@ -265,7 +261,6 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
title: 'Import Fehler! ❌',
message: `Fehler beim Importieren: ${error}`,
duration: 8000,
icon: <Download className="w-5 h-5 text-red-400" />
});
}, [addToast]);