full upgrade to dev

This commit is contained in:
2026-01-08 04:24:22 +01:00
parent e2c2585468
commit 884d7f984b
15 changed files with 2371 additions and 369 deletions

View File

@@ -1,19 +1,21 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import {
Code2,
Disc3,
Gamepad2,
ExternalLink,
Cpu,
Zap,
Clock,
Music
ChevronDown,
ChevronUp,
Activity,
X,
} from "lucide-react";
// Types passend zu deinem n8n Output
// Types matching your n8n output
interface StatusData {
status: {
text: string;
@@ -38,6 +40,7 @@ interface StatusData {
isActive: boolean;
project?: string;
file?: string;
language?: string;
stats?: {
time: string;
topLang: string;
@@ -48,213 +51,517 @@ interface StatusData {
export default function ActivityFeed() {
const [data, setData] = useState<StatusData | null>(null);
const [isExpanded, setIsExpanded] = useState(true);
const [isMinimized, setIsMinimized] = useState(false);
const [hasActivity, setHasActivity] = useState(false);
const [quote, setQuote] = useState<{
content: string;
author: string;
} | null>(null);
// Daten abrufen (alle 10 Sekunden für schnelleres Feedback)
// Fetch data every 30 seconds (optimized to match server cache)
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch("/api/n8n/status");
// Add timestamp to prevent aggressive caching but respect server cache
const res = await fetch("/api/n8n/status", {
cache: "default",
});
if (!res.ok) return;
const json = await res.json();
let json = await res.json();
console.log("ActivityFeed data (raw):", json);
// Handle array response if API returns it wrapped
if (Array.isArray(json)) {
json = json[0] || null;
}
console.log("ActivityFeed data (processed):", json);
setData(json);
// Check if there's any active activity
const hasActiveActivity =
json.coding?.isActive ||
json.gaming?.isPlaying ||
json.music?.isPlaying;
console.log("Has activity:", hasActiveActivity, {
coding: json.coding?.isActive,
gaming: json.gaming?.isPlaying,
music: json.music?.isPlaying,
});
setHasActivity(hasActiveActivity);
// Auto-expand if there's new activity and not minimized
if (hasActiveActivity && !isMinimized) {
setIsExpanded(true);
}
} catch (e) {
console.error("Failed to fetch activity", e);
}
};
fetchData();
const interval = setInterval(fetchData, 10000); // 10s Refresh
// Optimized: Poll every 30 seconds instead of 10 to reduce server load
// The n8n API already has 30s cache, so faster polling doesn't help
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
}, [isMinimized]);
// Fetch nerdy quote when idle
useEffect(() => {
if (!hasActivity && !quote) {
const techQuotes = [
{
content: "Simplicity is the soul of efficiency.",
author: "Austin Freeman",
},
{
content: "Talk is cheap. Show me the code.",
author: "Linus Torvalds",
},
{
content: "Code is like humor. When you have to explain it, its bad.",
author: "Cory House",
},
{
content: "Fix the cause, not the symptom.",
author: "Steve Maguire",
},
{
content:
"Optimism is an occupational hazard of programming: feedback is the treatment.",
author: "Kent Beck",
},
{
content: "Make it work, make it right, make it fast.",
author: "Kent Beck",
},
{
content: "First, solve the problem. Then, write the code.",
author: "John Johnson",
},
{
content: "Experience is the name everyone gives to their mistakes.",
author: "Oscar Wilde",
},
{
content:
"In order to be irreplaceable, one must always be different.",
author: "Coco Chanel",
},
{
content: "Java is to JavaScript what car is to Carpet.",
author: "Chris Heilmann",
},
{
content: "Knowledge is power.",
author: "Francis Bacon",
},
{
content: "Before software can be reusable it first has to be usable.",
author: "Ralph Johnson",
},
{
content: "Its not a bug its an undocumented feature.",
author: "Anonymous",
},
{
content: "Deleted code is debugged code.",
author: "Jeff Sickel",
},
{
content:
"Walking on water and developing software from a specification are easy if both are frozen.",
author: "Edward V. Berard",
},
{
content:
"If debugging is the process of removing software bugs, then programming must be the process of putting them in.",
author: "Edsger Dijkstra",
},
{
content:
"A user interface is like a joke. If you have to explain it, its not that good.",
author: "Martin Leblanc",
},
{
content: "The best error message is the one that never shows up.",
author: "Thomas Fuchs",
},
{
content:
"The most damaging phrase in the language is.. it's always been done this way",
author: "Grace Hopper",
},
{
content: "Stay hungry, stay foolish.",
author: "Steve Jobs",
},
];
setQuote(techQuotes[Math.floor(Math.random() * techQuotes.length)]);
}
}, [hasActivity, quote]);
if (!data) return null;
return (
<div className="fixed bottom-6 right-6 flex flex-col items-end gap-3 z-50 font-sans pointer-events-none">
<AnimatePresence mode="popLayout">
{/* --------------------------------------------------------------------------------
1. CODING CARD
Zeigt entweder "Live Coding" (Grün) oder "Tagesstatistik" (Grau/Blau)
-------------------------------------------------------------------------------- */}
{data.coding && (
<motion.div
key="coding"
initial={{ opacity: 0, x: 20, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 20, scale: 0.95 }}
layout
className={`pointer-events-auto backdrop-blur-xl border p-3 rounded-2xl flex items-center gap-3 w-72 shadow-2xl transition-colors
${data.coding.isActive
? "bg-black/80 border-green-500/20 shadow-green-900/10"
: "bg-black/60 border-white/10"}`}
>
{/* Icon Box */}
<div className={`shrink-0 p-2.5 rounded-xl border flex items-center justify-center
${data.coding.isActive
? "bg-green-500/10 border-green-500/20 text-green-400"
: "bg-white/5 border-white/10 text-gray-400"}`}
>
{data.coding.isActive ? <Zap size={18} fill="currentColor" /> : <Code2 size={18} />}
</div>
const activeCount = [
data.coding?.isActive,
data.gaming?.isPlaying,
data.music?.isPlaying,
].filter(Boolean).length;
<div className="flex flex-col min-w-0">
{data.coding.isActive ? (
// --- LIVE STATUS ---
<>
<div className="flex items-center gap-2 mb-0.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-[10px] font-bold text-green-400 uppercase tracking-widest">
Coding Now
// If minimized, show only a small indicator
if (isMinimized) {
return (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
onClick={() => setIsMinimized(false)}
className="fixed bottom-4 right-4 md:bottom-6 md:right-6 z-40 pointer-events-auto bg-black/80 backdrop-blur-xl border border-white/10 p-3 rounded-full shadow-2xl hover:scale-110 transition-transform"
>
<Activity size={20} className="text-white" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 bg-green-500 text-white text-[10px] font-bold rounded-full w-5 h-5 flex items-center justify-center">
{activeCount}
</span>
)}
</motion.button>
);
}
return (
<div className="fixed bottom-4 right-4 md:bottom-6 md:right-6 flex flex-col items-end gap-3 z-40 font-sans pointer-events-none w-[280px] sm:w-[320px] max-w-[calc(100vw-2rem)]">
{/* Main Container */}
<motion.div
layout
className="pointer-events-auto bg-black/90 backdrop-blur-2xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden w-full"
>
{/* Header - Always Visible - Changed from button to div to fix nesting error */}
<div
role="button"
tabIndex={0}
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="relative">
<Activity size={18} className="text-white" />
{hasActivity && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-green-500 rounded-full animate-pulse" />
)}
</div>
<div className="text-left">
<h3 className="text-sm font-bold text-white">Live Activity</h3>
<p className="text-[10px] text-white/50">
{activeCount > 0 ? `${activeCount} active now` : "No activity"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div
onClick={(e) => {
e.stopPropagation();
setIsMinimized(true);
}}
className="p-1 hover:bg-white/10 rounded-lg transition-colors cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
setIsMinimized(true);
}
}}
>
<X size={14} className="text-white/60" />
</div>
{isExpanded ? (
<ChevronUp size={18} className="text-white/60" />
) : (
<ChevronDown size={18} className="text-white/60" />
)}
</div>
</div>
{/* Expandable Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-y-auto max-h-[calc(100vh-200px)]"
>
<div className="border-t border-white/10 p-3 sm:p-4 space-y-3">
{/* CODING CARD */}
{data.coding && (
<motion.div
layout
className={`relative border rounded-xl p-3 transition-all ${
data.coding.isActive
? "bg-gradient-to-br from-green-500/10 to-emerald-500/5 border-green-500/30 shadow-lg shadow-green-500/10"
: "bg-white/5 border-white/10"
}`}
>
{/* "RIGHT NOW" Indicator */}
{data.coding.isActive && (
<div className="absolute -top-2 -right-2 bg-green-500 text-black text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider shadow-lg">
Right Now
</div>
)}
<div className="flex items-start gap-3">
<div
className={`shrink-0 p-2 rounded-lg border flex items-center justify-center ${
data.coding.isActive
? "bg-green-500/20 border-green-500/30 text-green-400"
: "bg-white/5 border-white/10 text-gray-400"
}`}
>
{data.coding.isActive ? (
<Zap size={16} fill="currentColor" />
) : (
<Code2 size={16} />
)}
</div>
<div className="flex-1 min-w-0">
{data.coding.isActive ? (
<>
<div className="flex items-center gap-2 mb-1">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-[10px] font-bold text-green-400 uppercase tracking-wider">
Coding Live
</span>
</div>
<p className="font-bold text-sm text-white truncate mb-0.5">
{data.coding.project || "Active Project"}
</p>
<p className="text-xs text-white/60 truncate">
{data.coding.file || "Writing code..."}
</p>
{data.coding.language && (
<div className="mt-2 inline-flex items-center gap-1 px-2 py-0.5 bg-green-500/10 border border-green-500/20 rounded-full">
<span className="text-[10px] font-semibold text-green-400">
{data.coding.language}
</span>
</div>
)}
</>
) : (
<>
<div className="flex items-center gap-1.5 mb-1">
<Clock size={10} className="text-gray-400" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">
Today's Coding
</span>
</div>
<p className="font-bold text-sm text-white mb-0.5">
{data.coding.stats?.time || "0m"}
</p>
<p className="text-xs text-white/60">
{data.coding.stats?.topLang || "No activity yet"}
</p>
</>
)}
</div>
</div>
</motion.div>
)}
{/* GAMING CARD */}
{data.gaming?.isPlaying && (
<motion.div
layout
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative bg-gradient-to-br from-indigo-500/10 to-purple-500/5 border border-indigo-500/30 rounded-xl p-3 overflow-hidden shadow-lg shadow-indigo-500/10"
>
{/* "RIGHT NOW" Indicator */}
<div className="absolute -top-2 -right-2 bg-indigo-500 text-white text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider shadow-lg">
Right Now
</div>
{/* Background Glow */}
<div className="absolute -right-8 -top-8 w-32 h-32 bg-indigo-500/20 blur-3xl rounded-full pointer-events-none" />
<div className="relative flex items-start gap-3">
<div className="shrink-0">
{data.gaming.image ? (
<Image
src={data.gaming.image}
alt="Game"
width={48}
height={48}
className="w-12 h-12 rounded-lg shadow-md object-cover ring-2 ring-indigo-500/30"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center">
<Gamepad2 className="text-indigo-400" size={20} />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-indigo-500"></span>
</span>
<span className="text-[10px] font-bold text-indigo-300 uppercase tracking-wider">
Gaming Now
</span>
</div>
<p className="font-bold text-sm text-white truncate mb-0.5">
{data.gaming.name}
</p>
<p className="text-xs text-indigo-200/60 truncate">
{data.gaming.details ||
data.gaming.state ||
"Playing..."}
</p>
</div>
</div>
</motion.div>
)}
{/* MUSIC CARD */}
{data.music?.isPlaying && (
<motion.div
layout
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
>
<a
href={data.music.url}
target="_blank"
rel="noreferrer"
className="relative block bg-gradient-to-br from-green-500/10 to-emerald-500/5 border border-green-500/30 rounded-xl p-3 hover:border-green-500/50 transition-all group shadow-lg shadow-green-500/10"
>
{/* "RIGHT NOW" Indicator */}
<div className="absolute -top-2 -right-2 bg-green-500 text-black text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider shadow-lg">
Right Now
</div>
<div className="relative flex items-start gap-3">
<div className="shrink-0 relative">
<Image
src={data.music.albumArt}
alt="Album"
width={48}
height={48}
className="w-12 h-12 rounded-lg shadow-md group-hover:scale-105 transition-transform ring-2 ring-green-500/30"
/>
<div className="absolute -bottom-1 -right-1 bg-black rounded-full p-1 border border-green-500/30 shadow-lg">
<Disc3
size={10}
className="text-green-400"
style={{
animation: "spin 3s linear infinite",
}}
/>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-green-400 uppercase tracking-wider">
Spotify
</span>
{/* Equalizer Animation */}
<div className="flex gap-[3px] h-3 items-end">
{[1, 2, 3].map((i) => (
<motion.div
key={i}
className="w-[3px] bg-green-500 rounded-full"
animate={{
height: ["30%", "100%", "50%"],
}}
transition={{
duration: 0.6,
repeat: Infinity,
repeatType: "reverse",
delay: i * 0.12,
}}
/>
))}
</div>
</div>
<p className="font-bold text-sm text-white truncate mb-0.5 group-hover:text-green-400 transition-colors">
{data.music.track}
</p>
<p className="text-xs text-white/60 truncate">
{data.music.artist}
</p>
</div>
</div>
</a>
</motion.div>
)}
{/* Quote of the Day (when idle) */}
{!hasActivity && quote && (
<div className="bg-white/5 rounded-lg p-3 border border-white/10 relative overflow-hidden group hover:bg-white/10 transition-colors">
<div className="absolute top-0 right-0 p-2 opacity-10 group-hover:opacity-20 transition-opacity">
<Code2 size={40} />
</div>
<p className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2">
Quote of the moment
</p>
<p className="text-sm text-white/90 italic font-serif leading-relaxed">
"{quote.content}"
</p>
<p className="text-xs text-white/50 mt-2 text-right">
{quote.author}
</p>
</div>
)}
{/* Status Footer */}
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
data.status.color === "green"
? "bg-green-500"
: data.status.color === "red"
? "bg-red-500"
: data.status.color === "yellow"
? "bg-yellow-500"
: "bg-gray-500"
}`}
/>
<span className="text-[11px] font-medium text-white/50 capitalize">
{data.status.text === "dnd"
? "Do Not Disturb"
: data.status.text}
</span>
</div>
<span className="font-bold text-sm text-white truncate">
{data.coding.project || "Unknown Project"}
<span className="text-[10px] text-white/30">
Updates every 30s
</span>
<span className="text-xs text-white/50 truncate">
{data.coding.file || "Writing code..."}
</span>
</>
) : (
// --- STATS STATUS ---
<>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-0.5 flex items-center gap-1">
<Clock size={10} /> Today's Stats
</span>
<span className="font-bold text-sm text-white">
{data.coding.stats?.time || "0m"}
</span>
<span className="text-xs text-white/50 truncate">
Focus: {data.coding.stats?.topLang}
</span>
</>
)}
</div>
</motion.div>
)}
{/* --------------------------------------------------------------------------------
2. GAMING CARD
Erscheint nur, wenn du spielst
-------------------------------------------------------------------------------- */}
{data.gaming?.isPlaying && (
<motion.div
key="gaming"
initial={{ opacity: 0, x: 20, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 20, scale: 0.95 }}
layout
className="pointer-events-auto bg-indigo-950/80 backdrop-blur-xl border border-indigo-500/20 p-3 rounded-2xl flex items-center gap-3 w-72 shadow-2xl relative overflow-hidden"
>
{/* Background Glow */}
<div className="absolute -right-4 -top-4 w-24 h-24 bg-indigo-500/20 blur-2xl rounded-full pointer-events-none" />
<div className="relative shrink-0">
{data.gaming.image ? (
<img
src={data.gaming.image}
alt="Game Art"
className="w-12 h-12 rounded-lg shadow-sm object-cover bg-indigo-900"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-indigo-500/20 flex items-center justify-center">
<Gamepad2 className="text-indigo-400" size={24} />
</div>
)}
</div>
<div className="flex flex-col min-w-0 z-10">
<span className="text-[10px] font-bold text-indigo-300 uppercase tracking-widest mb-0.5">
In Game
</span>
<span className="font-bold text-sm text-white truncate">
{data.gaming.name}
</span>
<span className="text-xs text-indigo-200/60 truncate">
{data.gaming.details || data.gaming.state || "Playing..."}
</span>
</div>
</motion.div>
)}
{/* --------------------------------------------------------------------------------
3. MUSIC CARD (Spotify)
Erscheint nur, wenn Musik läuft
-------------------------------------------------------------------------------- */}
{data.music?.isPlaying && (
<motion.div
key="music"
initial={{ opacity: 0, x: 20, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 20, scale: 0.95 }}
layout
className="pointer-events-auto group bg-black/80 backdrop-blur-md border border-white/10 p-3 rounded-2xl flex items-center gap-3 w-72 shadow-2xl hover:bg-black/90 transition-all"
>
<div className="relative shrink-0">
<img
src={data.music.albumArt}
alt="Album"
className="w-12 h-12 rounded-lg shadow-sm group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute -bottom-1 -right-1 bg-black rounded-full p-1 border border-white/10 shadow-sm z-10">
<Disc3 size={10} className="text-green-400 animate-spin-slow" />
</div>
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] font-bold text-green-400 uppercase tracking-widest flex items-center gap-1">
Spotify
</span>
{/* Equalizer Animation */}
<div className="flex gap-[2px] h-2 items-end">
{[1,2,3].map(i => (
<motion.div
key={i}
className="w-0.5 bg-green-500 rounded-full"
animate={{ height: ["20%", "100%", "40%"] }}
transition={{ duration: 0.5, repeat: Infinity, repeatType: "reverse", delay: i * 0.1 }}
/>
))}
</div>
</div>
<a
href={data.music.url}
target="_blank"
rel="noreferrer"
className="font-bold text-sm text-white truncate hover:underline decoration-white/30 underline-offset-2"
>
{data.music.track}
</a>
<span className="text-xs text-white/50 truncate">
{data.music.artist}
</span>
</div>
</motion.div>
)}
{/* --------------------------------------------------------------------------------
4. STATUS BADGE (Optional)
Kleiner Indikator ganz unten, falls nichts anderes da ist oder als Abschluss
-------------------------------------------------------------------------------- */}
<motion.div layout className="pointer-events-auto bg-black/40 backdrop-blur-sm border border-white/5 px-3 py-1.5 rounded-full flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
data.status.color === 'green' ? 'bg-green-500' :
data.status.color === 'red' ? 'bg-red-500' :
data.status.color === 'yellow' ? 'bg-yellow-500' : 'bg-gray-500'
}`} />
<span className="text-xs font-medium text-white/60 capitalize">
{data.status.text === 'dnd' ? 'Do not disturb' : data.status.text}
</span>
</motion.div>
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
}

View File

@@ -0,0 +1,384 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageCircle,
X,
Send,
Loader2,
Sparkles,
Trash2,
} from "lucide-react";
interface Message {
id: string;
text: string;
sender: "user" | "bot";
timestamp: Date;
isTyping?: boolean;
}
export default function ChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState(() => {
// Generate or retrieve conversation ID
if (typeof window !== "undefined") {
const stored = localStorage.getItem("chatSessionId");
if (stored) return stored;
const newId = crypto.randomUUID();
localStorage.setItem("chatSessionId", newId);
return newId;
}
return "default";
});
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Focus input when chat opens
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
// Load messages from localStorage
useEffect(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("chatMessages");
if (stored) {
try {
const parsed = JSON.parse(stored);
setMessages(
parsed.map((m: any) => ({
...m,
timestamp: new Date(m.timestamp),
})),
);
} catch (e) {
console.error("Failed to load chat history", e);
}
} else {
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
}
}, []);
// Save messages to localStorage
useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) {
localStorage.setItem("chatMessages", JSON.stringify(messages));
}
}, [messages]);
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
text: inputValue.trim(),
sender: "user",
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue("");
setIsLoading(true);
// Get last 10 messages for context
const history = messages.slice(-10).map((m) => ({
role: m.sender === "user" ? "user" : "assistant",
content: m.text,
}));
try {
const response = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: userMessage.text,
conversationId,
history,
}),
});
if (!response.ok) {
throw new Error("Failed to get response");
}
const data = await response.json();
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: data.reply || "Sorry, I couldn't process that. Please try again.",
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error("Chat error:", error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
text: "Sorry, I'm having trouble connecting right now. Please try again later or use the contact form below.",
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const clearChat = () => {
// Reset session ID
const newId = crypto.randomUUID();
setConversationId(newId);
if (typeof window !== "undefined") {
localStorage.setItem("chatSessionId", newId);
localStorage.removeItem("chatMessages");
}
setMessages([
{
id: "welcome",
text: "Conversation restarted! Ask me anything about Dennis! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
};
return (
<>
{/* Chat Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
role="button"
tabIndex={0}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onClick={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpen(true);
}
}}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-2xl hover:shadow-blue-500/50 hover:scale-110 transition-all duration-300 group cursor-pointer"
aria-label="Open chat"
>
<MessageCircle size={20} />
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1 bg-black/90 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Chat with AI assistant
</span>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed bottom-20 left-4 md:bottom-6 md:left-6 z-30 w-[300px] sm:w-[340px] md:w-[380px] max-w-[calc(100vw-2rem)] h-[450px] sm:h-[500px] md:h-[550px] max-h-[calc(100vh-10rem)] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col overflow-hidden border border-gray-200 dark:border-gray-800"
>
{/* Header */}
<div className="bg-gradient-to-br from-blue-500 to-purple-600 text-white p-3 md:p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Sparkles size={20} />
</div>
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white" />
</div>
<div>
<h3 className="font-bold text-sm">Dennis's AI Assistant</h3>
<p className="text-xs text-white/80">Always online</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={clearChat}
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-white/80 hover:text-white"
title="Clear conversation"
>
<Trash2 size={18} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label="Close chat"
>
<X size={20} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-3 md:space-y-4 bg-gray-50 dark:bg-gray-950">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
message.sender === "user"
? "bg-gradient-to-br from-blue-500 to-purple-600 text-white"
: "bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700"
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">
{message.text}
</p>
<p
className={`text-[10px] mt-1 ${
message.sender === "user"
? "text-white/60"
: "text-gray-500 dark:text-gray-400"
}`}
>
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</motion.div>
))}
{/* Typing Indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3">
<div className="flex gap-1">
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0,
}}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
/>
<motion.div
className="w-2 h-2 bg-gray-400 rounded-full"
animate={{ y: [0, -8, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
/>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 md:p-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 px-3 md:px-4 py-2 text-sm bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-full border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-full hover:shadow-lg hover:scale-110 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
<Send size={20} />
)}
</button>
</div>
{/* Quick Actions */}
<div className="flex gap-2 mt-2 overflow-x-auto pb-1 scrollbar-hide">
{[
"What are Dennis's skills?",
"Tell me about his projects",
"How can I contact him?",
].map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInputValue(suggestion);
inputRef.current?.focus();
}}
disabled={isLoading}
className="px-2 md:px-3 py-1 text-[10px] md:text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors whitespace-nowrap disabled:opacity-50 flex-shrink-0"
>
{suggestion}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}