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:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
60
app/components/ui/BentoGrid.tsx
Normal file
60
app/components/ui/BentoGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user