568 lines
22 KiB
TypeScript
568 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import React, { useEffect, useState } from "react";
|
||
import Image from "next/image";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
Code2,
|
||
Disc3,
|
||
Gamepad2,
|
||
Zap,
|
||
Clock,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Activity,
|
||
X,
|
||
} from "lucide-react";
|
||
|
||
// Types matching your n8n output
|
||
interface StatusData {
|
||
status: {
|
||
text: string;
|
||
color: string;
|
||
};
|
||
music: {
|
||
isPlaying: boolean;
|
||
track: string;
|
||
artist: string;
|
||
album: string;
|
||
albumArt: string;
|
||
url: string;
|
||
} | null;
|
||
gaming: {
|
||
isPlaying: boolean;
|
||
name: string;
|
||
image: string | null;
|
||
state?: string;
|
||
details?: string;
|
||
} | null;
|
||
coding: {
|
||
isActive: boolean;
|
||
project?: string;
|
||
file?: string;
|
||
language?: string;
|
||
stats?: {
|
||
time: string;
|
||
topLang: string;
|
||
topProject: string;
|
||
};
|
||
} | null;
|
||
}
|
||
|
||
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);
|
||
|
||
// Fetch data every 30 seconds (optimized to match server cache)
|
||
useEffect(() => {
|
||
const fetchData = async () => {
|
||
try {
|
||
// Add timestamp to prevent aggressive caching but respect server cache
|
||
const res = await fetch("/api/n8n/status", {
|
||
cache: "default",
|
||
});
|
||
if (!res.ok) return;
|
||
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();
|
||
// 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;
|
||
|
||
const activeCount = [
|
||
data.coding?.isActive,
|
||
data.gaming?.isPlaying,
|
||
data.music?.isPlaying,
|
||
].filter(Boolean).length;
|
||
|
||
// 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="text-[10px] text-white/30">
|
||
Updates every 30s
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|