Initial commit: Portfolio Website
Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { User, Bot } from 'lucide-react';
|
||||
|
||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
export interface ChatMessageData {
|
||||
id?: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
created_at?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp for display
|
||||
*/
|
||||
function formatTime(timestamp?: string): string {
|
||||
if (!timestamp) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessage component for displaying individual chat messages
|
||||
* Styled differently based on message role (user vs assistant)
|
||||
*/
|
||||
export function ChatMessage({ message, index = 0 }: ChatMessageProps) {
|
||||
const { role, content, created_at, isStreaming } = message;
|
||||
|
||||
// Don't render system messages
|
||||
if (role === 'system') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUser = role === 'user';
|
||||
const formattedTime = formatTime(created_at);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.2 }}
|
||||
className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isUser
|
||||
? 'bg-zinc-600/50'
|
||||
: 'bg-zinc-800/50 border border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-4 h-4 text-zinc-300" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4 text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div
|
||||
className={`flex flex-col max-w-[80%] ${
|
||||
isUser ? 'items-end' : 'items-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`px-4 py-3 rounded-2xl ${
|
||||
isUser
|
||||
? 'bg-zinc-600/50 text-white rounded-tr-sm'
|
||||
: 'bg-zinc-800/50 border border-zinc-800 text-zinc-200 rounded-tl-sm'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-4 ml-1 bg-zinc-400 animate-pulse rounded-sm" />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
{formattedTime && !isStreaming && (
|
||||
<span className="text-xs text-zinc-500 mt-1 px-1">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing indicator shown when assistant is generating a response
|
||||
*/
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex gap-3"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-zinc-800/50 border border-zinc-700">
|
||||
<Bot className="w-4 h-4 text-zinc-400" />
|
||||
</div>
|
||||
|
||||
{/* Typing dots */}
|
||||
<div className="px-4 py-3 rounded-2xl rounded-tl-sm bg-zinc-800/50 border border-zinc-800">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-2 h-2 bg-zinc-500 rounded-full"
|
||||
animate={{
|
||||
opacity: [0.4, 1, 0.4],
|
||||
scale: [0.8, 1, 0.8],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome message component for empty chat state
|
||||
*/
|
||||
export function WelcomeMessage() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center justify-center py-8 px-4 text-center"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-zinc-800/50 border border-zinc-700 flex items-center justify-center mb-4">
|
||||
<Bot className="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
Hi, I'm Damjan's AI Assistant
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 max-w-xs">
|
||||
I can help you learn about Damjan's skills, services, and experience.
|
||||
Feel free to ask me anything!
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessage;
|
||||
Reference in New Issue
Block a user