- Updated admin URLs from /admin to /manage - Integrated new admin dashboard and email management features - Added authentication system and project management - Resolved conflicts in DEV-SETUP.md, README.md, email routes, and components - Removed old admin page in favor of new manage page
615 lines
24 KiB
TypeScript
615 lines
24 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
<<<<<<< HEAD
|
||
import { EmailResponder } from './EmailResponder';
|
||
|
||
interface ContactMessage {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
subject: string;
|
||
message: string;
|
||
timestamp: string;
|
||
responded: boolean;
|
||
}
|
||
|
||
export const EmailManager: React.FC = () => {
|
||
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||
const [showResponder, setShowResponder] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [filter, setFilter] = useState<'all' | 'unread' | 'responded'>('all');
|
||
|
||
// Mock data for demonstration - in real app, fetch from API
|
||
useEffect(() => {
|
||
const mockMessages: ContactMessage[] = [
|
||
{
|
||
id: '1',
|
||
name: 'Max Mustermann',
|
||
email: 'max@example.com',
|
||
subject: 'Projekt-Anfrage',
|
||
message: 'Hallo Dennis,\n\nich interessiere mich für eine Zusammenarbeit an einem Web-Projekt. Können wir uns mal unterhalten?\n\nViele Grüße\nMax',
|
||
timestamp: new Date().toISOString(),
|
||
responded: false
|
||
},
|
||
{
|
||
id: '2',
|
||
name: 'Anna Schmidt',
|
||
email: 'anna@example.com',
|
||
subject: 'Frage zu deinem Portfolio',
|
||
message: 'Hi Dennis,\n\nsehr cooles Portfolio! Wie lange hast du an dem Design gearbeitet?\n\nLG Anna',
|
||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||
responded: true
|
||
},
|
||
{
|
||
id: '3',
|
||
name: 'Tom Weber',
|
||
email: 'tom@example.com',
|
||
subject: 'Job-Anfrage',
|
||
message: 'Hallo,\n\nwir suchen einen Full-Stack Developer. Bist du interessiert?\n\nTom',
|
||
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||
responded: false
|
||
}
|
||
];
|
||
|
||
setTimeout(() => {
|
||
setMessages(mockMessages);
|
||
setIsLoading(false);
|
||
}, 1000);
|
||
}, []);
|
||
|
||
const filteredMessages = messages.filter(message => {
|
||
switch (filter) {
|
||
case 'unread':
|
||
return !message.responded;
|
||
case 'responded':
|
||
return message.responded;
|
||
default:
|
||
return true;
|
||
}
|
||
});
|
||
|
||
const handleRespond = (message: ContactMessage) => {
|
||
setSelectedMessage(message);
|
||
setShowResponder(true);
|
||
};
|
||
|
||
const handleResponseSent = () => {
|
||
if (selectedMessage) {
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.id === selectedMessage.id
|
||
? { ...msg, responded: true }
|
||
: msg
|
||
));
|
||
}
|
||
setShowResponder(false);
|
||
setSelectedMessage(null);
|
||
};
|
||
|
||
const formatDate = (timestamp: string) => {
|
||
return new Date(timestamp).toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
const getMessagePreview = (message: string) => {
|
||
return message.length > 100 ? message.substring(0, 100) + '...' : message;
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
|
||
{/* Header */}
|
||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-900">📧 E-Mail Manager</h2>
|
||
<p className="text-gray-600 mt-1">Verwalte Kontaktanfragen und sende schöne Antworten</p>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<div className="text-sm text-gray-600">
|
||
{filteredMessages.length} von {messages.length} Nachrichten
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setFilter('all')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||
filter === 'all'
|
||
? 'bg-blue-100 text-blue-700 border border-blue-200'
|
||
: 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
Alle ({messages.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setFilter('unread')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||
filter === 'unread'
|
||
? 'bg-red-100 text-red-700 border border-red-200'
|
||
: 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
Ungelesen ({messages.filter(m => !m.responded).length})
|
||
</button>
|
||
<button
|
||
onClick={() => setFilter('responded')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||
filter === 'responded'
|
||
? 'bg-green-100 text-green-700 border border-green-200'
|
||
: 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
Beantwortet ({messages.filter(m => m.responded).length})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages List */}
|
||
<div className="space-y-4">
|
||
{filteredMessages.length === 0 ? (
|
||
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
|
||
<div className="text-6xl mb-4">📭</div>
|
||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Nachrichten</h3>
|
||
<p className="text-gray-600">
|
||
{filter === 'unread' && 'Alle Nachrichten wurden beantwortet!'}
|
||
{filter === 'responded' && 'Noch keine Nachrichten beantwortet.'}
|
||
{filter === 'all' && 'Noch keine Kontaktanfragen eingegangen.'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
filteredMessages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={`bg-white rounded-xl shadow-sm border p-6 transition-all hover:shadow-md ${
|
||
!message.responded ? 'border-l-4 border-l-red-500' : 'border-l-4 border-l-green-500'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div className={`w-3 h-3 rounded-full ${
|
||
message.responded ? 'bg-green-500' : 'bg-red-500'
|
||
}`}></div>
|
||
<h3 className="font-semibold text-gray-900">{message.name}</h3>
|
||
<span className="text-sm text-gray-500">{message.email}</span>
|
||
{!message.responded && (
|
||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||
Neu
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<h4 className="font-medium text-gray-800 mb-2">{message.subject}</h4>
|
||
|
||
<p className="text-gray-600 text-sm mb-3 whitespace-pre-wrap">
|
||
{getMessagePreview(message.message)}
|
||
</p>
|
||
|
||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||
<span>📅 {formatDate(message.timestamp)}</span>
|
||
{message.responded && (
|
||
<span className="text-green-600 font-medium">✅ Beantwortet</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 ml-4">
|
||
{!message.responded && (
|
||
<button
|
||
onClick={() => handleRespond(message)}
|
||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all font-medium text-sm flex items-center gap-2"
|
||
>
|
||
📧 Antworten
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
setSelectedMessage(message);
|
||
// Show full message modal
|
||
}}
|
||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium text-sm"
|
||
>
|
||
👁️ Ansehen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Email Responder Modal */}
|
||
{showResponder && selectedMessage && (
|
||
<EmailResponder
|
||
contactEmail={selectedMessage.email}
|
||
contactName={selectedMessage.name}
|
||
originalMessage={selectedMessage.message}
|
||
onClose={handleResponseSent}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
=======
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Mail,
|
||
Search,
|
||
Reply,
|
||
User,
|
||
CheckCircle,
|
||
Circle,
|
||
Send,
|
||
X,
|
||
RefreshCw,
|
||
Eye,
|
||
Calendar,
|
||
AtSign
|
||
} from 'lucide-react';
|
||
|
||
interface ContactMessage {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
subject: string;
|
||
message: string;
|
||
createdAt: string;
|
||
read: boolean;
|
||
responded: boolean;
|
||
priority: 'low' | 'medium' | 'high';
|
||
}
|
||
|
||
export const EmailManager: React.FC = () => {
|
||
const [messages, setMessages] = useState<ContactMessage[]>([]);
|
||
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [filter, setFilter] = useState<'all' | 'unread' | 'responded'>('all');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [showReplyModal, setShowReplyModal] = useState(false);
|
||
const [replyContent, setReplyContent] = useState('');
|
||
|
||
// Load messages from API
|
||
const loadMessages = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await fetch('/api/contacts', {
|
||
headers: {
|
||
'x-admin-request': 'true'
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const formattedMessages = data.contacts.map((contact: ContactMessage) => ({
|
||
id: contact.id.toString(),
|
||
name: contact.name,
|
||
email: contact.email,
|
||
subject: contact.subject,
|
||
message: contact.message,
|
||
createdAt: contact.createdAt,
|
||
read: false,
|
||
responded: contact.responded || false,
|
||
priority: 'medium' as const
|
||
}));
|
||
setMessages(formattedMessages);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading messages:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadMessages();
|
||
}, []);
|
||
|
||
const filteredMessages = messages.filter(message => {
|
||
const matchesFilter = filter === 'all' ||
|
||
(filter === 'unread' && !message.read) ||
|
||
(filter === 'responded' && message.responded);
|
||
|
||
const matchesSearch = searchTerm === '' ||
|
||
message.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
message.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
message.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
return matchesFilter && matchesSearch;
|
||
});
|
||
|
||
const handleMessageClick = (message: ContactMessage) => {
|
||
setSelectedMessage(message);
|
||
|
||
// Mark as read
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.id === message.id ? { ...msg, read: true } : msg
|
||
));
|
||
};
|
||
|
||
const handleReply = async () => {
|
||
if (!selectedMessage || !replyContent.trim()) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/email/respond', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
to: selectedMessage.email,
|
||
name: selectedMessage.name,
|
||
template: 'reply',
|
||
originalMessage: selectedMessage.message,
|
||
response: replyContent
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.id === selectedMessage.id ? { ...msg, responded: true } : msg
|
||
));
|
||
setShowReplyModal(false);
|
||
setReplyContent('');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error sending reply:', error);
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const getPriorityColor = (priority: string) => {
|
||
switch (priority) {
|
||
case 'high': return 'text-red-400';
|
||
case 'medium': return 'text-yellow-400';
|
||
case 'low': return 'text-green-400';
|
||
default: return 'text-blue-400';
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<motion.div
|
||
animate={{ rotate: 360 }}
|
||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||
className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">Email Manager</h2>
|
||
<p className="text-white/70 mt-1">Manage your contact messages</p>
|
||
</div>
|
||
<button
|
||
onClick={loadMessages}
|
||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
<span>Refresh</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filters and Search */}
|
||
<div className="flex flex-col sm:flex-row gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/50 w-4 h-4" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search messages..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div className="flex space-x-2">
|
||
{['all', 'unread', 'responded'].map((filterType) => (
|
||
<button
|
||
key={filterType}
|
||
onClick={() => setFilter(filterType as 'all' | 'unread' | 'responded')}
|
||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||
filter === filterType
|
||
? 'bg-blue-500 text-white'
|
||
: 'bg-white/10 text-white/70 hover:bg-white/20'
|
||
}`}
|
||
>
|
||
{filterType.charAt(0).toUpperCase() + filterType.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages List */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-1 space-y-3">
|
||
{filteredMessages.length === 0 ? (
|
||
<div className="text-center py-12 text-white/50">
|
||
<Mail className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||
<p>No messages found</p>
|
||
</div>
|
||
) : (
|
||
filteredMessages.map((message) => (
|
||
<motion.div
|
||
key={message.id}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className={`p-4 rounded-lg cursor-pointer transition-all ${
|
||
selectedMessage?.id === message.id
|
||
? 'bg-blue-500/20 border border-blue-500/50'
|
||
: 'bg-white/5 border border-white/10 hover:bg-white/10'
|
||
}`}
|
||
onClick={() => handleMessageClick(message)}
|
||
>
|
||
<div className="flex items-start justify-between mb-2">
|
||
<h3 className="font-semibold text-white truncate">{message.subject}</h3>
|
||
<div className="flex items-center space-x-2">
|
||
{!message.read && <Circle className="w-3 h-3 text-blue-400" />}
|
||
{message.responded && <CheckCircle className="w-3 h-3 text-green-400" />}
|
||
</div>
|
||
</div>
|
||
<p className="text-white/70 text-sm mb-2">{message.name}</p>
|
||
<p className="text-white/50 text-xs">{formatDate(message.createdAt)}</p>
|
||
</motion.div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Message Detail */}
|
||
<div className="lg:col-span-2 admin-glass-card p-6 rounded-xl">
|
||
{selectedMessage ? (
|
||
<div className="space-y-6">
|
||
{/* Message Header */}
|
||
<div className="flex items-start justify-between">
|
||
<div className="space-y-2">
|
||
<h3 className="text-xl font-bold text-white">{selectedMessage.subject}</h3>
|
||
<div className="flex items-center space-x-4 text-sm text-white/70">
|
||
<div className="flex items-center space-x-2">
|
||
<User className="w-4 h-4" />
|
||
<span>{selectedMessage.name}</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<AtSign className="w-4 h-4" />
|
||
<span>{selectedMessage.email}</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Calendar className="w-4 h-4" />
|
||
<span>{formatDate(selectedMessage.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
{!selectedMessage.read && <Circle className="w-4 h-4 text-blue-400" />}
|
||
{selectedMessage.responded && <CheckCircle className="w-4 h-4 text-green-400" />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Message Body */}
|
||
<div className="p-4 bg-white/5 rounded-lg border border-white/10">
|
||
<h4 className="text-white font-medium mb-3">Message:</h4>
|
||
<div className="text-white/80 whitespace-pre-wrap leading-relaxed">
|
||
{selectedMessage.message}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex space-x-3">
|
||
<button
|
||
onClick={() => setShowReplyModal(true)}
|
||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||
>
|
||
<Reply className="w-4 h-4" />
|
||
<span>Reply</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedMessage(null)}
|
||
className="px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-white/50">
|
||
<Eye className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||
<p>Select a message to view details</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* Reply Modal */}
|
||
<AnimatePresence>
|
||
{showReplyModal && selectedMessage && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||
onClick={() => setShowReplyModal(false)}
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.9, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
exit={{ scale: 0.9, opacity: 0 }}
|
||
className="bg-gray-900/95 backdrop-blur-xl border border-white/20 rounded-2xl p-6 max-w-2xl w-full"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-xl font-bold text-white">Reply to {selectedMessage.name}</h2>
|
||
<button
|
||
onClick={() => setShowReplyModal(false)}
|
||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-white/70" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<textarea
|
||
value={replyContent}
|
||
onChange={(e) => setReplyContent(e.target.value)}
|
||
placeholder="Type your reply..."
|
||
className="w-full h-32 p-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||
/>
|
||
|
||
<div className="flex space-x-3">
|
||
<button
|
||
onClick={handleReply}
|
||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
<span>Send Reply</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setShowReplyModal(false)}
|
||
className="px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
>>>>>>> dev
|