feat: complete design overhaul with bento grid and island nav

Refactored About section to use a responsive Bento Grid layout. Redesigned Hero for stronger visual impact. Implemented floating Island navigation. Updated Project cards for cleaner aesthetic.
This commit is contained in:
2026-02-16 00:48:45 +01:00
parent 5347a9ff3b
commit 332adab08c
5 changed files with 414 additions and 1050 deletions

View File

@@ -1,421 +1,193 @@
"use client"; "use client";
import { motion, Variants } from "framer-motion"; import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Gamepad, Code, Activity, Lightbulb } from "lucide-react"; import { BentoGrid, BentoGridItem } from "./ui/BentoGrid";
import { useEffect, useState } from "react"; import {
IconClipboardCopy,
IconFileBroken,
IconSignature,
IconTableColumn,
} from "@tabler/icons-react"; // Wir nutzen Lucide, ich tausche die gleich aus
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, MapPin, User } from "lucide-react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient"; import RichTextClient from "./RichTextClient";
import CurrentlyReading from "./CurrentlyReading"; import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks"; import ReadBooks from "./ReadBooks";
import { motion } from "framer-motion";
// Type definitions for CMS data // Helper for Tech Stack Icons
interface TechStackItem { const iconMap: Record<string, any> = {
id: string; Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2
name: string | number | null | undefined;
url?: string;
icon_url?: string;
sort: number;
}
interface TechStackCategory {
id: string;
key: string;
icon: string;
sort: number;
name: string;
items: TechStackItem[];
}
interface Hobby {
id: string;
key: string;
icon: string;
title: string | number | null | undefined;
description?: string;
}
const staggerContainer: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2,
},
},
};
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
}; };
const About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about"); const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [techStackFromCMS, setTechStackFromCMS] = useState<TechStackCategory[] | null>(null);
const [hobbiesFromCMS, setHobbiesFromCMS] = useState<Hobby[] | null>(null); // Data State
const [techStack, setTechStack] = useState<any[]>([]);
const [hobbies, setHobbies] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
// Load Content Page
(async () => { (async () => {
try { try {
const res = await fetch( const res = await fetch(`/api/content/page?key=home-about&locale=${locale}`);
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json(); const data = await res.json();
// Only use CMS content if it exists for the active locale. if (data?.content?.content) setCmsDoc(data.content.content as JSONContent);
if (data?.content?.content && data?.content?.locale === locale) { } catch {}
setCmsDoc(data.content.content as JSONContent);
} else {
setCmsDoc(null);
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
}
})(); })();
}, [locale]);
// Load Tech Stack from Directus // Load Tech Stack
useEffect(() => {
(async () => { (async () => {
try { try {
const res = await fetch(`/api/tech-stack?locale=${encodeURIComponent(locale)}`); const res = await fetch(`/api/tech-stack?locale=${locale}`);
if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data?.techStack && data.techStack.length > 0) { if (data?.techStack) setTechStack(data.techStack);
setTechStackFromCMS(data.techStack); } catch {}
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.log('Tech Stack from Directus not available, using fallback');
}
}
})(); })();
}, [locale]);
// Load Hobbies from Directus // Load Hobbies
useEffect(() => {
(async () => { (async () => {
try { try {
const res = await fetch(`/api/hobbies?locale=${encodeURIComponent(locale)}`); const res = await fetch(`/api/hobbies?locale=${locale}`);
if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data?.hobbies && data.hobbies.length > 0) { if (data?.hobbies) setHobbies(data.hobbies);
setHobbiesFromCMS(data.hobbies); } catch {}
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.log('Hobbies from Directus not available, using fallback');
}
}
})(); })();
}, [locale]); }, [locale]);
// Fallback Tech Stack (from messages/en.json, messages/de.json)
const techStackFallback = [
{
key: 'frontend',
category: t("techStack.categories.frontendMobile"),
icon: Globe,
items: ["Next.js", "Tailwind CSS", "Flutter"],
},
{
key: 'backend',
category: t("techStack.categories.backendDevops"),
icon: Server,
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
},
{
key: 'tools',
category: t("techStack.categories.toolsAutomation"),
icon: Wrench,
items: ["Git", "CI/CD", "n8n", t("techStack.items.selfHostedServices")],
},
{
key: 'security',
category: t("techStack.categories.securityAdmin"),
icon: Shield,
items: ["CrowdSec", "Suricata", "Mailcow"],
},
];
// Map icon names from Directus to Lucide components
const iconMap: Record<string, any> = {
Globe,
Server,
Code,
Wrench,
Shield,
Activity,
Lightbulb,
Gamepad2,
Gamepad
};
// Fallback Hobbies
const hobbiesFallback: Array<{ icon: typeof Code; text: string }> = [
{ icon: Code, text: t("hobbies.selfHosting") },
{ icon: Gamepad2, text: t("hobbies.gaming") },
{ icon: Server, text: t("hobbies.gameServers") },
{ icon: Activity, text: t("hobbies.jogging") },
];
// Use CMS Hobbies if available, otherwise fallback
const hobbies = hobbiesFromCMS
? hobbiesFromCMS
.map((hobby: Hobby) => {
// Convert to string, handling NaN/null/undefined
const text = hobby.title == null || (typeof hobby.title === 'number' && isNaN(hobby.title))
? ''
: String(hobby.title);
return {
icon: iconMap[hobby.icon] || Code,
text
};
})
.filter(h => {
const isValid = h.text.trim().length > 0;
if (!isValid && process.env.NODE_ENV === 'development') {
console.log('[About] Filtered out invalid hobby:', h);
}
return isValid;
})
: hobbiesFallback;
// Use CMS Tech Stack if available, otherwise fallback
const techStack = techStackFromCMS
? techStackFromCMS.map((cat: TechStackCategory) => {
const items = cat.items
.map((item: TechStackItem) => {
// Convert to string, handling NaN/null/undefined
if (item.name == null || (typeof item.name === 'number' && isNaN(item.name))) {
if (process.env.NODE_ENV === 'development') {
console.log('[About] Invalid item.name in category', cat.key, ':', item);
}
return '';
}
return String(item.name);
})
.filter(name => {
const isValid = name.trim().length > 0;
if (!isValid && process.env.NODE_ENV === 'development') {
console.log('[About] Filtered out empty item name in category', cat.key);
}
return isValid;
});
if (items.length === 0 && process.env.NODE_ENV === 'development') {
console.warn('[About] Category has no valid items after filtering:', cat.key);
}
return {
key: cat.key,
category: cat.name,
icon: iconMap[cat.icon] || Code,
items
};
})
: techStackFallback;
return ( return (
<section <section id="about" className="py-32 px-4 relative bg-stone-50 dark:bg-stone-950">
id="about" {/* Background Noise/Gradient */}
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="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 pointer-events-none mix-blend-soft-light"></div>
> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-[500px] bg-gradient-to-b from-liquid-mint/10 via-transparent to-transparent blur-3xl pointer-events-none"></div>
<div className="max-w-6xl mx-auto relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-start"> <div className="max-w-7xl mx-auto mb-16 text-center relative z-10">
{/* Left Column: Bio & Hobbies */}
<div className="space-y-16">
{/* Biography */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={staggerContainer}
className="space-y-8"
>
<motion.h2 <motion.h2
variants={fadeInUp} initial={{ opacity: 0, y: 20 }}
className="text-4xl md:text-5xl font-bold text-stone-900" whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tight"
> >
{t("title")} {t("title")}
</motion.h2> </motion.h2>
<motion.div </div>
variants={fadeInUp}
className="prose prose-stone prose-lg text-stone-700 space-y-4" <BentoGrid className="max-w-6xl mx-auto relative z-10">
>
{/* 1. The Bio (Large Item) */}
<BentoGridItem
className="md:col-span-2 md:row-span-2 bg-gradient-to-br from-white to-stone-50 dark:from-stone-900 dark:to-stone-950"
title={
<div className="flex items-center gap-2">
<User size={20} className="text-liquid-mint" />
<span>Dennis Konkol</span>
</div>
}
description={
<div className="mt-4 prose prose-stone dark:prose-invert max-w-none text-base leading-relaxed">
{cmsDoc ? ( {cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" /> <RichTextClient doc={cmsDoc} />
) : ( ) : (
<> <p className="text-stone-600 dark:text-stone-400">
<p>{t("p1")}</p> {t("p1")} {t("p2")}
<p>{t("p2")}</p> </p>
<p>{t("p3")}</p>
</>
)} )}
<motion.div </div>
variants={fadeInUp} }
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-lavender/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm" header={
> <div className="w-full h-40 bg-gradient-to-r from-liquid-mint/20 to-liquid-sky/20 rounded-xl mb-4 flex items-center justify-center overflow-hidden relative group">
<div className="flex items-start gap-3"> <div className="absolute inset-0 bg-[url('/images/me.jpg')] bg-cover bg-center opacity-40 group-hover:scale-105 transition-transform duration-700"></div>
<Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" /> <div className="relative z-10 bg-white/30 dark:bg-black/30 backdrop-blur-md px-6 py-2 rounded-full border border-white/20">
<div> <span className="font-mono text-sm font-bold text-stone-800 dark:text-white">Software Engineer</span>
<p className="text-sm font-semibold text-stone-800 mb-1">
{t("funFactTitle")}
</p>
<p className="text-sm text-stone-700 leading-relaxed">
{t("funFactBody")}
</p>
</div> </div>
</div> </div>
</motion.div> }
</motion.div> />
</motion.div>
{/* Hobbies Section */} {/* 2. Location & Status */}
<motion.div <BentoGridItem
initial="hidden" className="md:col-span-1"
whileInView="visible" title="Osnabrück, Germany"
viewport={{ once: true, margin: "-100px" }} description="Available for new opportunities"
variants={staggerContainer} icon={<MapPin className="h-4 w-4 text-neutral-500" />}
className="space-y-6" header={
> <div className="flex flex-1 w-full h-full min-h-[6rem] rounded-xl bg-gradient-to-br from-neutral-200 dark:from-neutral-900 to-neutral-100 dark:to-neutral-800 items-center justify-center">
<motion.h3 <div className="relative">
variants={fadeInUp} <div className="w-3 h-3 bg-green-500 rounded-full animate-ping absolute top-0 right-0"></div>
className="text-2xl font-bold text-stone-900" <div className="w-3 h-3 bg-green-500 rounded-full relative z-10 border-2 border-white dark:border-stone-900"></div>
> </div>
{t("hobbiesTitle")} <span className="ml-3 text-sm font-bold text-stone-600 dark:text-stone-300">Online & Active</span>
</motion.h3> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> }
{hobbies.map((hobby, idx) => ( />
<motion.div
key={`hobby-${hobby.text}-${idx}`} {/* 3. Tech Stack (Marquee Style or Grid) */}
variants={fadeInUp} <BentoGridItem
whileHover={{ className="md:col-span-1"
scale: 1.02, title={t("techStackTitle")}
transition: { duration: 0.4, ease: "easeOut" }, description="Tools I work with daily"
}} header={
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${ <div className="flex flex-wrap gap-2 min-h-[6rem] p-2">
idx % 4 === 0 {techStack.length > 0 ? (
? "bg-gradient-to-r from-liquid-mint/25 to-liquid-sky/25 border-liquid-mint/50 hover:border-liquid-mint/70" techStack.slice(0, 8).flatMap(cat => cat.items.map((item: any) => (
: idx % 4 === 1 <span key={item.name} className="px-2 py-1 bg-stone-100 dark:bg-stone-800 rounded text-xs font-mono border border-stone-200 dark:border-stone-700">
? "bg-gradient-to-r from-liquid-coral/25 to-liquid-peach/25 border-liquid-coral/50 hover:border-liquid-coral/70" {item.name}
: idx % 4 === 2
? "bg-gradient-to-r from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70"
: "bg-gradient-to-r from-liquid-lime/25 to-liquid-teal/25 border-liquid-lime/50 hover:border-liquid-lime/70"
}`}
>
<hobby.icon size={20} className="text-stone-700" />
<span className="text-stone-800 font-semibold text-sm">
{String(hobby.text)}
</span> </span>
</motion.div> ))).slice(0, 12)
))} ) : (
</div> <div className="text-xs text-stone-400">Loading Stack...</div>
</motion.div> )}
</div> </div>
}
icon={<Code className="h-4 w-4 text-neutral-500" />}
/>
{/* Right Column: Tech Stack & Reading */} {/* 4. Currently Reading */}
<div className="space-y-16"> <BentoGridItem
{/* Tech Stack */} className="md:col-span-1 md:row-span-2"
<motion.div title={null}
initial="hidden" description={null}
whileInView="visible" header={
viewport={{ once: true, margin: "-100px" }} <div className="h-full flex flex-col">
variants={staggerContainer} <div className="font-bold text-stone-900 dark:text-white mb-4 flex items-center gap-2">
className="space-y-8" <Activity size={16} /> Reading
>
<motion.h3
variants={fadeInUp}
className="text-2xl font-bold text-stone-900"
>
{t("techStackTitle")}
</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-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
idx === 0
? "bg-gradient-to-br from-liquid-sky/20 to-liquid-mint/20 border-liquid-sky/40 hover:border-liquid-sky/60"
: idx === 1
? "bg-gradient-to-br from-liquid-peach/20 to-liquid-coral/20 border-liquid-peach/40 hover:border-liquid-peach/60"
: idx === 2
? "bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 border-liquid-lavender/40 hover:border-liquid-lavender/60"
: "bg-gradient-to-br from-liquid-teal/20 to-liquid-lime/20 border-liquid-teal/40 hover:border-liquid-teal/60"
}`}
>
<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> </div>
<h4 className="font-semibold text-stone-800"> <div className="flex-1 overflow-hidden">
{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-800 font-semibold transition-all duration-400 ease-out backdrop-blur-sm ${
itemIdx % 4 === 0
? "bg-liquid-mint/20 border-liquid-mint/40 hover:bg-liquid-mint/30"
: itemIdx % 4 === 1
? "bg-liquid-lavender/20 border-liquid-lavender/40 hover:bg-liquid-lavender/30"
: itemIdx % 4 === 2
? "bg-liquid-rose/20 border-liquid-rose/40 hover:bg-liquid-rose/30"
: "bg-liquid-sky/20 border-liquid-sky/40 hover:bg-liquid-sky/30"
}`}
>
{String(item)}
</span>
))}
</div>
</motion.div>
))}
</div>
</motion.div>
{/* Reading Section */}
<div className="space-y-10">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeInUp}
>
<CurrentlyReading /> <CurrentlyReading />
</motion.div> </div>
<div className="mt-4 pt-4 border-t border-stone-100 dark:border-stone-800">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeInUp}
>
<ReadBooks /> <ReadBooks />
</motion.div>
</div> </div>
</div> </div>
}
/>
{/* 5. Hobbies */}
<BentoGridItem
className="md:col-span-2"
title={t("hobbiesTitle")}
description="What keeps me busy"
header={
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{hobbies.map((hobby, i) => {
const Icon = iconMap[hobby.icon] || Lightbulb;
return (
<div key={i} className="flex flex-col items-center justify-center p-4 bg-stone-50 dark:bg-stone-800/50 rounded-xl border border-stone-100 dark:border-stone-700/50 hover:bg-white dark:hover:bg-stone-800 transition-colors">
<Icon size={24} className="mb-2 text-liquid-purple" />
<span className="text-xs font-medium text-center">{hobby.title}</span>
</div> </div>
)
})}
</div> </div>
}
icon={<Gamepad2 className="h-4 w-4 text-neutral-500" />}
/>
</BentoGrid>
</section> </section>
); );
}; };

View File

@@ -2,32 +2,20 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Mail } from "lucide-react"; import { Menu, X } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
const Header = () => { const Header = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams();
const t = useTranslations("nav"); const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`; const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navItems = [ const navItems = [
{ name: t("home"), href: `/${locale}` }, { name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` }, { name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
@@ -35,234 +23,83 @@ const Header = () => {
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` }, { name: t("contact"), href: isHome ? "#contact" : `/${locale}#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" },
];
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
const qs = searchParams.toString();
const query = qs ? `?${qs}` : "";
const enHref = `/en${pathWithoutLocale}${query}`;
const deHref = `/de${pathWithoutLocale}${query}`;
// Always render to prevent flash, but use opacity transition
return ( return (
<> <>
<motion.header <div className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
initial={false} <motion.nav
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.5, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none" className="pointer-events-auto bg-white/80 dark:bg-stone-900/80 backdrop-blur-xl border border-stone-200/50 dark:border-stone-800/50 shadow-lg rounded-full px-2 py-2 flex items-center gap-2"
>
<div
className={`pointer-events-auto transition-all duration-500 ease-out ${
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
}`}
>
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`
backdrop-blur-xl transition-all duration-500
${
scrolled
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
: "bg-white/85 border border-stone-200/30 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"
> >
{/* Logo / Home Button */}
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center" className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors font-bold text-stone-900 dark:text-stone-100"
> >
dk<span className="text-red-500">0</span> dk
</Link> </Link>
</motion.div>
<nav className="hidden md:flex items-center space-x-8"> {/* Desktop Nav Items */}
<div className="hidden md:flex items-center gap-1 px-2">
{navItems.map((item) => ( {navItems.map((item) => (
<motion.div
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
<Link <Link
key={item.name}
href={item.href} 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" className="px-4 py-2 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-100 hover:bg-stone-100 dark:hover:bg-stone-800/50 rounded-full transition-all"
onClick={(e) => {
if (item.href.startsWith("#")) {
e.preventDefault();
const element = document.querySelector(item.href);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
}}
> >
{item.name} {item.name}
<motion.span
className="absolute -bottom-1 left-0 right-0 h-0.5 bg-gradient-to-r from-liquid-mint to-liquid-lavender rounded-full"
initial={{ scaleX: 0, opacity: 0 }}
whileHover={{ scaleX: 1, opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
}}
style={{ transformOrigin: "left center" }}
/>
</Link> </Link>
</motion.div>
))} ))}
</nav>
<div className="hidden md:flex items-center space-x-3">
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
<Link
href={enHref}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "en"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Switch language to English"
>
EN
</Link>
<Link
href={deHref}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "de"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Sprache auf Deutsch umstellen"
>
DE
</Link>
</div> </div>
<div className="w-px h-6 bg-stone-200 dark:bg-stone-800 mx-1 hidden md:block"></div>
{/* Actions */}
<div className="flex items-center gap-1">
<Link
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
className="w-9 h-9 flex items-center justify-center text-xs font-bold text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
>
{locale === "en" ? "DE" : "EN"}
</Link>
<ThemeToggle /> <ThemeToggle />
{socialLinks.map((social) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-full bg-white/40 hover:bg-white/80 border border-white/50 text-stone-600 hover:text-stone-900 transition-all shadow-sm liquid-hover"
>
<social.icon size={18} />
</motion.a>
))}
</div>
<motion.button {/* Mobile Menu Button */}
whileTap={{ scale: 0.95 }} <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover" className="w-9 h-9 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
aria-label={isOpen ? "Close menu" : "Open menu"}
> >
{isOpen ? <X size={24} /> : <Menu size={24} />} {isOpen ? <X size={18} /> : <Menu size={18} />}
</motion.button> </button>
</motion.div> </div>
</motion.nav>
</div> </div>
{/* Mobile Menu Overlay */}
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-40 md:hidden pointer-events-auto"
onClick={() => setIsOpen(false)}
/>
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }} initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }} exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ duration: 0.3, type: "spring" }} className="fixed top-20 left-4 right-4 z-40 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-3xl shadow-2xl p-4 md:hidden"
className="absolute top-24 left-4 right-4 bg-cream/95 backdrop-blur-xl border border-stone-200 shadow-xl rounded-3xl z-50 p-6 pointer-events-auto"
>
<div className="space-y-2">
{navItems.map((item, index) => (
<motion.div
key={item.name}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0 }}
transition={{ delay: index * 0.05 }}
> >
<div className="flex flex-col gap-2">
{navItems.map((item) => (
<Link <Link
key={item.name}
href={item.href} href={item.href}
onClick={(e) => { onClick={() => setIsOpen(false)}
setIsOpen(false); className="px-4 py-3 text-lg font-medium text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-stone-800/50 rounded-xl"
if (item.href.startsWith("#")) {
e.preventDefault();
setTimeout(() => {
const element = document.querySelector(item.href);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
}
}}
className="block text-stone-600 hover:text-stone-900 hover:bg-white/50 transition-all font-medium py-3 px-4 rounded-xl"
> >
{item.name} {item.name}
</Link> </Link>
</motion.div>
))}
<div className="pt-6 mt-4 border-t border-stone-200">
<div className="flex justify-center items-center space-x-4">
<ThemeToggle />
{socialLinks.map((social, index) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
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}
>
<social.icon size={20} />
</motion.a>
))} ))}
</div> </div>
</div>
</div>
</motion.div> </motion.div>
</>
)} )}
</AnimatePresence> </AnimatePresence>
</motion.header>
</> </>
); );
}; };

