🚀 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:
@@ -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>
|
||||
|
||||
130
components/AnalyticsProvider.tsx
Normal file
130
components/AnalyticsProvider.tsx
Normal 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}</>;
|
||||
};
|
||||
139
components/PerformanceDashboard.tsx
Normal file
139
components/PerformanceDashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user