full upgrade to dev
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { message } = await request.json();
|
||||
let userMessage = "";
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
try {
|
||||
const json = await request.json();
|
||||
userMessage = json.message;
|
||||
const history = json.history || [];
|
||||
|
||||
if (!userMessage || typeof userMessage !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required' },
|
||||
{ status: 400 }
|
||||
{ error: "Message is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,72 +19,144 @@ export async function POST(request: Request) {
|
||||
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
|
||||
|
||||
if (!n8nWebhookUrl) {
|
||||
console.error('N8N_WEBHOOK_URL not configured');
|
||||
// Return fallback response
|
||||
console.error("N8N_WEBHOOK_URL not configured");
|
||||
return NextResponse.json({
|
||||
reply: getFallbackResponse(message)
|
||||
reply: getFallbackResponse(userMessage),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Sending to n8n: ${n8nWebhookUrl}/webhook/chat`);
|
||||
|
||||
const response = await fetch(`${n8nWebhookUrl}/webhook/chat`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
...(process.env.N8N_API_KEY && {
|
||||
'Authorization': `Bearer ${process.env.N8N_API_KEY}`
|
||||
Authorization: `Bearer ${process.env.N8N_API_KEY}`,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
history: history,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`n8n webhook failed with status: ${response.status}`);
|
||||
throw new Error(`n8n webhook failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json({ reply: data.reply || data.message || data.response });
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
|
||||
// Fallback to mock responses if n8n is down
|
||||
const { message } = await request.json();
|
||||
return NextResponse.json(
|
||||
{ reply: getFallbackResponse(message) }
|
||||
);
|
||||
console.log("n8n response data:", data);
|
||||
|
||||
const reply =
|
||||
data.reply ||
|
||||
data.message ||
|
||||
data.response ||
|
||||
data.text ||
|
||||
data.content ||
|
||||
(Array.isArray(data) && data[0]?.reply);
|
||||
|
||||
if (!reply) {
|
||||
console.warn("n8n response missing reply field:", data);
|
||||
// If n8n returns successfully but without a clear reply field,
|
||||
// we might want to show the fallback or a generic error,
|
||||
// but strictly speaking we shouldn't show "Couldn't process".
|
||||
// Let's try to stringify the whole data if it's small, or use fallback.
|
||||
if (data && typeof data === "object" && Object.keys(data).length > 0) {
|
||||
// It returned something, but we don't know what field to use.
|
||||
// Check for common n8n structure
|
||||
if (data.output) return NextResponse.json({ reply: data.output });
|
||||
if (data.data) return NextResponse.json({ reply: data.data });
|
||||
}
|
||||
throw new Error("Invalid response format from n8n");
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Chat API error:", error);
|
||||
|
||||
// Fallback to mock responses
|
||||
// Now using the variable captured at the start
|
||||
return NextResponse.json({ reply: getFallbackResponse(userMessage) });
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackResponse(message: string): string {
|
||||
if (!message || typeof message !== "string") {
|
||||
return "I'm having a bit of trouble understanding. Could you try asking again?";
|
||||
}
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (lowerMessage.includes('skill') || lowerMessage.includes('tech')) {
|
||||
return "Dennis specializes in full-stack development with Next.js, Flutter for mobile, and DevOps with Docker Swarm. He's passionate about self-hosting and runs his own infrastructure!";
|
||||
if (
|
||||
lowerMessage.includes("skill") ||
|
||||
lowerMessage.includes("tech") ||
|
||||
lowerMessage.includes("stack")
|
||||
) {
|
||||
return "I specialize in full-stack development with Next.js, React, and Flutter for mobile. On the DevOps side, I love working with Docker Swarm, Traefik, and CI/CD pipelines. Basically, if it involves code or servers, I'm interested!";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('project')) {
|
||||
return "Dennis has built Clarity (a Flutter app for people with dyslexia) and runs a complete self-hosted infrastructure with Docker Swarm, Traefik, and automated CI/CD pipelines. Check out the Projects section for more!";
|
||||
if (
|
||||
lowerMessage.includes("project") ||
|
||||
lowerMessage.includes("built") ||
|
||||
lowerMessage.includes("work")
|
||||
) {
|
||||
return "One of my key projects is Clarity, a Flutter app designed to help people with dyslexia. I also maintain a comprehensive self-hosted infrastructure with Docker Swarm. You can check out more details in the Projects section!";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('contact') || lowerMessage.includes('email') || lowerMessage.includes('reach')) {
|
||||
return "You can reach Dennis via the contact form on this site or email him at contact@dk0.dev. He's always open to discussing new opportunities and interesting projects!";
|
||||
if (
|
||||
lowerMessage.includes("contact") ||
|
||||
lowerMessage.includes("email") ||
|
||||
lowerMessage.includes("reach") ||
|
||||
lowerMessage.includes("hire")
|
||||
) {
|
||||
return "The best way to reach me is through the contact form below or by emailing contact@dk0.dev. I'm always open to discussing new ideas, opportunities, or just chatting about tech!";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('location') || lowerMessage.includes('where')) {
|
||||
return "Dennis is based in Osnabrück, Germany. He's a student who's passionate about technology and self-hosting.";
|
||||
if (
|
||||
lowerMessage.includes("location") ||
|
||||
lowerMessage.includes("where") ||
|
||||
lowerMessage.includes("live")
|
||||
) {
|
||||
return "I'm based in Osnabrück, Germany. It's a great place to be a student and work on tech projects!";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('hobby') || lowerMessage.includes('free time')) {
|
||||
return "When Dennis isn't coding or managing servers, he enjoys gaming, jogging, and experimenting with new technologies. He also uses pen and paper for notes despite automating everything else!";
|
||||
if (
|
||||
lowerMessage.includes("hobby") ||
|
||||
lowerMessage.includes("free time") ||
|
||||
lowerMessage.includes("fun")
|
||||
) {
|
||||
return "When I'm not coding or tweaking my servers, I enjoy gaming, going for a jog, or experimenting with new tech. Fun fact: I still use pen and paper for my calendar, even though I automate everything else!";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('devops') || lowerMessage.includes('docker') || lowerMessage.includes('infrastructure')) {
|
||||
return "Dennis runs his own infrastructure on IONOS and OVHcloud using Docker Swarm, Traefik for reverse proxy, and custom CI/CD pipelines. He loves self-hosting and managing game servers!";
|
||||
if (
|
||||
lowerMessage.includes("devops") ||
|
||||
lowerMessage.includes("docker") ||
|
||||
lowerMessage.includes("server") ||
|
||||
lowerMessage.includes("hosting")
|
||||
) {
|
||||
return "I'm really into DevOps! I run my own infrastructure on IONOS and OVHcloud using Docker Swarm and Traefik. It allows me to host various services and game servers efficiently while learning a ton about system administration.";
|
||||
}
|
||||
|
||||
if (lowerMessage.includes('student') || lowerMessage.includes('study')) {
|
||||
return "Yes, Dennis is currently a student in Osnabrück while also working on various tech projects and managing his own infrastructure. He's always learning and exploring new technologies!";
|
||||
if (
|
||||
lowerMessage.includes("student") ||
|
||||
lowerMessage.includes("study") ||
|
||||
lowerMessage.includes("education")
|
||||
) {
|
||||
return "Yes, I'm currently a student in Osnabrück. I balance my studies with working on personal projects and managing my self-hosted infrastructure. It keeps me busy but I learn something new every day!";
|
||||
}
|
||||
|
||||
if (
|
||||
lowerMessage.includes("hello") ||
|
||||
lowerMessage.includes("hi ") ||
|
||||
lowerMessage.includes("hey")
|
||||
) {
|
||||
return "Hi there! I'm Dennis's AI assistant (currently in offline mode). How can I help you learn more about Dennis today?";
|
||||
}
|
||||
|
||||
// Default response
|
||||
return "That's a great question! Dennis is a full-stack developer and DevOps enthusiast who loves building things with Next.js, Flutter, and Docker. Feel free to ask me more specific questions about his skills, projects, or experience!";
|
||||
return "That's an interesting question! I'm currently operating in fallback mode, so my knowledge is a bit limited right now. But I can tell you that Dennis is a full-stack developer and DevOps enthusiast who loves building with Next.js and Docker. Feel free to ask about his skills, projects, or how to contact him!";
|
||||
}
|
||||
|
||||
@@ -68,21 +68,24 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Call n8n webhook to trigger AI image generation
|
||||
const n8nResponse = await fetch(`${n8nWebhookUrl}/ai-image-generation`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(n8nSecretToken && {
|
||||
Authorization: `Bearer ${n8nSecretToken}`,
|
||||
const n8nResponse = await fetch(
|
||||
`${n8nWebhookUrl}/webhook/ai-image-generation`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(n8nSecretToken && {
|
||||
Authorization: `Bearer ${n8nSecretToken}`,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: projectId,
|
||||
regenerate: regenerate,
|
||||
triggeredBy: "api",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: projectId,
|
||||
regenerate: regenerate,
|
||||
triggeredBy: "api",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
if (!n8nResponse.ok) {
|
||||
const errorText = await n8nResponse.text();
|
||||
|
||||
@@ -7,14 +7,17 @@ export const revalidate = 30;
|
||||
export async function GET() {
|
||||
try {
|
||||
// Rufe den n8n Webhook auf
|
||||
const res = await fetch(`${process.env.N8N_WEBHOOK_URL}/denshooter-71242/status`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Add timestamp to query to bypass Cloudflare cache
|
||||
const res = await fetch(
|
||||
`${process.env.N8N_WEBHOOK_URL}/webhook/denshooter-71242/status?t=${Date.now()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 30 },
|
||||
},
|
||||
// Cache-Optionen für Next.js
|
||||
next: { revalidate: 30 }
|
||||
});
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`n8n error: ${res.status}`);
|
||||
@@ -25,6 +28,19 @@ export async function GET() {
|
||||
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
|
||||
const statusData = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
|
||||
if (!statusData) {
|
||||
throw new Error("Empty data received from n8n");
|
||||
}
|
||||
|
||||
// Ensure coding object has proper structure
|
||||
if (statusData.coding && typeof statusData.coding === "object") {
|
||||
// Already properly formatted from n8n
|
||||
} else if (statusData.coding === null || statusData.coding === undefined) {
|
||||
// No coding data - keep as null
|
||||
statusData.coding = null;
|
||||
}
|
||||
|
||||
return NextResponse.json(statusData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching n8n status:", error);
|
||||
@@ -33,7 +49,7 @@ export async function GET() {
|
||||
status: { text: "offline", color: "gray" },
|
||||
music: null,
|
||||
gaming: null,
|
||||
coding: null
|
||||
coding: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, it’s 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: "It’s not a bug – it’s 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, it’s 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
384
app/components/ChatWidget.tsx
Normal file
384
app/components/ChatWidget.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
@@ -7,14 +9,37 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log error details to console
|
||||
console.error("Global Error:", error);
|
||||
console.error("Error Name:", error.name);
|
||||
console.error("Error Message:", error.message);
|
||||
console.error("Error Stack:", error.stack);
|
||||
console.error("Error Digest:", error.digest);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div className="flex flex-col items-center justify-center h-screen gap-4">
|
||||
<h2>Critical System Error</h2>
|
||||
<button onClick={() => reset()}>Restart App</button>
|
||||
<div className="flex flex-col items-center justify-center h-screen gap-4 p-4">
|
||||
<h2 className="text-2xl font-bold text-red-600">
|
||||
Critical System Error
|
||||
</h2>
|
||||
<div className="bg-red-50 border border-red-200 rounded p-4 max-w-2xl">
|
||||
<p className="font-semibold mb-2">Error Type: {error.name}</p>
|
||||
<p className="text-sm mb-2">Message: {error.message}</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-gray-600">Digest: {error.digest}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Restart App
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ToastProvider } from "@/components/Toast";
|
||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||
import { ClientOnly } from "./components/ClientOnly";
|
||||
import BackgroundBlobsClient from "./components/BackgroundBlobsClient";
|
||||
import ChatWidget from "./components/ChatWidget";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
||||
<BackgroundBlobsClient />
|
||||
</ClientOnly>
|
||||
<div className="relative z-10">{children}</div>
|
||||
<ChatWidget />
|
||||
</ToastProvider>
|
||||
</AnalyticsProvider>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user