View File

@@ -1,251 +1,100 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ArrowDown, Code, Zap, Rocket } from "lucide-react"; import { ArrowDown, Github, Linkedin, Mail } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
const Hero = () => { const Hero = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.hero"); const t = useTranslations("home.hero");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
// If the API falls back to another locale, keep showing next-intl strings
// so the locale switch visibly changes the page.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
} else {
setCmsDoc(null);
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
}
})();
}, [locale]);
const features = [
{ icon: Code, text: t("features.f1") },
{ icon: Zap, text: t("features.f2") },
{ icon: Rocket, text: t("features.f3") },
];
return ( return (
<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 dark:from-stone-900 dark:via-stone-900 dark:to-stone-800 transition-colors duration-500"> <section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-4">
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto"> {/* Dynamic Background */}
{/* Profile Image with Organic Blob Mask */} <div className="absolute inset-0 pointer-events-none">
<motion.div <div className="absolute top-[20%] left-[20%] w-[500px] h-[500px] bg-liquid-mint/20 rounded-full blur-[120px] animate-pulse"></div>
initial={{ opacity: 0, scale: 0.9 }} <div className="absolute bottom-[20%] right-[20%] w-[400px] h-[400px] bg-liquid-purple/20 rounded-full blur-[100px] animate-pulse delay-1000"></div>
animate={{ opacity: 1, scale: 1 }} <div className="absolute top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-white/50 dark:bg-stone-950/80 blur-3xl rounded-full"></div>
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }} </div>
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 - Very slow and smooth */}
<motion.div
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 dark:from-liquid-mint/20 dark:via-liquid-blue/15 dark:to-liquid-lavender/20"
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 dark:from-liquid-rose/15 dark:via-purple-900/10 dark:to-liquid-mint/15"
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 */} <div className="relative z-10 text-center max-w-5xl mx-auto">
<motion.div {/* Availability Badge */}
className="absolute inset-0 overflow-hidden bg-stone-100 dark:bg-stone-800"
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",
}}
>
{/* Use a plain <img> to fully bypass Next.js image optimizer (dev 400 issue). */}
<img
src="/images/me.jpg"
alt="Dennis Konkol"
className="absolute inset-0 w-full h-full object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
loading="eager"
decoding="async"
/>
{/* 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>
{/* Domain Badge - repositioned below image */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }} transition={{ duration: 0.5 }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30" className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/50 dark:bg-stone-900/50 border border-stone-200 dark:border-stone-800 backdrop-blur-sm mb-8"
> >
<div className="px-6 py-2.5 rounded-full bg-white/90 dark:bg-stone-800/90 backdrop-blur-xl text-stone-900 dark:text-stone-50 font-sans font-bold text-sm tracking-wide shadow-lg border-2 border-stone-300 dark:border-stone-700"> <span className="relative flex h-2 w-2">
dk<span className="text-red-500 font-extrabold">0</span>.dev <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
</div> <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</motion.div> </span>
<span className="text-xs font-medium text-stone-600 dark:text-stone-400 uppercase tracking-wider">Available for work</span>
{/* Floating Badges - subtle animations */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: 5 }}
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 dark:bg-stone-800/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 dark:text-stone-300 z-30"
>
<Code size={24} />
</motion.div>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: -5 }}
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 dark:bg-stone-800/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 dark:text-stone-300 z-30"
>
<Zap size={24} />
</motion.div>
</div>
</motion.div> </motion.div>
{/* Main Title */} {/* Main Title */}
<motion.div <h1 className="text-6xl md:text-9xl font-black tracking-tighter text-stone-900 dark:text-stone-50 mb-6 leading-[0.9]">
initial={{ opacity: 0, y: 20 }} <motion.span
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }} transition={{ duration: 0.7, delay: 0.1 }}
className="mb-8 flex flex-col items-center justify-center relative" className="block"
> >
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 dark:text-stone-50 mb-2"> Building
Dennis Konkol </motion.span>
<motion.span
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.2 }}
className="block text-transparent bg-clip-text bg-gradient-to-r from-stone-800 via-stone-600 to-stone-800 dark:from-stone-100 dark:via-stone-400 dark:to-stone-100 pb-2"
>
Digital Products
</motion.span>
</h1> </h1>
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 dark:text-stone-400 mt-2">
Software Engineer
</h2>
</motion.div>
{/* Description */} {/* Subtitle */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="text-lg md:text-2xl text-stone-600 dark:text-stone-400 max-w-2xl mx-auto font-light leading-relaxed mb-12"
>
I'm Dennis, a Software Engineer crafting polished web & mobile experiences with a focus on performance and design.
</motion.p>
{/* Buttons */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }} transition={{ duration: 0.6, delay: 0.6 }}
className="text-lg md:text-xl text-stone-700 dark:text-stone-300 mb-12 max-w-2xl mx-auto leading-relaxed" className="flex flex-col sm:flex-row items-center justify-center gap-4"
> >
{cmsDoc ? ( <a href="#projects" className="px-8 py-4 bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900 rounded-full font-bold hover:scale-105 active:scale-95 transition-transform">
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none" /> View My Work
) : ( </a>
<p>{t("description")}</p> <div className="flex gap-2">
)} <a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="p-4 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-full hover:bg-stone-50 dark:hover:bg-stone-800 transition-colors">
</motion.div> <Github size={20} />
</a>
{/* Features */} <a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="p-4 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-full hover:bg-stone-50 dark:hover:bg-stone-800 transition-colors">
<motion.div <Linkedin size={20} />
initial={{ opacity: 0, y: 20 }} </a>
animate={{ opacity: 1, y: 0 }} <a href="mailto:contact@dk0.dev" className="p-4 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-full hover:bg-stone-50 dark:hover:bg-stone-800 transition-colors">
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }} <Mail size={20} />
className="flex flex-wrap justify-center gap-4 mb-12" </a>
> </div>
{features.map((feature, index) => (
<motion.div
key={feature.text}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.5,
delay: 0.5 + index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{ scale: 1.03, y: -3 }}
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 dark:bg-stone-800/85 border-2 border-stone-300 dark:border-stone-700 shadow-md backdrop-blur-lg"
>
<feature.icon className="w-4 h-4 text-stone-800 dark:text-stone-200" />
<span className="text-stone-800 dark:text-stone-200 font-semibold text-sm">
{feature.text}
</span>
</motion.div>
))}
</motion.div>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.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.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 shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
>
<span className="text-cream">{t("ctaWork")}</span>
<ArrowDown size={18} />
</motion.a>
<motion.a
href="#contact"
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>{t("ctaContact")}</span>
</motion.a>
</motion.div> </motion.div>
</div> </div>
{/* Scroll Indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: [0, 10, 0] }}
transition={{ delay: 1, duration: 2, repeat: Infinity }}
className="absolute bottom-10 left-1/2 -translate-x-1/2"
>
<ArrowDown className="text-stone-400 dark:text-stone-600" />
</motion.div>
</section> </section>
); );
}; };

View File

@@ -1,35 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion, Variants } from "framer-motion"; import { motion } from "framer-motion";
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
const staggerContainer: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
delayChildren: 0.1,
},
},
};
interface Project { interface Project {
id: number; id: number;
slug: string; slug: string;
@@ -53,214 +30,83 @@ const Projects = () => {
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const response = await fetch( const response = await fetch("/api/projects?featured=true&published=true&limit=6");
"/api/projects?featured=true&published=true&limit=6",
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setProjects(data.projects || []); setProjects(data.projects || []);
} }
} catch (error) { } catch (error) {}
if (process.env.NODE_ENV === "development") {
console.error("Error loading projects:", error);
}
}
}; };
loadProjects(); loadProjects();
}, []); }, []);
return ( return (
<section <section id="projects" className="py-32 px-4 bg-stone-50 dark:bg-stone-950">
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="max-w-7xl mx-auto">
<motion.div <div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
initial="hidden" <div>
whileInView="visible" <h2 className="text-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tight mb-4">
viewport={{ once: true, margin: "-50px" }} Selected Work
variants={fadeInUp}
className="text-center mb-20"
>
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
{t("title")}
</h2> </h2>
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light"> <p className="text-xl text-stone-500 max-w-xl">
{t("subtitle")} Projects that pushed my boundaries.
</p> </p>
</motion.div> </div>
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-bold border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity">
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
<motion.div <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
variants={staggerContainer}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{projects.map((project) => ( {projects.map((project) => (
<motion.div <motion.div
key={project.id} key={project.id}
variants={fadeInUp} initial={{ opacity: 0, y: 20 }}
whileHover={{ y: -8 }} whileInView={{ opacity: 1, y: 0 }}
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-[box-shadow,border-color,background-color] duration-500" viewport={{ once: true }}
className="group relative"
> >
{/* Project Cover / Image Area */} <Link href={`/${locale}/projects/${project.slug}`} className="block">
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100"> {/* Image Card */}
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-6">
{project.imageUrl ? ( {project.imageUrl ? (
<>
<Image <Image
src={project.imageUrl} src={project.imageUrl}
alt={project.title} alt={project.title}
fill fill
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110" className="object-cover transition-transform duration-700 group-hover:scale-105"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : ( ) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden"> <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" /> <span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
</div> </div>
)} )}
{/* Overlay on Hover */}
{/* Texture/Grain Overlay */} <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{/* Featured Badge */}
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
{t("featured")}
</div>
</div>
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
>
<Github size={20} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={20} />
</a>
)}
</div>
</div> </div>
{/* Content */} {/* Text Content */}
<div className="p-6 flex flex-col flex-1"> <div className="flex justify-between items-start">
{/* Stretched Link covering the whole card (including image area) */} <div>
<Link <h3 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-2 group-hover:underline decoration-2 underline-offset-4">
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title} {project.title}
</h3> </h3>
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100"> <p className="text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
<Calendar size={12} />
<span>
{(() => {
const d = new Date(project.date);
return isNaN(d.getTime()) ? project.date : d.getFullYear();
})()}
</span>
</div>
</div>
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
{project.description} {project.description}
</p> </p>
</div>
<div className="flex flex-wrap gap-2 mb-6"> <div className="hidden md:flex gap-2">
{project.tags.slice(0, 4).map((tag) => ( {project.tags.slice(0, 2).map(tag => (
<span <span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
key={tag}
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
>
{tag} {tag}
</span> </span>
))} ))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div>
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
<div className="flex gap-3">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div> </div>
</div> </div>
</div>
</motion.div>
))}
</motion.div>
<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={`/${locale}/projects`}
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"
>
{t("viewAll")} <ArrowRight size={16} />
</Link> </Link>
</motion.div> </motion.div>
))}
</div>
</div> </div>
</section> </section>
); );

View File

@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
onClick,
}: {
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
header?: React.ReactNode;
icon?: React.ReactNode;
onClick?: () => void;
}) => {
return (
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className={cn(
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
className
)}
onClick={onClick}
>
{header}
<div className="group-hover/bento:translate-x-2 transition duration-200">
{icon}
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
{title}
</div>
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
{description}
</div>
</div>
</motion.div>
);
};