update
This commit is contained in:
562
components/AdminDashboard.tsx
Normal file
562
components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Database,
|
||||
Search,
|
||||
Filter,
|
||||
BarChart3,
|
||||
Download,
|
||||
Upload,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
Plus,
|
||||
Save,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Clock,
|
||||
Star,
|
||||
Tag,
|
||||
FolderOpen,
|
||||
Calendar,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { projectService, DatabaseProject } from '@/lib/prisma';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
interface AdminDashboardProps {
|
||||
onProjectSelect: (project: DatabaseProject) => void;
|
||||
onNewProject: () => void;
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ onProjectSelect, onNewProject }: AdminDashboardProps) {
|
||||
const [projects, setProjects] = useState<DatabaseProject[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'title' | 'difficulty' | 'views'>('date');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<number>>(new Set());
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
const { showImportSuccess, showImportError, showError } = useToast();
|
||||
|
||||
// Load projects from database
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await projectService.getAllProjects();
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
// Fallback to localStorage if database fails
|
||||
const savedProjects = localStorage.getItem('portfolio-projects');
|
||||
if (savedProjects) {
|
||||
setProjects(JSON.parse(savedProjects));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort projects
|
||||
const filteredProjects = projects
|
||||
.filter(project => {
|
||||
const matchesSearch = project.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesCategory = !selectedCategory || project.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
aValue = new Date(a.created_at);
|
||||
bValue = new Date(b.created_at);
|
||||
break;
|
||||
case 'title':
|
||||
aValue = a.title.toLowerCase();
|
||||
bValue = b.title.toLowerCase();
|
||||
break;
|
||||
case 'difficulty':
|
||||
const difficultyOrder = { 'Beginner': 1, 'Intermediate': 2, 'Advanced': 3, 'Expert': 4 };
|
||||
aValue = difficultyOrder[a.difficulty as keyof typeof difficultyOrder];
|
||||
bValue = difficultyOrder[b.difficulty as keyof typeof difficultyOrder];
|
||||
break;
|
||||
case 'views':
|
||||
aValue = a.analytics.views;
|
||||
bValue = b.analytics.views;
|
||||
break;
|
||||
default:
|
||||
aValue = a.created_at;
|
||||
bValue = b.created_at;
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Statistics
|
||||
const stats = {
|
||||
total: projects.length,
|
||||
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
|
||||
};
|
||||
|
||||
// Bulk operations
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedProjects.size === 0) return;
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${selectedProjects.size} projects?`)) {
|
||||
try {
|
||||
for (const id of selectedProjects) {
|
||||
await projectService.deleteProject(id);
|
||||
}
|
||||
setSelectedProjects(new Set());
|
||||
await loadProjects();
|
||||
showImportSuccess(selectedProjects.size); // Reuse for success message
|
||||
} catch (error) {
|
||||
console.error('Error deleting projects:', error);
|
||||
showError('Fehler beim Löschen', 'Einige Projekte konnten nicht gelöscht werden.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPublish = async (published: boolean) => {
|
||||
if (selectedProjects.size === 0) return;
|
||||
|
||||
try {
|
||||
for (const id of selectedProjects) {
|
||||
await projectService.updateProject(id, { published });
|
||||
}
|
||||
setSelectedProjects(new Set());
|
||||
await loadProjects();
|
||||
showImportSuccess(selectedProjects.size); // Reuse for success message
|
||||
} catch (error) {
|
||||
console.error('Error updating projects:', error);
|
||||
showError('Fehler beim Aktualisieren', 'Einige Projekte konnten nicht aktualisiert werden.');
|
||||
}
|
||||
};
|
||||
|
||||
// Export/Import
|
||||
const exportProjects = () => {
|
||||
const dataStr = JSON.stringify(projects, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importProjects = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const importedProjects = JSON.parse(e.target?.result as string);
|
||||
// Validate and import projects
|
||||
let importedCount = 0;
|
||||
for (const project of importedProjects) {
|
||||
if (project.id) delete project.id; // Remove ID for new import
|
||||
await projectService.createProject(project);
|
||||
importedCount++;
|
||||
}
|
||||
await loadProjects();
|
||||
showImportSuccess(importedCount);
|
||||
} catch (error) {
|
||||
console.error('Error importing projects:', error);
|
||||
showImportError('Bitte überprüfe das Dateiformat und versuche es erneut.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const categories = Array.from(new Set(projects.map(p => p.category)));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white flex items-center">
|
||||
<Database className="mr-3 text-blue-400" />
|
||||
Project Database
|
||||
</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowStats(!showStats)}
|
||||
className={`px-4 py-2 rounded-lg transition-colors flex items-center space-x-2 ${
|
||||
showStats ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 size={20} />
|
||||
<span>Stats</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={exportProjects}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Download size={20} />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<label className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center space-x-2 cursor-pointer">
|
||||
<Upload size={20} />
|
||||
<span>Import</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={importProjects}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gradient-to-br from-blue-500/20 to-blue-600/20 p-4 rounded-xl border border-blue-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-blue-300">Total Projects</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.total}</p>
|
||||
</div>
|
||||
<FolderOpen className="text-blue-400" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-500/20 to-green-600/20 p-4 rounded-xl border border-green-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-300">Published</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.published}</p>
|
||||
</div>
|
||||
<Eye className="text-green-400" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-600/20 p-4 rounded-xl border border-yellow-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-yellow-300">Featured</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.featured}</p>
|
||||
</div>
|
||||
<Star className="text-yellow-400" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-purple-600/20 p-4 rounded-xl border border-purple-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-purple-300">Categories</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.categories}</p>
|
||||
</div>
|
||||
<Tag className="text-purple-400" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
{showStats && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-indigo-500/20 to-indigo-600/20 p-4 rounded-xl border border-indigo-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-indigo-300">Total Views</p>
|
||||
<p className="text-xl font-bold text-white">{stats.totalViews.toLocaleString()}</p>
|
||||
</div>
|
||||
<TrendingUp className="text-indigo-400" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-pink-500/20 to-pink-600/20 p-4 rounded-xl border border-pink-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-pink-300">Total Likes</p>
|
||||
<p className="text-xl font-bold text-white">{stats.totalLikes.toLocaleString()}</p>
|
||||
</div>
|
||||
<Users className="text-pink-400" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-orange-500/20 to-orange-600/20 p-4 rounded-xl border border-orange-500/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-orange-300">Avg Lighthouse</p>
|
||||
<p className="text-xl font-bold text-white">{stats.avgLighthouse}/100</p>
|
||||
</div>
|
||||
<Activity className="text-orange-400" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Controls */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="title">Sort by Title</option>
|
||||
<option value="difficulty">Sort by Difficulty</option>
|
||||
<option value="views">Sort by Views</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
|
||||
className="w-full px-4 py-2 bg-gray-800/50 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="desc">Descending</option>
|
||||
<option value="asc">Ascending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedProjects.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center space-x-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg"
|
||||
>
|
||||
<span className="text-blue-300 font-medium">
|
||||
{selectedProjects.size} project(s) selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleBulkPublish(true)}
|
||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Publish All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkPublish(false)}
|
||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Unpublish All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedProjects(new Set())}
|
||||
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Projects List */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="glass-card p-6 rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
Projects ({filteredProjects.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onNewProject}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>New Project</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredProjects.map((project) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className={`p-4 rounded-lg cursor-pointer transition-all border ${
|
||||
selectedProjects.has(project.id)
|
||||
? 'bg-blue-600/20 border-blue-500/50'
|
||||
: 'bg-gray-800/30 hover:bg-gray-700/30 border-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjects.has(project.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedProjects);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(project.id);
|
||||
} else {
|
||||
newSelected.delete(project.id);
|
||||
}
|
||||
setSelectedProjects(newSelected);
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-800 border-gray-700 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h4 className="font-medium text-white">{project.title}</h4>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
project.difficulty === 'Beginner' ? 'bg-green-500/20 text-green-400' :
|
||||
project.difficulty === 'Intermediate' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
project.difficulty === 'Advanced' ? 'bg-orange-500/20 text-orange-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{project.difficulty}
|
||||
</span>
|
||||
{project.featured && (
|
||||
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
{project.published ? (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium">
|
||||
Published
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-500/20 text-gray-400 rounded text-xs font-medium">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-2">{project.description}</p>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<Tag className="mr-1" size={14} />
|
||||
{project.category}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Calendar className="mr-1" size={14} />
|
||||
{new Date(project.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Eye className="mr-1" size={14} />
|
||||
{project.analytics.views} views
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Activity className="mr-1" size={14} />
|
||||
{project.performance.lighthouse}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onProjectSelect(project)}
|
||||
className="p-2 text-gray-400 hover:text-blue-400 transition-colors"
|
||||
title="Edit Project"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`/projects/${project.id}`, '_blank')}
|
||||
className="p-2 text-gray-400 hover:text-green-400 transition-colors"
|
||||
title="View Project"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredProjects.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FolderOpen className="mx-auto mb-4" size={48} />
|
||||
<p className="text-lg font-medium">No projects found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
components/Toast.tsx
Normal file
306
components/Toast.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
X,
|
||||
Mail,
|
||||
Database,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastItem = ({ toast, onRemove }: ToastProps) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration !== 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onRemove(toast.id), 300);
|
||||
}, toast.duration || 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast.duration, toast.id, onRemove]);
|
||||
|
||||
const getIcon = () => {
|
||||
switch (toast.type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-green-400" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-5 h-5 text-red-400" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-5 h-5 text-yellow-400" />;
|
||||
case 'info':
|
||||
return <Info className="w-5 h-5 text-blue-400" />;
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-blue-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getColors = () => {
|
||||
switch (toast.type) {
|
||||
case 'success':
|
||||
return 'bg-white border-green-300 text-green-900 shadow-lg';
|
||||
case 'error':
|
||||
return 'bg-white border-red-300 text-red-900 shadow-lg';
|
||||
case 'warning':
|
||||
return 'bg-white border-yellow-300 text-yellow-900 shadow-lg';
|
||||
case 'info':
|
||||
return 'bg-white border-blue-300 text-blue-900 shadow-lg';
|
||||
default:
|
||||
return 'bg-white border-gray-300 text-gray-900 shadow-lg';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className={`relative p-4 rounded-xl border ${getColors()} shadow-xl hover:shadow-2xl transition-all duration-300 max-w-sm`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold mb-1">{toast.title}</h4>
|
||||
<p className="text-sm opacity-90">{toast.message}</p>
|
||||
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={toast.action.onClick}
|
||||
className="mt-2 text-xs font-medium underline hover:no-underline transition-all"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onRemove(toast.id), 300);
|
||||
}}
|
||||
className="flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{toast.duration !== 0 && (
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%' }}
|
||||
transition={{ duration: (toast.duration || 5000) / 1000, ease: "linear" }}
|
||||
className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-blue-400 to-green-400 rounded-b-xl"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast context and provider
|
||||
import { createContext, useContext, useCallback } from 'react';
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
showSuccess: (title: string, message?: string) => void;
|
||||
showError: (title: string, message?: string) => void;
|
||||
showWarning: (title: string, message?: string) => void;
|
||||
showInfo: (title: string, message?: string) => void;
|
||||
showEmailSent: (email: string) => void;
|
||||
showEmailError: (error: string) => void;
|
||||
showProjectSaved: (title: string) => void;
|
||||
showProjectDeleted: (title: string) => void;
|
||||
showImportSuccess: (count: number) => void;
|
||||
showImportError: (error: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newToast = { ...toast, id };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
addToast(toast);
|
||||
}, [addToast]);
|
||||
|
||||
const showSuccess = useCallback((title: string, message?: string) => {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title,
|
||||
message: message || '',
|
||||
duration: 4000
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showError = useCallback((title: string, message?: string) => {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title,
|
||||
message: message || '',
|
||||
duration: 6000
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showWarning = useCallback((title: string, message?: string) => {
|
||||
addToast({
|
||||
type: 'warning',
|
||||
title,
|
||||
message: message || '',
|
||||
duration: 5000
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showInfo = useCallback((title: string, message?: string) => {
|
||||
addToast({
|
||||
type: 'info',
|
||||
title,
|
||||
message: message || '',
|
||||
duration: 4000
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showEmailSent = useCallback((email: string) => {
|
||||
addToast({
|
||||
type: 'success',
|
||||
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]);
|
||||
|
||||
const showEmailError = useCallback((error: string) => {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'E-Mail Fehler! ❌',
|
||||
message: `Fehler beim Senden: ${error}`,
|
||||
duration: 8000
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showProjectSaved = useCallback((title: string) => {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Projekt gespeichert! 💾',
|
||||
message: `"${title}" wurde erfolgreich in der Datenbank gespeichert.`,
|
||||
duration: 4000,
|
||||
icon: <Save className="w-5 h-5 text-green-400" />
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showProjectDeleted = useCallback((title: string) => {
|
||||
addToast({
|
||||
type: 'warning',
|
||||
title: 'Projekt gelöscht! 🗑️',
|
||||
message: `"${title}" wurde aus der Datenbank entfernt.`,
|
||||
duration: 4000,
|
||||
icon: <Trash2 className="w-5 h-5 text-yellow-400" />
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showImportSuccess = useCallback((count: number) => {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Import erfolgreich! 📥',
|
||||
message: `${count} Projekte wurden erfolgreich importiert.`,
|
||||
duration: 5000,
|
||||
icon: <Upload className="w-5 h-5 text-green-400" />
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const showImportError = useCallback((error: string) => {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Import Fehler! ❌',
|
||||
message: `Fehler beim Importieren: ${error}`,
|
||||
duration: 8000,
|
||||
icon: <Download className="w-5 h-5 text-red-400" />
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const contextValue: ToastContextType = {
|
||||
showToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
showEmailSent,
|
||||
showEmailError,
|
||||
showProjectSaved,
|
||||
showProjectDeleted,
|
||||
showImportSuccess,
|
||||
showImportError
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-3 max-w-sm">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRemove={removeToast}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastItem;
|
||||
Reference in New Issue
Block a user