perf: remove framer-motion and lucide-react from critical path
All checks were successful
Gitea CI / test-build (push) Successful in 11m36s
All checks were successful
Gitea CI / test-build (push) Successful in 11m36s
- Remove framer-motion from Hero.tsx and HeaderClient.tsx, replace with CSS animations/transitions - Replace lucide-react icons (Menu, X, Mail) with inline SVGs in HeaderClient.tsx - Lazy-load About, Projects, Contact, Footer via dynamic() imports in ClientWrappers.tsx - Defer ShaderGradient/BackgroundBlobs loading via requestIdleCallback in ClientProviders.tsx - Remove AnimatePresence page wrapper that caused full re-renders - Enable experimental.optimizeCss (critters) for critical CSS inlining - Add fadeIn keyframe to Tailwind config for CSS-based animations Homepage JS reduced from 563KB to 438KB (-125KB). Eliminates ~39s main thread work from WebGL init and layout thrashing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import { ToastProvider } from "@/components/Toast";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { ConsentProvider } from "./ConsentProvider";
|
||||
import { ThemeProvider } from "./ThemeProvider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
||||
ssr: false,
|
||||
@@ -25,66 +24,19 @@ export default function ClientProviders({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [is404Page, setIs404Page] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Check if we're on a 404 page by looking for the data attribute or pathname
|
||||
const check404 = () => {
|
||||
try {
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
const has404Component = document.querySelector('[data-404-page]');
|
||||
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
|
||||
setIs404Page(!!has404Component || is404Path);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - 404 detection is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error checking 404 status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Check immediately and after a short delay
|
||||
try {
|
||||
check404();
|
||||
const timeout = setTimeout(check404, 100);
|
||||
const interval = setInterval(check404, 500);
|
||||
return () => {
|
||||
try {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(interval);
|
||||
} catch {
|
||||
// Silently fail during cleanup
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// If setup fails, just return empty cleanup
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error setting up 404 check:', error);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Wrap in multiple error boundaries to isolate failures
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<ConsentProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<GatedProviders mounted={mounted}>
|
||||
{children}
|
||||
</GatedProviders>
|
||||
</ThemeProvider>
|
||||
</ConsentProvider>
|
||||
@@ -99,13 +51,20 @@ function GatedProviders({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mounted: boolean;
|
||||
is404Page: boolean;
|
||||
}) {
|
||||
// Defer heavy Three.js/WebGL background until after LCP
|
||||
const [deferredReady, setDeferredReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
|
||||
return () => cancelIdleCallback(id);
|
||||
}, [mounted]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
{mounted && <BackgroundBlobs />}
|
||||
{mounted && <ShaderGradientBackground />}
|
||||
{deferredReady && <BackgroundBlobs />}
|
||||
{deferredReady && <ShaderGradientBackground />}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
*/
|
||||
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Hero from './Hero';
|
||||
import About from './About';
|
||||
import Projects from './Projects';
|
||||
import Contact from './Contact';
|
||||
import Footer from './Footer';
|
||||
import type {
|
||||
HeroTranslations,
|
||||
AboutTranslations,
|
||||
@@ -21,6 +18,12 @@ import type {
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
|
||||
// Lazy-load below-fold sections to reduce initial JS payload
|
||||
const About = dynamic(() => import('./About'), { ssr: false });
|
||||
const Projects = dynamic(() => import('./Projects'), { ssr: false });
|
||||
const Contact = dynamic(() => import('./Contact'), { ssr: false });
|
||||
const Footer = dynamic(() => import('./Footer'), { ssr: false });
|
||||
|
||||
const messageMap = { en: enMessages, de: deMessages };
|
||||
|
||||
function getNormalizedLocale(locale: string): 'en' | 'de' {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Mail } from "lucide-react";
|
||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type { NavTranslations } from "@/types/translations";
|
||||
|
||||
// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
|
||||
const MenuIcon = ({ size = 24 }: { size?: number }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
|
||||
);
|
||||
const XIcon = ({ size = 24 }: { size?: number }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
);
|
||||
const MailIcon = ({ size = 20 }: { size?: number }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
);
|
||||
|
||||
interface HeaderClientProps {
|
||||
locale: string;
|
||||
translations: NavTranslations;
|
||||
@@ -44,7 +53,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
||||
href: "https://linkedin.com/in/dkonkol",
|
||||
label: "LinkedIn",
|
||||
},
|
||||
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
||||
{ icon: MailIcon, href: "mailto:contact@dk0.dev", label: "Email" },
|
||||
];
|
||||
|
||||
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
|
||||
@@ -55,53 +64,38 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.header
|
||||
initial={false}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<header className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
|
||||
<div
|
||||
className={`pointer-events-auto transition-all duration-500 ease-out ${
|
||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
<div
|
||||
className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 hover:scale-105 transition-transform">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
||||
>
|
||||
dk<span className="text-red-500">0</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<div key={item.name} className="hover:-translate-y-0.5 active:scale-95 transition-all">
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Language Switcher */}
|
||||
@@ -129,121 +123,109 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05, rotate: 90 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors"
|
||||
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
{isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
</header>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Mobile menu overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden transition-opacity duration-200 ${
|
||||
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ x: "100%", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100%", opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="text-2xl font-black text-stone-900"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
dk<span className="text-red-500">0</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Mobile menu panel */}
|
||||
<div
|
||||
className={`fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto transition-transform duration-300 ease-out ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="text-2xl font-black text-stone-900"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
dk<span className="text-red-500">0</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<XIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors"
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Language Switcher Mobile */}
|
||||
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
|
||||
<Link
|
||||
href={enHref}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||
locale === "en"
|
||||
? "bg-stone-900 text-white"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
<Link
|
||||
href={deHref}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||
locale === "de"
|
||||
? "bg-stone-900 text-white"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-stone-200">
|
||||
<div className="flex justify-center space-x-6">
|
||||
{socialLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
|
||||
aria-label={link.label}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Language Switcher Mobile */}
|
||||
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
|
||||
<Link
|
||||
href={enHref}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||
locale === "en"
|
||||
? "bg-stone-900 text-white"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</Link>
|
||||
<Link
|
||||
href={deHref}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||
locale === "de"
|
||||
? "bg-stone-900 text-white"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-stone-200">
|
||||
<div className="flex justify-center space-x-6">
|
||||
{socialLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
|
||||
aria-label={link.label}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Icon size={20} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -29,16 +28,8 @@ const Hero = () => {
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||
{/* Liquid Ambient Background */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
|
||||
/>
|
||||
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
|
||||
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
|
||||
@@ -46,45 +37,25 @@ const Hero = () => {
|
||||
|
||||
{/* Left: Text Content */}
|
||||
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm animate-[fadeIn_0.5s_ease-out]">
|
||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
|
||||
<motion.span
|
||||
initial={{ x: -50 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="block"
|
||||
>
|
||||
<span className="block">
|
||||
{getLabel("hero.line1", "Building")}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial={{ x: -50 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
|
||||
>
|
||||
</span>
|
||||
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4">
|
||||
{getLabel("hero.line2", "Stuff.")}
|
||||
</motion.span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4 animate-[fadeIn_0.6s_ease-out_0.3s_both]">
|
||||
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||
{t("ctaWork")}
|
||||
@@ -92,22 +63,11 @@ const Hero = () => {
|
||||
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||
{t("ctaContact")}
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: The Photo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1
|
||||
}}
|
||||
transition={{
|
||||
opacity: { duration: 1 },
|
||||
scale: { duration: 1 }
|
||||
}}
|
||||
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
|
||||
>
|
||||
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0 animate-[fadeIn_1s_ease-out]">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
||||
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1024px) 320px, 500px" />
|
||||
@@ -116,18 +76,14 @@ const Hero = () => {
|
||||
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{ y: [0, 15, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
|
||||
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
|
||||
experimental:
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
optimizeCss: true,
|
||||
optimizePackageImports: ["lucide-react", "framer-motion", "three", "@react-three/fiber"],
|
||||
}
|
||||
: {
|
||||
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"critters": "^0.0.23",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.7",
|
||||
@@ -7260,6 +7261,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -7847,6 +7855,22 @@
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/critters": {
|
||||
"version": "0.0.23",
|
||||
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz",
|
||||
"integrity": "sha512-/MCsQbuzTPA/ZTOjjyr2Na5o3lRpr8vd0MZE8tMP0OBNg/VrLxWHteVKalQ8KR+fBmUadbJLdoyEz9sT+q84qg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"css-select": "^5.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-media-query-parser": "^0.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
@@ -7910,6 +7934,23 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
@@ -7921,6 +7962,19 @@
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -13532,6 +13586,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||
@@ -14252,6 +14319,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-media-query-parser": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
|
||||
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss-nested": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
|
||||
17
package.json
17
package.json
@@ -87,10 +87,10 @@
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Chrome versions",
|
||||
"last 2 Firefox versions",
|
||||
"last 2 Safari versions",
|
||||
"last 2 Edge versions"
|
||||
"chrome >= 100",
|
||||
"firefox >= 100",
|
||||
"safari >= 15",
|
||||
"edge >= 100"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -106,6 +106,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"critters": "^0.0.23",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.7",
|
||||
@@ -121,11 +122,5 @@
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.9.3",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome >= 100",
|
||||
"firefox >= 100",
|
||||
"safari >= 15",
|
||||
"edge >= 100"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0", transform: "translateY(10px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-out",
|
||||
},
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
|
||||
Reference in New Issue
Block a user