full upgrade to dev
This commit is contained in:
@@ -1,9 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Code, Terminal, Cpu, Globe } from 'lucide-react';
|
||||
import { LiquidHeading } from '@/components/LiquidHeading';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code } from "lucide-react";
|
||||
|
||||
// Smooth animation configuration
|
||||
const smoothTransition = {
|
||||
duration: 1,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: smoothTransition,
|
||||
},
|
||||
};
|
||||
|
||||
const About = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -14,81 +39,205 @@ const About = () => {
|
||||
|
||||
const techStack = [
|
||||
{
|
||||
category: 'Frontend',
|
||||
category: "Frontend & Mobile",
|
||||
icon: Globe,
|
||||
items: ['React', 'TypeScript', 'Tailwind', 'Next.js']
|
||||
items: ["Next.js", "Tailwind CSS", "Flutter"],
|
||||
},
|
||||
{
|
||||
category: 'Backend',
|
||||
icon: Terminal,
|
||||
items: ['Node.js', 'PostgreSQL', 'Prisma', 'API Design']
|
||||
category: "Backend & DevOps",
|
||||
icon: Server,
|
||||
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
|
||||
},
|
||||
{
|
||||
category: 'Tools',
|
||||
icon: Cpu,
|
||||
items: ['Git', 'Docker', 'VS Code', 'Figma']
|
||||
}
|
||||
category: "Tools & Automation",
|
||||
icon: Wrench,
|
||||
items: ["Git", "CI/CD", "n8n", "Self-hosted Services"],
|
||||
},
|
||||
{
|
||||
category: "Security & Admin",
|
||||
icon: Shield,
|
||||
items: ["CrowdSec", "Suricata", "Mailcow"],
|
||||
},
|
||||
];
|
||||
|
||||
const hobbies = [
|
||||
{ icon: Code, text: "Self-Hosting & DevOps" },
|
||||
{ icon: Gamepad2, text: "Gaming" },
|
||||
{ icon: Server, text: "Setting up Game Servers" },
|
||||
];
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<section id="about" className="py-24 px-4 bg-white relative overflow-hidden">
|
||||
<section
|
||||
id="about"
|
||||
className="py-24 px-4 bg-gradient-to-br from-liquid-sky/15 via-liquid-lavender/10 to-liquid-pink/15 relative overflow-hidden"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
||||
{/* Text Content */}
|
||||
<div className="space-y-8">
|
||||
<LiquidHeading
|
||||
text="About Me"
|
||||
level={2}
|
||||
className="text-4xl md:text-5xl font-bold text-stone-900"
|
||||
/>
|
||||
<div className="prose prose-stone prose-lg text-stone-600">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={staggerContainer}
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.h2
|
||||
variants={fadeInUp}
|
||||
className="text-4xl md:text-5xl font-bold text-stone-900"
|
||||
>
|
||||
About Me
|
||||
</motion.h2>
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
className="prose prose-stone prose-lg text-stone-700 space-y-4"
|
||||
>
|
||||
<p>
|
||||
Hi, I'm Dennis. I'm a software engineer who likes building things that work well and look good.
|
||||
Hi, I'm Dennis – a student and passionate self-hoster based
|
||||
in Osnabrück, Germany.
|
||||
</p>
|
||||
<p>
|
||||
I'm currently based in Osnabrück, Germany. My journey in tech is driven by curiosity—I love figuring out how things work and how to make them better.
|
||||
I love building full-stack web applications with{" "}
|
||||
<strong>Next.js</strong> and mobile apps with{" "}
|
||||
<strong>Flutter</strong>. But what really excites me is{" "}
|
||||
<strong>DevOps</strong>: I run my own infrastructure on{" "}
|
||||
<strong>IONOS</strong> and <strong>OVHcloud</strong>, managing
|
||||
everything with <strong>Docker Swarm</strong>,{" "}
|
||||
<strong>Traefik</strong>, and automated CI/CD pipelines with my
|
||||
own runners.
|
||||
</p>
|
||||
<p>
|
||||
When I'm not in front of a screen, you can find me listening to music, exploring new ideas, or just relaxing.
|
||||
When I'm not coding or tinkering with servers, you'll
|
||||
find me <strong>gaming</strong>, <strong>jogging</strong>, or
|
||||
experimenting with new tech like game servers or automation
|
||||
workflows with <strong>n8n</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm italic text-stone-500 bg-stone-50 p-4 rounded-lg border-l-4 border-liquid-mint">
|
||||
💡 Fun fact: Even though I automate a lot, I still use pen and
|
||||
paper for my calendar and notes – it helps me clear my head and
|
||||
stay focused.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Simplified Skills / Tech Stack */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<h3 className="text-xl font-bold text-stone-900 mb-2">My Toolbox</h3>
|
||||
{techStack.map((stack, idx) => (
|
||||
<motion.div
|
||||
key={stack.category}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="p-6 rounded-xl bg-stone-50 border border-stone-100 hover:border-stone-200 transition-colors"
|
||||
{/* Tech Stack & Hobbies */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={staggerContainer}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<motion.h3
|
||||
variants={fadeInUp}
|
||||
className="text-2xl font-bold text-stone-900 mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
|
||||
<stack.icon size={20} />
|
||||
</div>
|
||||
<h4 className="font-semibold text-stone-800">{stack.category}</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stack.items.map(item => (
|
||||
<span key={item} className="px-3 py-1 bg-white rounded-md border border-stone-200 text-sm text-stone-600">
|
||||
{item}
|
||||
My Tech Stack
|
||||
</motion.h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{techStack.map((stack, idx) => (
|
||||
<motion.div
|
||||
key={`${stack.category}-${idx}`}
|
||||
variants={fadeInUp}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className={`p-5 rounded-xl border-2 transition-all duration-500 ease-out ${
|
||||
idx === 0
|
||||
? "bg-gradient-to-br from-liquid-sky/10 to-liquid-mint/10 border-liquid-sky/30 hover:border-liquid-sky/50 hover:from-liquid-sky/15 hover:to-liquid-mint/15"
|
||||
: idx === 1
|
||||
? "bg-gradient-to-br from-liquid-peach/10 to-liquid-coral/10 border-liquid-peach/30 hover:border-liquid-peach/50 hover:from-liquid-peach/15 hover:to-liquid-coral/15"
|
||||
: idx === 2
|
||||
? "bg-gradient-to-br from-liquid-lavender/10 to-liquid-pink/10 border-liquid-lavender/30 hover:border-liquid-lavender/50 hover:from-liquid-lavender/15 hover:to-liquid-pink/15"
|
||||
: "bg-gradient-to-br from-liquid-teal/10 to-liquid-lime/10 border-liquid-teal/30 hover:border-liquid-teal/50 hover:from-liquid-teal/15 hover:to-liquid-lime/15"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
|
||||
<stack.icon size={18} />
|
||||
</div>
|
||||
<h4 className="font-semibold text-stone-800">
|
||||
{stack.category}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stack.items.map((item, itemIdx) => (
|
||||
<span
|
||||
key={`${stack.category}-${item}-${itemIdx}`}
|
||||
className={`px-3 py-1.5 rounded-lg border-2 text-sm text-stone-700 font-medium transition-all duration-400 ease-out ${
|
||||
itemIdx % 4 === 0
|
||||
? "bg-liquid-mint/10 border-liquid-mint/30 hover:bg-liquid-mint/20 hover:border-liquid-mint/50"
|
||||
: itemIdx % 4 === 1
|
||||
? "bg-liquid-lavender/10 border-liquid-lavender/30 hover:bg-liquid-lavender/20 hover:border-liquid-lavender/50"
|
||||
: itemIdx % 4 === 2
|
||||
? "bg-liquid-rose/10 border-liquid-rose/30 hover:bg-liquid-rose/20 hover:border-liquid-rose/50"
|
||||
: "bg-liquid-sky/10 border-liquid-sky/30 hover:bg-liquid-sky/20 hover:border-liquid-sky/50"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hobbies */}
|
||||
<div>
|
||||
<motion.h3
|
||||
variants={fadeInUp}
|
||||
className="text-xl font-bold text-stone-900 mb-4"
|
||||
>
|
||||
When I'm Not Coding
|
||||
</motion.h3>
|
||||
<div className="space-y-3">
|
||||
{hobbies.map((hobby, idx) => (
|
||||
<motion.div
|
||||
key={`hobby-${hobby.text}-${idx}`}
|
||||
variants={fadeInUp}
|
||||
whileHover={{
|
||||
x: 8,
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all duration-500 ease-out ${
|
||||
idx === 0
|
||||
? "bg-gradient-to-r from-liquid-mint/10 to-liquid-sky/10 border-liquid-mint/30 hover:border-liquid-mint/50 hover:from-liquid-mint/15 hover:to-liquid-sky/15"
|
||||
: idx === 1
|
||||
? "bg-gradient-to-r from-liquid-coral/10 to-liquid-peach/10 border-liquid-coral/30 hover:border-liquid-coral/50 hover:from-liquid-coral/15 hover:to-liquid-peach/15"
|
||||
: "bg-gradient-to-r from-liquid-lavender/10 to-liquid-pink/10 border-liquid-lavender/30 hover:border-liquid-lavender/50 hover:from-liquid-lavender/15 hover:to-liquid-pink/15"
|
||||
}`}
|
||||
>
|
||||
<hobby.icon size={20} className="text-stone-600" />
|
||||
<span className="text-stone-700 font-medium">
|
||||
{hobby.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
whileHover={{
|
||||
x: 8,
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className="p-4 rounded-xl bg-gradient-to-r from-liquid-lime/15 to-liquid-teal/15 border-2 border-liquid-lime/40 hover:border-liquid-lime/60 hover:from-liquid-lime/20 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
|
||||
>
|
||||
<p className="text-sm text-stone-600">
|
||||
🏃 Jogging to clear my mind and stay active
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
export default About;
|
||||
|
||||
@@ -1,48 +1,270 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Music, Code, Monitor, MessageSquare, Send, X } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Music,
|
||||
Code,
|
||||
Monitor,
|
||||
MessageSquare,
|
||||
Send,
|
||||
X,
|
||||
Loader2,
|
||||
Github,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Coffee,
|
||||
Headphones,
|
||||
Terminal,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
Activity,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ActivityData {
|
||||
activity: {
|
||||
type: 'coding' | 'listening' | 'watching';
|
||||
type:
|
||||
| "coding"
|
||||
| "listening"
|
||||
| "watching"
|
||||
| "gaming"
|
||||
| "reading"
|
||||
| "running";
|
||||
details: string;
|
||||
timestamp: string;
|
||||
project?: string;
|
||||
language?: string;
|
||||
repo?: string;
|
||||
link?: string;
|
||||
} | null;
|
||||
music: {
|
||||
isPlaying: boolean;
|
||||
track: string;
|
||||
artist: string;
|
||||
platform: 'spotify' | 'apple';
|
||||
album?: string;
|
||||
platform: "spotify" | "apple";
|
||||
progress?: number;
|
||||
albumArt?: string;
|
||||
spotifyUrl?: string;
|
||||
} | null;
|
||||
watching: {
|
||||
title: string;
|
||||
platform: 'youtube' | 'netflix';
|
||||
platform: "youtube" | "netflix" | "twitch";
|
||||
type: "video" | "stream" | "movie" | "series";
|
||||
} | null;
|
||||
gaming: {
|
||||
game: string;
|
||||
platform: "steam" | "playstation" | "xbox";
|
||||
status: "playing" | "idle";
|
||||
} | null;
|
||||
status: {
|
||||
mood: string;
|
||||
customMessage?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Matrix rain effect for coding
|
||||
const MatrixRain = () => {
|
||||
const chars = "01";
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden opacity-20 pointer-events-none">
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute text-liquid-mint font-mono text-xs"
|
||||
style={{ left: `${(i / 15) * 100}%` }}
|
||||
animate={{
|
||||
y: ["-100%", "200%"],
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 3 + 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 2,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{[...Array(20)].map((_, j) => (
|
||||
<div key={j}>{chars[Math.floor(Math.random() * chars.length)]}</div>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Sound waves for music
|
||||
const SoundWaves = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden pointer-events-none">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-1 bg-gradient-to-t from-liquid-rose to-liquid-coral rounded-full"
|
||||
style={{ left: `${20 + i * 15}%` }}
|
||||
animate={{
|
||||
height: ["20%", "80%", "20%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.1,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Running animation
|
||||
const RunningAnimation = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
className="absolute bottom-2 text-4xl"
|
||||
animate={{
|
||||
x: ["-10%", "110%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
🏃
|
||||
</motion.div>
|
||||
<div className="absolute bottom-2 left-0 right-0 h-0.5 bg-liquid-lime/30" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Gaming particles
|
||||
const GamingParticles = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 bg-liquid-peach/60 rounded-full"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
}}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TV scan lines
|
||||
const TVScanLines = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-b from-transparent via-white/10 to-transparent h-8"
|
||||
animate={{
|
||||
y: ["-100%", "200%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const activityIcons = {
|
||||
coding: Terminal,
|
||||
listening: Headphones,
|
||||
watching: Tv,
|
||||
gaming: Gamepad2,
|
||||
reading: Coffee,
|
||||
running: Activity,
|
||||
};
|
||||
|
||||
const activityColors = {
|
||||
coding: {
|
||||
bg: "from-liquid-mint/20 to-liquid-sky/20",
|
||||
border: "border-liquid-mint/40",
|
||||
text: "text-liquid-mint",
|
||||
pulse: "bg-green-500",
|
||||
},
|
||||
listening: {
|
||||
bg: "from-liquid-rose/20 to-liquid-coral/20",
|
||||
border: "border-liquid-rose/40",
|
||||
text: "text-liquid-rose",
|
||||
pulse: "bg-red-500",
|
||||
},
|
||||
watching: {
|
||||
bg: "from-liquid-lavender/20 to-liquid-pink/20",
|
||||
border: "border-liquid-lavender/40",
|
||||
text: "text-liquid-lavender",
|
||||
pulse: "bg-purple-500",
|
||||
},
|
||||
gaming: {
|
||||
bg: "from-liquid-peach/20 to-liquid-yellow/20",
|
||||
border: "border-liquid-peach/40",
|
||||
text: "text-liquid-peach",
|
||||
pulse: "bg-orange-500",
|
||||
},
|
||||
reading: {
|
||||
bg: "from-liquid-teal/20 to-liquid-lime/20",
|
||||
border: "border-liquid-teal/40",
|
||||
text: "text-liquid-teal",
|
||||
pulse: "bg-teal-500",
|
||||
},
|
||||
running: {
|
||||
bg: "from-liquid-lime/20 to-liquid-mint/20",
|
||||
border: "border-liquid-lime/40",
|
||||
text: "text-liquid-lime",
|
||||
pulse: "bg-lime-500",
|
||||
},
|
||||
};
|
||||
|
||||
export const ActivityFeed = () => {
|
||||
const [data, setData] = useState<ActivityData | null>(null);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [chatMessage, setChatMessage] = useState('');
|
||||
const [chatHistory, setChatHistory] = useState<{
|
||||
role: 'user' | 'ai';
|
||||
text: string;
|
||||
}[]>([
|
||||
{ role: 'ai', text: 'Hi! I am Dennis\'s AI assistant. Ask me anything about him!' }
|
||||
const [chatMessage, setChatMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chatHistory, setChatHistory] = useState<
|
||||
{
|
||||
role: "user" | "ai";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}[]
|
||||
>([
|
||||
{
|
||||
role: "ai",
|
||||
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his work, skills, or projects! 🚀",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/n8n/status');
|
||||
const res = await fetch("/api/n8n/status");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch activity', e);
|
||||
console.error("Failed to fetch activity", e);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
@@ -50,67 +272,306 @@ export const ActivityFeed = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = (e: React.FormEvent) => {
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!chatMessage.trim()) return;
|
||||
if (!chatMessage.trim() || isLoading) return;
|
||||
|
||||
const userMsg = chatMessage;
|
||||
setChatHistory(prev => [...prev, { role: 'user', text: userMsg }]);
|
||||
setChatMessage('');
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "user", text: userMsg, timestamp: Date.now() },
|
||||
]);
|
||||
setChatMessage("");
|
||||
setIsLoading(true);
|
||||
|
||||
// Mock AI response - would connect to n8n webhook
|
||||
setTimeout(() => {
|
||||
setChatHistory(prev => [...prev, { role: 'ai', text: `That's a great question about "${userMsg}"! I'll ask Dennis to add more info about that.` }]);
|
||||
}, 1000);
|
||||
try {
|
||||
const response = await fetch("/api/n8n/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: userMsg }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "ai", text: data.reply, timestamp: Date.now() },
|
||||
]);
|
||||
} else {
|
||||
throw new Error("Chat API failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "ai",
|
||||
text: "Sorry, I encountered an error. Please try again later.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
const renderActivityBubble = () => {
|
||||
if (!data?.activity) return null;
|
||||
|
||||
const { type, details, project, language, link } = data.activity;
|
||||
const Icon = activityIcons[type];
|
||||
const colors = activityColors[type];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className={`relative bg-gradient-to-r ${colors.bg} backdrop-blur-md border-2 ${colors.border} shadow-lg rounded-2xl px-5 py-3 flex items-start gap-3 text-sm text-stone-800 max-w-xs overflow-hidden`}
|
||||
>
|
||||
{/* Background Animation based on activity type */}
|
||||
{type === "coding" && <MatrixRain />}
|
||||
{type === "running" && <RunningAnimation />}
|
||||
{type === "gaming" && <GamingParticles />}
|
||||
{type === "watching" && <TVScanLines />}
|
||||
|
||||
<div className="relative z-10 flex-shrink-0 mt-1">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span
|
||||
className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.pulse} opacity-75`}
|
||||
></span>
|
||||
<span
|
||||
className={`relative inline-flex rounded-full h-3 w-3 ${colors.pulse}`}
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<motion.div
|
||||
animate={
|
||||
type === "coding"
|
||||
? { rotate: [0, 360] }
|
||||
: type === "running"
|
||||
? { scale: [1, 1.2, 1] }
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
duration: type === "coding" ? 2 : 1,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
<Icon size={16} className={colors.text} />
|
||||
</motion.div>
|
||||
<span className="font-semibold capitalize">{type}</span>
|
||||
</div>
|
||||
<p className="text-stone-900 font-medium truncate">{details}</p>
|
||||
{project && (
|
||||
<p className="text-stone-600 text-xs mt-1 flex items-center gap-1">
|
||||
<Github size={12} />
|
||||
{project}
|
||||
</p>
|
||||
)}
|
||||
{language && (
|
||||
<span className="inline-block mt-2 px-2 py-0.5 bg-white/60 rounded text-xs text-stone-700 border border-stone-200 font-mono">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-xs text-stone-700 hover:text-stone-900 underline"
|
||||
>
|
||||
View <ExternalLink size={10} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMusicBubble = () => {
|
||||
if (!data?.music?.isPlaying) return null;
|
||||
|
||||
const { track, artist, album, progress, albumArt, spotifyUrl } = data.music;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.8, delay: 0.15, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="relative bg-gradient-to-r from-liquid-rose/20 to-liquid-coral/20 backdrop-blur-md border-2 border-liquid-rose/40 shadow-lg rounded-2xl px-5 py-3 flex items-center gap-3 text-sm text-stone-800 max-w-xs overflow-hidden"
|
||||
>
|
||||
{/* Animated sound waves background */}
|
||||
<SoundWaves />
|
||||
|
||||
{albumArt && (
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 360] }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||
className="relative z-10 w-14 h-14 rounded-full overflow-hidden flex-shrink-0 border-2 border-white shadow-md"
|
||||
>
|
||||
<img
|
||||
src={albumArt}
|
||||
alt={album || track}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="relative z-10 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<Headphones size={16} className="text-liquid-rose" />
|
||||
</motion.div>
|
||||
<span className="font-semibold">Now Playing</span>
|
||||
</div>
|
||||
<p className="text-stone-900 font-bold text-sm truncate">{track}</p>
|
||||
<p className="text-stone-600 text-xs truncate">{artist}</p>
|
||||
{progress !== undefined && (
|
||||
<div className="mt-2 w-full bg-white/50 rounded-full h-1.5 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-liquid-rose to-liquid-coral"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{spotifyUrl && (
|
||||
<a
|
||||
href={spotifyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-xs text-stone-700 hover:text-stone-900 underline"
|
||||
>
|
||||
<Waves size={10} />
|
||||
Listen with me
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusBubble = () => {
|
||||
if (!data?.status) return null;
|
||||
|
||||
const { mood, customMessage } = data.status;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
animate={{ x: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ x: 50, opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="relative bg-gradient-to-r from-liquid-lavender/20 to-liquid-pink/20 backdrop-blur-md border-2 border-liquid-lavender/40 shadow-lg rounded-2xl px-5 py-3 flex items-center gap-3 text-sm text-stone-800 max-w-xs overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="text-3xl flex-shrink-0"
|
||||
>
|
||||
{mood}
|
||||
</motion.div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{customMessage && (
|
||||
<p className="text-stone-900 font-medium text-sm">
|
||||
{customMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-4 pointer-events-none">
|
||||
|
||||
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col items-end gap-4 pointer-events-none">
|
||||
{/* Chat Window */}
|
||||
<AnimatePresence>
|
||||
{showChat && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="pointer-events-auto bg-white/80 backdrop-blur-xl border border-white/60 shadow-2xl rounded-2xl w-80 overflow-hidden"
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="pointer-events-auto bg-white/95 backdrop-blur-xl border-2 border-stone-200 shadow-2xl rounded-2xl w-96 max-w-[calc(100vw-3rem)] overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-white/50 flex justify-between items-center bg-white/40">
|
||||
<span className="font-semibold text-stone-800 flex items-center gap-2">
|
||||
<MessageSquare size={16} />
|
||||
Ask me anything
|
||||
<div className="p-4 border-b-2 border-stone-200 flex justify-between items-center bg-gradient-to-r from-liquid-mint/10 to-liquid-sky/10">
|
||||
<span className="font-bold text-stone-900 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-liquid-mint" />
|
||||
AI Assistant
|
||||
</span>
|
||||
<button onClick={() => setShowChat(false)} className="text-stone-500 hover:text-stone-800">
|
||||
<X size={16} />
|
||||
<button
|
||||
onClick={() => setShowChat(false)}
|
||||
className="text-stone-500 hover:text-stone-900 transition-colors duration-300 p-1 hover:bg-stone-100 rounded-lg"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-64 overflow-y-auto p-4 space-y-3 bg-white/20">
|
||||
<div className="h-96 overflow-y-auto p-4 space-y-3 bg-gradient-to-b from-stone-50/50 to-white/50">
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] p-3 rounded-xl text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-stone-800 text-white rounded-tr-none'
|
||||
: 'bg-white text-stone-800 shadow-sm rounded-tl-none'
|
||||
}`}>
|
||||
<motion.div
|
||||
key={`chat-${msg.timestamp}-${i}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] p-3 rounded-2xl text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-gradient-to-br from-stone-800 to-stone-900 text-white rounded-tr-none shadow-md"
|
||||
: "bg-gradient-to-br from-white to-stone-50 text-stone-900 shadow-md rounded-tl-none border-2 border-stone-100"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[85%] p-3 rounded-2xl text-sm bg-gradient-to-br from-white to-stone-50 text-stone-900 shadow-md rounded-tl-none border-2 border-stone-100 flex items-center gap-2">
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin text-liquid-mint"
|
||||
/>
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSendMessage} className="p-3 border-t border-white/50 bg-white/40 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="p-4 border-t-2 border-stone-200 bg-gradient-to-r from-liquid-mint/5 to-liquid-sky/5 flex gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={chatMessage}
|
||||
onChange={(e) => setChatMessage(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-white/60 border border-white/60 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-stone-400"
|
||||
placeholder="Ask me anything..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-white border-2 border-stone-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-mint focus:border-transparent disabled:opacity-50 transition-all duration-300"
|
||||
/>
|
||||
<button type="submit" className="p-2 bg-stone-800 text-white rounded-lg hover:bg-black transition-colors">
|
||||
<Send size={16} />
|
||||
</button>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading || !chatMessage.trim()}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-3 bg-gradient-to-br from-stone-900 to-stone-800 text-white rounded-xl hover:from-black hover:to-stone-900 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
<Send size={18} />
|
||||
</motion.button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -118,41 +579,40 @@ export const ActivityFeed = () => {
|
||||
|
||||
{/* Activity Bubbles */}
|
||||
<div className="flex flex-col items-end gap-2 pointer-events-auto">
|
||||
{data.activity?.type === 'coding' && (
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
className="bg-white/80 backdrop-blur-md border border-white/60 shadow-lg rounded-full px-4 py-2 flex items-center gap-2 text-sm text-stone-700"
|
||||
>
|
||||
<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>
|
||||
<Code size={14} />
|
||||
<span>Working on <strong>{data.activity.details}</strong></span>
|
||||
</motion.div>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
{renderActivityBubble()}
|
||||
{renderMusicBubble()}
|
||||
{renderStatusBubble()}
|
||||
</AnimatePresence>
|
||||
|
||||
{data.music?.isPlaying && (
|
||||
<motion.div
|
||||
initial={{ x: 50, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white/80 backdrop-blur-md border border-white/60 shadow-lg rounded-full px-4 py-2 flex items-center gap-2 text-sm text-stone-700"
|
||||
>
|
||||
<Music size={14} className="animate-pulse text-liquid-rose" />
|
||||
<span>Listening to <strong>{data.music.track}</strong></span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Chat Toggle Button */}
|
||||
{/* Chat Toggle Button with Notification Indicator */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileHover={{ scale: 1.08, rotate: 5 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className="bg-stone-900 text-white rounded-full p-4 shadow-xl hover:bg-black transition-all"
|
||||
className="relative bg-stone-900 text-white rounded-full p-4 shadow-xl hover:bg-stone-950 transition-all duration-500 ease-out"
|
||||
title="Ask me anything about Dennis"
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
{!showChat && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut", delay: 0.2 }}
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-liquid-mint rounded-full border-2 border-white"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ scale: [1, 1.3, 1] }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute inset-0 bg-liquid-mint rounded-full"
|
||||
/>
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Mail, MapPin, Send } from 'lucide-react';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { LiquidHeading } from '@/components/LiquidHeading';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Mail, MapPin, Send } from "lucide-react";
|
||||
import { useToast } from "@/components/Toast";
|
||||
|
||||
const Contact = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -15,10 +14,10 @@ const Contact = () => {
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -29,27 +28,27 @@ const Contact = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
newErrors.name = "Name is required";
|
||||
} else if (formData.name.trim().length < 2) {
|
||||
newErrors.name = 'Name must be at least 2 characters';
|
||||
newErrors.name = "Name must be at least 2 characters";
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required';
|
||||
newErrors.email = "Email is required";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Please enter a valid email address';
|
||||
newErrors.email = "Please enter a valid email address";
|
||||
}
|
||||
|
||||
if (!formData.subject.trim()) {
|
||||
newErrors.subject = 'Subject is required';
|
||||
newErrors.subject = "Subject is required";
|
||||
} else if (formData.subject.trim().length < 3) {
|
||||
newErrors.subject = 'Subject must be at least 3 characters';
|
||||
newErrors.subject = "Subject must be at least 3 characters";
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = 'Message is required';
|
||||
newErrors.message = "Message is required";
|
||||
} else if (formData.message.trim().length < 10) {
|
||||
newErrors.message = 'Message must be at least 10 characters';
|
||||
newErrors.message = "Message must be at least 10 characters";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -58,18 +57,18 @@ const Contact = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/email', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
@@ -81,41 +80,49 @@ const Contact = () => {
|
||||
|
||||
if (response.ok) {
|
||||
showEmailSent(formData.email);
|
||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||
setFormData({ name: "", email: "", subject: "", message: "" });
|
||||
setTouched({});
|
||||
setErrors({});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
showEmailError(errorData.error || 'Failed to send message. Please try again.');
|
||||
showEmailError(
|
||||
errorData.error || "Failed to send message. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
showEmailError('Network error. Please check your connection and try again.');
|
||||
console.error("Error sending email:", error);
|
||||
showEmailError(
|
||||
"Network error. Please check your connection and try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value
|
||||
[name]: value,
|
||||
});
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name]) {
|
||||
setErrors({
|
||||
...errors,
|
||||
[name]: ''
|
||||
[name]: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const handleBlur = (
|
||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
setTouched({
|
||||
...touched,
|
||||
[e.target.name]: true
|
||||
[e.target.name]: true,
|
||||
});
|
||||
validateForm();
|
||||
};
|
||||
@@ -123,53 +130,61 @@ const Contact = () => {
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
title: 'Email',
|
||||
value: 'contact@dk0.dev',
|
||||
href: 'mailto:contact@dk0.dev'
|
||||
title: "Email",
|
||||
value: "contact@dk0.dev",
|
||||
href: "mailto:contact@dk0.dev",
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Location',
|
||||
value: 'Osnabrück, Germany',
|
||||
}
|
||||
title: "Location",
|
||||
value: "Osnabrück, Germany",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="contact" className="py-24 px-4 relative bg-cream">
|
||||
<section
|
||||
id="contact"
|
||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<LiquidHeading
|
||||
text="Contact Me"
|
||||
level={2}
|
||||
className="text-4xl md:text-5xl font-bold mb-6 text-stone-800"
|
||||
/>
|
||||
<p className="text-xl text-stone-500 max-w-2xl mx-auto mt-4">
|
||||
Interested in working together or have questions about my projects? Feel free to reach out!
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
||||
Contact Me
|
||||
</h2>
|
||||
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
||||
Interested in working together or have questions about my projects?
|
||||
Feel free to reach out!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-stone-800 mb-6">
|
||||
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
||||
Get In Touch
|
||||
</h3>
|
||||
<p className="text-stone-600 leading-relaxed">
|
||||
I'm always available to discuss new opportunities, interesting projects,
|
||||
or simply chat about technology and innovation.
|
||||
<p className="text-stone-700 leading-relaxed">
|
||||
I'm always available to discuss new opportunities,
|
||||
interesting projects, or simply chat about technology and
|
||||
innovation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -182,37 +197,50 @@ const Contact = () => {
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
whileHover={{ x: 5 }}
|
||||
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/60 transition-colors group border-transparent hover:border-white/60"
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: index * 0.15,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
}}
|
||||
whileHover={{
|
||||
x: 8,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-all duration-500 ease-out group border-transparent hover:border-white/70"
|
||||
>
|
||||
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
|
||||
<info.icon className="w-6 h-6 text-stone-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-stone-800">{info.title}</h4>
|
||||
<h4 className="font-semibold text-stone-800">
|
||||
{info.title}
|
||||
</h4>
|
||||
<p className="text-stone-500">{info.value}</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="glass-card p-8 rounded-3xl bg-white/40 border border-white/60"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-stone-800 mb-6">Send Message</h3>
|
||||
|
||||
<h3 className="text-2xl font-bold text-stone-800 mb-6">
|
||||
Send Message
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-stone-600 mb-2">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Name <span className="text-liquid-rose">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -225,20 +253,29 @@ const Contact = () => {
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.name && touched.name
|
||||
? 'border-red-400 focus:ring-red-400'
|
||||
: 'border-white/60 focus:ring-liquid-blue focus:border-transparent'
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
placeholder="Your name"
|
||||
aria-invalid={errors.name && touched.name ? 'true' : 'false'}
|
||||
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
|
||||
aria-invalid={
|
||||
errors.name && touched.name ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.name && touched.name ? "name-error" : undefined
|
||||
}
|
||||
/>
|
||||
{errors.name && touched.name && (
|
||||
<p id="name-error" className="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
<p id="name-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-stone-600 mb-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Email <span className="text-liquid-rose">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -251,21 +288,30 @@ const Contact = () => {
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.email && touched.email
|
||||
? 'border-red-400 focus:ring-red-400'
|
||||
: 'border-white/60 focus:ring-liquid-blue focus:border-transparent'
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
placeholder="your@email.com"
|
||||
aria-invalid={errors.email && touched.email ? 'true' : 'false'}
|
||||
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
|
||||
aria-invalid={
|
||||
errors.email && touched.email ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.email && touched.email ? "email-error" : undefined
|
||||
}
|
||||
/>
|
||||
{errors.email && touched.email && (
|
||||
<p id="email-error" className="mt-1 text-sm text-red-500">{errors.email}</p>
|
||||
<p id="email-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-stone-600 mb-2">
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Subject <span className="text-liquid-rose">*</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -278,20 +324,31 @@ const Contact = () => {
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.subject && touched.subject
|
||||
? 'border-red-400 focus:ring-red-400'
|
||||
: 'border-white/60 focus:ring-liquid-blue focus:border-transparent'
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
placeholder="What's this about?"
|
||||
aria-invalid={errors.subject && touched.subject ? 'true' : 'false'}
|
||||
aria-describedby={errors.subject && touched.subject ? 'subject-error' : undefined}
|
||||
aria-invalid={
|
||||
errors.subject && touched.subject ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.subject && touched.subject
|
||||
? "subject-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{errors.subject && touched.subject && (
|
||||
<p id="subject-error" className="mt-1 text-sm text-red-500">{errors.subject}</p>
|
||||
<p id="subject-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-stone-600 mb-2">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Message <span className="text-liquid-rose">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
@@ -304,16 +361,24 @@ const Contact = () => {
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
|
||||
errors.message && touched.message
|
||||
? 'border-red-400 focus:ring-red-400'
|
||||
: 'border-white/60 focus:ring-liquid-blue focus:border-transparent'
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
placeholder="Tell me more about your project or question..."
|
||||
aria-invalid={errors.message && touched.message ? 'true' : 'false'}
|
||||
aria-describedby={errors.message && touched.message ? 'message-error' : undefined}
|
||||
aria-invalid={
|
||||
errors.message && touched.message ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.message && touched.message
|
||||
? "message-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
{errors.message && touched.message ? (
|
||||
<p id="message-error" className="text-sm text-red-500">{errors.message}</p>
|
||||
<p id="message-error" className="text-sm text-red-500">
|
||||
{errors.message}
|
||||
</p>
|
||||
) : (
|
||||
<span></span>
|
||||
)}
|
||||
@@ -328,7 +393,8 @@ const Contact = () => {
|
||||
disabled={isSubmitting}
|
||||
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
||||
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
||||
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-black transition-all shadow-lg"
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Menu, X, Mail } from 'lucide-react';
|
||||
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Mail } from "lucide-react";
|
||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
||||
import Link from "next/link";
|
||||
|
||||
const Header = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -20,21 +20,25 @@ const Header = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'About', href: '#about' },
|
||||
{ name: 'Projects', href: '#projects' },
|
||||
{ name: 'Contact', href: '#contact' },
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "About", href: "#about" },
|
||||
{ name: "Projects", href: "#projects" },
|
||||
{ name: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' },
|
||||
{ icon: Mail, href: 'mailto:contact@dk0.dev', label: 'Email' },
|
||||
{ icon: SiGithub, href: "https://github.com/Denshooter", label: "GitHub" },
|
||||
{
|
||||
icon: SiLinkedin,
|
||||
href: "https://linkedin.com/in/dkonkol",
|
||||
label: "LinkedIn",
|
||||
},
|
||||
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
@@ -49,22 +53,30 @@ const Header = () => {
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<div className={`pointer-events-auto transition-all duration-500 ease-out ${
|
||||
scrolled ? 'w-full max-w-5xl' : 'w-full max-w-7xl'
|
||||
}`}>
|
||||
<div className={`
|
||||
<div
|
||||
className={`pointer-events-auto transition-all duration-500 ease-out ${
|
||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
backdrop-blur-xl transition-all duration-500
|
||||
${scrolled
|
||||
? 'bg-white/95 border border-stone-300 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3'
|
||||
: 'bg-white/85 border border-stone-200 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full'
|
||||
${
|
||||
scrolled
|
||||
? "bg-white/95 border border-stone-300 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
|
||||
: "bg-white/85 border border-stone-200 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
|
||||
}
|
||||
flex justify-between items-center
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Link href="/" className="text-2xl font-bold font-mono text-stone-800 tracking-tighter liquid-hover">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-2xl font-bold font-mono text-stone-800 tracking-tighter liquid-hover"
|
||||
>
|
||||
dk<span className="text-liquid-rose">0</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -80,11 +92,14 @@ const Header = () => {
|
||||
href={item.href}
|
||||
className="text-stone-600 hover:text-stone-900 transition-colors duration-200 font-medium relative group px-2 py-1 liquid-hover"
|
||||
onClick={(e) => {
|
||||
if (item.href.startsWith('#')) {
|
||||
if (item.href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -153,12 +168,15 @@ const Header = () => {
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
setIsOpen(false);
|
||||
if (item.href.startsWith('#')) {
|
||||
if (item.href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@@ -169,7 +187,7 @@ const Header = () => {
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
|
||||
<div className="pt-6 mt-4 border-t border-stone-200">
|
||||
<div className="flex justify-center space-x-4">
|
||||
{socialLinks.map((social, index) => (
|
||||
@@ -180,7 +198,9 @@ const Header = () => {
|
||||
rel="noopener noreferrer"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: (navItems.length + index) * 0.05 }}
|
||||
transition={{
|
||||
delay: (navItems.length + index) * 0.05,
|
||||
}}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
className="p-3 rounded-full bg-white/60 text-stone-600 shadow-sm"
|
||||
aria-label={social.label}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowDown, Code, Zap, Rocket } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { LiquidHeading } from '@/components/LiquidHeading';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
const Hero = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -14,97 +13,136 @@ const Hero = () => {
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{ icon: Code, text: 'Full-Stack Development' },
|
||||
{ icon: Zap, text: 'Modern Technologies' },
|
||||
{ icon: Rocket, text: 'Innovative Solutions' },
|
||||
{ icon: Code, text: "Next.js & Flutter" },
|
||||
{ icon: Zap, text: "Docker Swarm & CI/CD" },
|
||||
{ icon: Rocket, text: "Self-Hosted Infrastructure" },
|
||||
];
|
||||
|
||||
// Smooth scroll configuration
|
||||
const smoothTransition = {
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 50,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-20 pb-8 bg-transparent">
|
||||
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10">
|
||||
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
|
||||
{/* Domain Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="mb-8 inline-block"
|
||||
>
|
||||
<div className="px-6 py-2 rounded-full glass-panel text-stone-600 font-mono text-sm tracking-wider uppercase">
|
||||
dk<span className="text-liquid-rose font-bold">0</span>.dev
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Profile Image with Organic Blob Mask */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, delay: 0.7, ease: "easeOut" }}
|
||||
className="mb-10 flex justify-center relative z-20"
|
||||
transition={{ duration: 1.2, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="mb-12 flex justify-center relative z-20"
|
||||
>
|
||||
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
|
||||
{/* Large Rotating Liquid Blobs behind image */}
|
||||
<motion.div
|
||||
className="absolute w-[140%] h-[140%] bg-gradient-to-tr from-liquid-mint via-liquid-blue to-liquid-lavender opacity-60 blur-3xl -z-10"
|
||||
animate={{
|
||||
borderRadius: ["60% 40% 30% 70%/60% 30% 70% 40%", "30% 60% 70% 40%/50% 60% 30% 60%", "60% 40% 30% 70%/60% 30% 70% 40%"],
|
||||
rotate: [0, 180, 360],
|
||||
scale: [1, 1.1, 1]
|
||||
}}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute w-[120%] h-[120%] bg-gradient-to-bl from-liquid-rose via-purple-200 to-liquid-mint opacity-40 blur-2xl -z-10"
|
||||
animate={{
|
||||
borderRadius: ["40% 60% 70% 30%/40% 50% 60% 50%", "60% 30% 40% 70%/60% 40% 70% 30%", "40% 60% 70% 30%/40% 50% 60% 50%"],
|
||||
rotate: [360, 180, 0],
|
||||
scale: [1, 0.9, 1]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
{/* The Image Container with Organic Border Radius - No hard border, just shadow and glass */}
|
||||
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
|
||||
<motion.div
|
||||
className="absolute inset-0 overflow-hidden shadow-2xl bg-stone-100"
|
||||
style={{ filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.15))" }}
|
||||
animate={{
|
||||
borderRadius: ["60% 40% 30% 70%/60% 30% 70% 40%", "30% 60% 70% 40%/50% 60% 30% 60%", "60% 40% 30% 70%/60% 30% 70% 40%"]
|
||||
}}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10"
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
],
|
||||
rotate: [0, 120, 0],
|
||||
scale: [1, 1.08, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 35,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10"
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
||||
"60% 30% 40% 70%/60% 40% 70% 30%",
|
||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
||||
],
|
||||
rotate: [0, -90, 0],
|
||||
scale: [1, 1.05, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 40,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The Image Container with Organic Border Radius */}
|
||||
<motion.div
|
||||
className="absolute inset-0 overflow-hidden bg-stone-100"
|
||||
style={{
|
||||
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
|
||||
willChange: "border-radius",
|
||||
}}
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
],
|
||||
}}
|
||||
transition={{
|
||||
duration: 12,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/images/me.jpg"
|
||||
alt="Dennis Konkol"
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-110 transition-transform duration-700"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Glossy Overlay for Liquid Feel */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/30 via-transparent to-transparent opacity-50 pointer-events-none z-10" />
|
||||
|
||||
{/* Inner Border/Highlight */}
|
||||
<div className="absolute inset-0 border-[3px] border-white/20 rounded-[inherit] pointer-events-none z-20" />
|
||||
<Image
|
||||
src="/images/me.jpg"
|
||||
alt="Dennis Konkol"
|
||||
fill
|
||||
className="object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Glossy Overlay for Liquid Feel */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/25 via-transparent to-white/10 opacity-60 pointer-events-none z-10" />
|
||||
|
||||
{/* Inner Border/Highlight */}
|
||||
<div className="absolute inset-0 border-[2px] border-white/30 rounded-[inherit] pointer-events-none z-20" />
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Badges */}
|
||||
|
||||
{/* Domain Badge - repositioned below image */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 1.5, type: "spring" }}
|
||||
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/90 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.8, ease: "easeOut" }}
|
||||
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
|
||||
>
|
||||
<div className="px-6 py-2.5 rounded-full glass-panel text-stone-700 font-mono text-sm tracking-wider shadow-lg backdrop-blur-xl border border-white/50">
|
||||
dk<span className="text-liquid-rose font-bold">0</span>.dev
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Badges - subtle animations */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 1.2, duration: 0.8, ease: "easeOut" }}
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
>
|
||||
<Code size={24} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 1.7, type: "spring" }}
|
||||
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/90 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 1.4, duration: 0.8, ease: "easeOut" }}
|
||||
whileHover={{ scale: 1.1, rotate: -5 }}
|
||||
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
>
|
||||
<Zap size={24} />
|
||||
</motion.div>
|
||||
@@ -112,74 +150,95 @@ const Hero = () => {
|
||||
</motion.div>
|
||||
|
||||
{/* Main Title */}
|
||||
<div className="mb-6 flex flex-col items-center justify-center relative">
|
||||
<LiquidHeading
|
||||
text="Dennis Konkol"
|
||||
level={1}
|
||||
className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-800"
|
||||
/>
|
||||
<LiquidHeading
|
||||
text="Software Engineer"
|
||||
level={2}
|
||||
className="text-2xl md:text-4xl font-light tracking-wide text-stone-500 mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="mb-8 flex flex-col items-center justify-center relative"
|
||||
>
|
||||
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
|
||||
Dennis Konkol
|
||||
</h1>
|
||||
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2">
|
||||
Software Engineer
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 1.2 }}
|
||||
className="text-lg md:text-xl text-stone-600 mb-12 max-w-2xl mx-auto leading-relaxed"
|
||||
transition={{ duration: 1, delay: 0.9, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
|
||||
>
|
||||
I craft digital experiences with a focus on <span className="text-stone-900 font-semibold decoration-liquid-mint decoration-2 underline underline-offset-2">design</span>, <span className="text-stone-900 font-semibold decoration-liquid-lavender decoration-2 underline underline-offset-2">performance</span>, and <span className="text-stone-900 font-semibold decoration-liquid-rose decoration-2 underline underline-offset-2">user experience</span>.
|
||||
Student and passionate{" "}
|
||||
<span className="text-stone-900 font-semibold decoration-liquid-mint decoration-2 underline underline-offset-4">
|
||||
self-hoster
|
||||
</span>{" "}
|
||||
building full-stack web apps and mobile solutions. I run my own{" "}
|
||||
<span className="text-stone-900 font-semibold decoration-liquid-lavender decoration-2 underline underline-offset-4">
|
||||
infrastructure
|
||||
</span>{" "}
|
||||
and love exploring{" "}
|
||||
<span className="text-stone-900 font-semibold decoration-liquid-rose decoration-2 underline underline-offset-4">
|
||||
DevOps
|
||||
</span>
|
||||
.
|
||||
</motion.p>
|
||||
|
||||
{/* Features */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 1.4 }}
|
||||
transition={{ duration: 1, delay: 1.1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="flex flex-wrap justify-center gap-4 mb-12"
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.text}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 1.6 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/60 border border-white/80 shadow-sm backdrop-blur-sm liquid-hover"
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 1.3 + index * 0.15,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
}}
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/70 border border-white/90 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<feature.icon className="w-4 h-4 text-stone-700" />
|
||||
<span className="text-stone-700 font-medium text-sm">{feature.text}</span>
|
||||
<span className="text-stone-700 font-medium text-sm">
|
||||
{feature.text}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 1.8 }}
|
||||
transition={{ duration: 1, delay: 1.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
|
||||
>
|
||||
<motion.a
|
||||
href="#projects"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-8 py-4 bg-stone-900 text-cream rounded-full font-medium shadow-lg hover:shadow-xl hover:bg-black transition-all flex items-center gap-2 liquid-hover"
|
||||
whileHover={{ scale: 1.03, y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="px-8 py-4 bg-stone-900 text-cream rounded-full font-medium shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
|
||||
>
|
||||
<span>View My Work</span>
|
||||
<ArrowDown size={18} />
|
||||
</motion.a>
|
||||
|
||||
|
||||
<motion.a
|
||||
href="#contact"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all liquid-hover"
|
||||
whileHover={{ scale: 1.03, y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500"
|
||||
>
|
||||
<span>Contact Me</span>
|
||||
</motion.a>
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Github, Calendar, Layers, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { LiquidHeading } from '@/components/LiquidHeading';
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ExternalLink,
|
||||
Github,
|
||||
Calendar,
|
||||
Layers,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
// Smooth animation configuration
|
||||
const smoothTransition = {
|
||||
duration: 0.8,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
};
|
||||
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: smoothTransition,
|
||||
},
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
@@ -29,13 +60,15 @@ const Projects = () => {
|
||||
setMounted(true);
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects?featured=true&published=true&limit=6');
|
||||
const response = await fetch(
|
||||
"/api/projects?featured=true&published=true&limit=6",
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
console.error("Error loading projects:", error);
|
||||
}
|
||||
};
|
||||
loadProjects();
|
||||
@@ -44,67 +77,93 @@ const Projects = () => {
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<section id="projects" className="py-24 px-4 relative bg-stone-50/50">
|
||||
<section
|
||||
id="projects"
|
||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-peach/15 via-liquid-yellow/10 to-liquid-coral/15 overflow-hidden"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<LiquidHeading
|
||||
text="Selected Works"
|
||||
level={2}
|
||||
className="text-4xl md:text-6xl font-bold mb-6 text-stone-900"
|
||||
/>
|
||||
<p className="text-lg text-stone-500 max-w-2xl mx-auto mt-4 font-light">
|
||||
A collection of projects I've worked on, ranging from web applications to experiments.
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={fadeInUp}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
|
||||
Selected Works
|
||||
</h2>
|
||||
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light">
|
||||
A collection of projects I've worked on, ranging from web
|
||||
applications to experiments.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={staggerContainer}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{projects.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -8 }}
|
||||
className="group relative flex flex-col bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-stone-100"
|
||||
variants={fadeInUp}
|
||||
whileHover={{
|
||||
y: -12,
|
||||
transition: { duration: 0.5, ease: "easeOut" },
|
||||
}}
|
||||
className="group relative flex flex-col bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-700 ease-out border border-stone-100 hover:border-stone-200"
|
||||
>
|
||||
{/* Project Cover / Header */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-stone-100">
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-stone-50 to-stone-100">
|
||||
{project.imageUrl ? (
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-100 to-stone-200 flex items-center justify-center p-8 group-hover:from-stone-50 group-hover:to-stone-100 transition-colors">
|
||||
<div className="w-full h-full border-2 border-dashed border-stone-300 rounded-xl flex items-center justify-center">
|
||||
<Layers className="text-stone-300 w-12 h-12" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint/10 via-transparent to-liquid-rose/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-100 to-stone-200 flex items-center justify-center p-8 group-hover:from-stone-50 group-hover:to-stone-100 transition-colors duration-700 ease-out">
|
||||
<div className="w-full h-full border-2 border-dashed border-stone-300 rounded-xl flex items-center justify-center">
|
||||
<Layers className="text-stone-300 w-12 h-12" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint/10 via-transparent to-liquid-rose/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay Links */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-4 backdrop-blur-[2px]">
|
||||
{project.github && (
|
||||
<a href={project.github} target="_blank" rel="noopener noreferrer" className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-transform" aria-label="GitHub">
|
||||
<Github size={20} />
|
||||
</a>
|
||||
)}
|
||||
{project.live && (
|
||||
<a href={project.live} target="_blank" rel="noopener noreferrer" className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-transform" aria-label="Live Demo">
|
||||
<ExternalLink size={20} />
|
||||
</a>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-700 ease-out flex items-center justify-center gap-4 backdrop-blur-sm">
|
||||
{project.github && (
|
||||
<a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github size={20} />
|
||||
</a>
|
||||
)}
|
||||
{project.live && (
|
||||
<a
|
||||
href={project.live}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 bg-white rounded-full text-stone-900 hover:scale-110 transition-all duration-500 ease-out hover:shadow-lg"
|
||||
aria-label="Live Demo"
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 p-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
|
||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-700 transition-colors duration-500">
|
||||
{project.title}
|
||||
</h3>
|
||||
<span className="text-xs font-mono text-stone-400 bg-stone-100 px-2 py-1 rounded">
|
||||
@@ -112,45 +171,60 @@ const Projects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-stone-600 text-sm leading-relaxed mb-6 line-clamp-3 flex-1">
|
||||
<p className="text-stone-700 text-sm leading-relaxed mb-6 line-clamp-3 flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 mt-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-xs px-2.5 py-1 bg-stone-50 border border-stone-100 rounded-md text-stone-500 font-medium">
|
||||
{project.tags.slice(0, 3).map((tag, tIdx) => (
|
||||
<span
|
||||
key={`${project.id}-${tag}-${tIdx}`}
|
||||
className="text-xs px-2.5 py-1 bg-stone-50 border border-stone-100 rounded-md text-stone-600 font-medium hover:bg-stone-100 hover:border-stone-200 transition-all duration-400 ease-out"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{project.tags.length > 3 && (
|
||||
<span className="text-xs px-2 py-1 text-stone-400">+ {project.tags.length - 3}</span>
|
||||
<span className="text-xs px-2 py-1 text-stone-400">
|
||||
+ {project.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/projects/${project.title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="inline-flex items-center text-sm font-semibold text-stone-900 hover:gap-2 transition-all group/link"
|
||||
|
||||
<Link
|
||||
href={`/projects/${project.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className="inline-flex items-center text-sm font-semibold text-stone-900 hover:gap-3 transition-all duration-500 ease-out group/link"
|
||||
>
|
||||
Read more <ArrowRight size={16} className="ml-1 transition-transform group-hover/link:translate-x-1" />
|
||||
Read more{" "}
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="ml-1 transition-transform duration-500 ease-out group-hover/link:translate-x-2"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="mt-16 text-center"
|
||||
>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-white border border-stone-200 rounded-full text-stone-600 font-medium hover:bg-stone-50 hover:border-stone-300 transition-all shadow-sm"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white border border-stone-200 rounded-full text-stone-700 font-medium hover:bg-stone-50 hover:border-stone-300 hover:gap-3 transition-all duration-500 ease-out shadow-sm hover:shadow-md"
|
||||
>
|
||||
Archive <ArrowRight size={16} />
|
||||
View All Projects <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
export default Projects;
|
||||
|
||||
240
app/components/admin/AIImageGenerator.tsx
Normal file
240
app/components/admin/AIImageGenerator.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Image as ImageIcon,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AIImageGeneratorProps {
|
||||
projectId: number;
|
||||
projectTitle: string;
|
||||
currentImageUrl?: string | null;
|
||||
onImageGenerated?: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
export default function AIImageGenerator({
|
||||
projectId,
|
||||
projectTitle,
|
||||
currentImageUrl,
|
||||
onImageGenerated,
|
||||
}: AIImageGeneratorProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [generatedImageUrl, setGeneratedImageUrl] = useState(
|
||||
currentImageUrl || null,
|
||||
);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const handleGenerate = async (regenerate: boolean = false) => {
|
||||
setIsGenerating(true);
|
||||
setStatus("idle");
|
||||
setMessage("Generating AI image...");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/n8n/generate-image", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: projectId,
|
||||
regenerate: regenerate,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setStatus("success");
|
||||
setMessage(data.message || "Image generated successfully!");
|
||||
setGeneratedImageUrl(data.imageUrl);
|
||||
setShowPreview(true);
|
||||
|
||||
if (onImageGenerated) {
|
||||
onImageGenerated(data.imageUrl);
|
||||
}
|
||||
} else {
|
||||
setStatus("error");
|
||||
setMessage(data.error || data.message || "Failed to generate image");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border-2 border-stone-200 p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-purple-100 to-pink-100 rounded-lg">
|
||||
<Sparkles className="text-purple-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-stone-900">AI Image Generator</h3>
|
||||
<p className="text-sm text-stone-600">
|
||||
Generate cover image for:{" "}
|
||||
<span className="font-semibold">{projectTitle}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current/Generated Image Preview */}
|
||||
<AnimatePresence mode="wait">
|
||||
{(generatedImageUrl || showPreview) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-4 relative group"
|
||||
>
|
||||
<div className="aspect-[4/3] rounded-xl overflow-hidden border-2 border-stone-200 bg-stone-50">
|
||||
{generatedImageUrl ? (
|
||||
<img
|
||||
src={generatedImageUrl}
|
||||
alt={projectTitle}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ImageIcon className="text-stone-300" size={48} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{generatedImageUrl && (
|
||||
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-medium text-stone-700 border border-stone-200">
|
||||
Current Image
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Status Message */}
|
||||
<AnimatePresence mode="wait">
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`mb-4 p-3 rounded-xl border-2 flex items-center gap-2 ${
|
||||
status === "success"
|
||||
? "bg-green-50 border-green-200 text-green-800"
|
||||
: status === "error"
|
||||
? "bg-red-50 border-red-200 text-red-800"
|
||||
: "bg-blue-50 border-blue-200 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{status === "success" && <CheckCircle size={18} />}
|
||||
{status === "error" && <XCircle size={18} />}
|
||||
{status === "idle" && isGenerating && (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => handleGenerate(false)}
|
||||
disabled={isGenerating || (!regenerate && !!generatedImageUrl)}
|
||||
className={`flex-1 py-3 px-4 rounded-xl font-semibold text-white transition-all duration-300 flex items-center justify-center gap-2 ${
|
||||
isGenerating
|
||||
? "bg-stone-400 cursor-not-allowed"
|
||||
: generatedImageUrl
|
||||
? "bg-stone-300 cursor-not-allowed"
|
||||
: "bg-gradient-to-br from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 shadow-lg hover:shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={18} />
|
||||
Generate Image
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{generatedImageUrl && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => handleGenerate(true)}
|
||||
disabled={isGenerating}
|
||||
className={`py-3 px-4 rounded-xl font-semibold transition-all duration-300 flex items-center justify-center gap-2 border-2 ${
|
||||
isGenerating
|
||||
? "bg-stone-100 border-stone-300 text-stone-400 cursor-not-allowed"
|
||||
: "bg-white border-purple-300 text-purple-700 hover:bg-purple-50 hover:border-purple-400"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Regenerate
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-4 p-3 bg-gradient-to-br from-blue-50 to-purple-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs text-stone-700 leading-relaxed">
|
||||
<span className="font-semibold">💡 How it works:</span> The AI
|
||||
analyzes your project&aposs title, description, category, and tech
|
||||
stack to create a unique cover image using Stable Diffusion.
|
||||
Generation takes 15-30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options (Optional) */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-stone-700 hover:text-stone-900 transition-colors">
|
||||
Advanced Options
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 pl-4 border-l-2 border-stone-200">
|
||||
<div className="text-xs text-stone-600 space-y-1">
|
||||
<p>
|
||||
<strong>Image Size:</strong> 1024x768 (4:3 aspect ratio)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Quality:</strong> High (30 steps, CFG 7)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Sampler:</strong> DPM++ 2M Karras
|
||||
</p>
|
||||
<p>
|
||||
<strong>Model:</strong> SDXL Base / Category-specific
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.open("/docs/ai-image-generation/SETUP.md", "_blank")
|
||||
}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium underline"
|
||||
>
|
||||
View Full Documentation →
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user