Polish hero, projects, modal, and tech marquee
UI polish and refactor across home components: HeroSection now uses a fixed particle seed and staggered motion variants to avoid hydration/layout shifts, updates background/video/gradient overlays, refines typewriter timing and CTA styles, and improves accessibility/visuals. DemoModal is portalled to document.body, z-index/backdrop/animation timing adjusted, and body-scroll/escape handling preserved. ProjectsSection adds a new "Client Websites" grid with two project cards (davo1.cz, Epinger) including badges, previews and visit links. TechMarquee was refactored to a seamless repeating track (four copies) with a CSS marquee animation, hover-to-pause and item hover effects; also minor theme/color tweaks and general style improvements.
This commit is contained in:
@@ -5,20 +5,29 @@ import { FaChevronDown } from "react-icons/fa";
|
|||||||
|
|
||||||
const ROLES = ["Web Developer", "Drone Pilot", "Systems Architect", "Real-Time Engineer"];
|
const ROLES = ["Web Developer", "Drone Pilot", "Systems Architect", "Real-Time Engineer"];
|
||||||
|
|
||||||
const PARTICLES = Array.from({ length: 10 }, (_, i) => ({
|
// Fixed seed so no hydration mismatch & no layout shift on re-render
|
||||||
id: i,
|
const PARTICLES = [
|
||||||
left: `${10 + Math.random() * 80}%`,
|
{ id: 0, left: "12%", top: "18%", size: 5, delay: 0, dur: 5.2 },
|
||||||
top: `${10 + Math.random() * 75}%`,
|
{ id: 1, left: "82%", top: "14%", size: 7, delay: 0.8, dur: 6.1 },
|
||||||
size: `${4 + Math.random() * 8}px`,
|
{ id: 2, left: "25%", top: "72%", size: 4, delay: 1.5, dur: 4.8 },
|
||||||
delay: `${Math.random() * 4}s`,
|
{ id: 3, left: "68%", top: "55%", size: 9, delay: 0.3, dur: 7.0 },
|
||||||
duration: `${4 + Math.random() * 4}s`,
|
{ id: 4, left: "90%", top: "40%", size: 5, delay: 2.1, dur: 5.5 },
|
||||||
}));
|
{ id: 5, left: "44%", top: "85%", size: 6, delay: 0.6, dur: 6.3 },
|
||||||
|
{ id: 6, left: "8%", top: "60%", size: 4, delay: 1.9, dur: 4.5 },
|
||||||
|
{ id: 7, left: "57%", top: "22%", size: 8, delay: 0.2, dur: 5.8 },
|
||||||
|
{ id: 8, left: "75%", top: "78%", size: 5, delay: 1.2, dur: 6.6 },
|
||||||
|
{ id: 9, left: "35%", top: "45%", size: 6, delay: 2.4, dur: 5.0 },
|
||||||
|
];
|
||||||
|
|
||||||
const fade = (delay = 0) => ({
|
const stagger = {
|
||||||
initial: { opacity: 0, y: 30 },
|
container: {
|
||||||
animate: { opacity: 1, y: 0 },
|
animate: { transition: { staggerChildren: 0.18 } },
|
||||||
transition: { duration: 0.7, ease: "easeOut", delay },
|
},
|
||||||
});
|
item: {
|
||||||
|
initial: { opacity: 0, y: 28 },
|
||||||
|
animate: { opacity: 1, y: 0, transition: { duration: 0.65, ease: "easeOut" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const [roleIdx, setRoleIdx] = useState(0);
|
const [roleIdx, setRoleIdx] = useState(0);
|
||||||
@@ -28,167 +37,154 @@ export default function HeroSection() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = ROLES[roleIdx];
|
const target = ROLES[roleIdx];
|
||||||
let i = typing ? 0 : target.length;
|
let i = typing ? 0 : target.length;
|
||||||
const speed = typing ? 55 : 30;
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (typing) {
|
if (typing) {
|
||||||
i++;
|
i++;
|
||||||
setDisplayed(target.slice(0, i));
|
setDisplayed(target.slice(0, i));
|
||||||
if (i >= target.length) {
|
if (i >= target.length) { clearInterval(timer); setTimeout(() => setTyping(false), 1800); }
|
||||||
clearInterval(timer);
|
|
||||||
setTimeout(() => setTyping(false), 1800);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
i--;
|
i--;
|
||||||
setDisplayed(target.slice(0, i));
|
setDisplayed(target.slice(0, i));
|
||||||
if (i <= 0) {
|
if (i <= 0) { clearInterval(timer); setRoleIdx(p => (p + 1) % ROLES.length); setTyping(true); }
|
||||||
clearInterval(timer);
|
|
||||||
setRoleIdx((prev) => (prev + 1) % ROLES.length);
|
|
||||||
setTyping(true);
|
|
||||||
}
|
}
|
||||||
}
|
}, typing ? 55 : 28);
|
||||||
}, speed);
|
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [roleIdx, typing]);
|
}, [roleIdx, typing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section style={{ minHeight: "100svh", position: "relative", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
|
||||||
style={{ minHeight: "100svh", position: "relative", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }}
|
|
||||||
>
|
{/* Video background — shows when file is present */}
|
||||||
{/* Video background */}
|
<video autoPlay muted loop playsInline
|
||||||
<video
|
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", zIndex: 0, opacity: 0.3 }}
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", zIndex: 0, opacity: 0.35 }}
|
|
||||||
src="/assets/hero-drone.mp4"
|
src="/assets/hero-drone.mp4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gradient overlay */}
|
{/* Animated mesh gradient fallback / overlay */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", inset: 0, zIndex: 1,
|
position: "absolute", inset: 0, zIndex: 1,
|
||||||
background: "linear-gradient(to bottom, rgba(3,29,68,0.55) 0%, rgba(3,29,68,0.85) 60%, var(--c-background) 100%)",
|
background: `
|
||||||
|
radial-gradient(ellipse 80% 60% at 20% 30%, color-mix(in hsl, var(--c-boxes), transparent 60%) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 80% 70%, color-mix(in hsl, var(--c-other), transparent 75%) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 100% 80% at 50% 50%, color-mix(in hsl, var(--c-background-light), transparent 20%) 0%, var(--c-background) 80%)
|
||||||
|
`,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Subtle grid lines */}
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: 0, zIndex: 1, opacity: 0.04,
|
||||||
|
backgroundImage: "linear-gradient(var(--c-lines) 1px, transparent 1px), linear-gradient(90deg, var(--c-lines) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "60px 60px",
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
{/* Floating particles */}
|
{/* Floating particles */}
|
||||||
{PARTICLES.map((p) => (
|
{PARTICLES.map(p => (
|
||||||
<div
|
<div key={p.id} className="animate-float" style={{
|
||||||
key={p.id}
|
position: "absolute", left: p.left, top: p.top,
|
||||||
className="animate-float"
|
width: p.size, height: p.size, borderRadius: "50%",
|
||||||
style={{
|
background: "var(--c-lines)", opacity: 0.22, filter: "blur(1.5px)",
|
||||||
position: "absolute",
|
zIndex: 1, animationDelay: `${p.delay}s`, animationDuration: `${p.dur}s`,
|
||||||
left: p.left,
|
}} />
|
||||||
top: p.top,
|
|
||||||
width: p.size,
|
|
||||||
height: p.size,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "var(--c-text)",
|
|
||||||
opacity: 0.18,
|
|
||||||
filter: "blur(2px)",
|
|
||||||
zIndex: 1,
|
|
||||||
animationDelay: p.delay,
|
|
||||||
animationDuration: p.duration,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ position: "relative", zIndex: 2, textAlign: "center", padding: "2rem 1.5rem", maxWidth: "820px" }}>
|
<motion.div
|
||||||
|
variants={stagger.container}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
style={{ position: "relative", zIndex: 2, textAlign: "center", padding: "2rem 1.5rem", maxWidth: "860px", width: "100%" }}
|
||||||
|
>
|
||||||
{/* Badge */}
|
{/* Badge */}
|
||||||
<motion.div {...fade(0.1)}>
|
<motion.div variants={stagger.item} style={{ marginBottom: "1.5rem" }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
display: "inline-block",
|
display: "inline-flex", alignItems: "center", gap: "0.4em",
|
||||||
padding: "0.35em 1em",
|
padding: "0.4em 1.1em", borderRadius: "9999px",
|
||||||
borderRadius: "9999px",
|
border: "1px solid color-mix(in hsl, var(--c-other), transparent 45%)",
|
||||||
border: "1px solid color-mix(in hsl, var(--c-other), transparent 40%)",
|
background: "color-mix(in hsl, var(--c-other), transparent 82%)",
|
||||||
background: "color-mix(in hsl, var(--c-other), transparent 80%)",
|
color: "var(--c-other)", fontSize: "0.8rem", fontWeight: 600,
|
||||||
color: "var(--c-other)",
|
letterSpacing: "0.07em", textTransform: "uppercase",
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.06em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: "1.2rem",
|
|
||||||
}}>
|
}}>
|
||||||
✦ Available for projects
|
<span style={{ fontSize: "0.6em" }}>●</span> Available for projects
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name — gradient text via inline WebkitTextFillColor so no class conflict */}
|
||||||
<motion.h1 {...fade(0.25)} style={{ fontSize: "clamp(2.8rem, 7vw, 5rem)", fontWeight: 900, marginBottom: "0.3em", lineHeight: 1.1 }}>
|
<motion.h1
|
||||||
<span className="text-rainbow animate-gradient" style={{ background: "linear-gradient(90deg, var(--c-other), var(--c-lines), var(--c-text), var(--c-other))" }}>
|
variants={stagger.item}
|
||||||
|
style={{ fontSize: "clamp(3rem, 8vw, 5.5rem)", fontWeight: 900, margin: "0 0 0.4em", lineHeight: 1.05, letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "linear-gradient(135deg, var(--c-other) 0%, var(--c-lines) 45%, var(--c-text) 100%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
backgroundClip: "text",
|
||||||
|
}}>
|
||||||
Bruno Novotný
|
Bruno Novotný
|
||||||
</span>
|
</span>
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Typewriter */}
|
{/* Typewriter role */}
|
||||||
<motion.div {...fade(0.4)} style={{ fontSize: "clamp(1.2rem, 3vw, 1.9rem)", fontWeight: 500, color: "var(--c-text)", marginBottom: "1.5rem", minHeight: "2.5rem" }}>
|
<motion.div
|
||||||
<span style={{ color: "color-mix(in hsl, var(--c-lines), transparent 30%)" }}>I build as a </span>
|
variants={stagger.item}
|
||||||
<span style={{ color: "var(--c-text)", fontWeight: 700 }}>{displayed}</span>
|
style={{ fontSize: "clamp(1.1rem, 2.8vw, 1.7rem)", fontWeight: 400, marginBottom: "1.6rem", minHeight: "2.2rem", color: "color-mix(in hsl, var(--c-text), transparent 20%)" }}
|
||||||
<span style={{ color: "var(--c-other)", animation: "bounce-y 0.7s ease-in-out infinite" }}>|</span>
|
>
|
||||||
|
I am a{" "}
|
||||||
|
<span style={{ fontWeight: 700, color: "var(--c-text)" }}>{displayed}</span>
|
||||||
|
<span style={{ color: "var(--c-other)", fontWeight: 300 }}>|</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Sub */}
|
{/* Subtitle */}
|
||||||
<motion.p {...fade(0.55)} style={{ color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "1.05rem", maxWidth: "560px", margin: "0 auto 2.2rem", lineHeight: 1.7 }}>
|
<motion.p
|
||||||
From stunning drone footage to complex web platforms — I craft digital experiences that work at scale.
|
variants={stagger.item}
|
||||||
|
style={{ color: "color-mix(in hsl, var(--c-text), transparent 40%)", fontSize: "1rem", maxWidth: "540px", margin: "0 auto 2.4rem", lineHeight: 1.75 }}
|
||||||
|
>
|
||||||
|
From cinematic drone footage to complex real-time web platforms — I craft digital experiences that work at scale.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* CTAs */}
|
{/* CTAs */}
|
||||||
<motion.div {...fade(0.7)} style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
<motion.div variants={stagger.item} style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
||||||
<a
|
<a
|
||||||
href="#projects"
|
href="#projects"
|
||||||
className="animate-pulse-glow"
|
|
||||||
style={{
|
style={{
|
||||||
padding: "0.8em 2em",
|
padding: "0.8em 2.2em", borderRadius: "9999px", cursor: "pointer",
|
||||||
borderRadius: "9999px",
|
background: "linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 45%))",
|
||||||
background: "linear-gradient(135deg, var(--c-other), color-mix(in hsl, var(--c-other), var(--c-boxes) 40%))",
|
color: "#031D44", fontWeight: 700, fontSize: "0.95rem",
|
||||||
color: "#031D44",
|
textDecoration: "none", border: "none",
|
||||||
fontWeight: 700,
|
boxShadow: "0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 55%)",
|
||||||
fontSize: "0.95rem",
|
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||||
textDecoration: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "transform 0.2s ease",
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.transform = "scale(1.05)")}
|
onMouseEnter={e => { e.currentTarget.style.transform = "scale(1.06)"; e.currentTarget.style.boxShadow = "0 0 2rem color-mix(in hsl, var(--c-other), transparent 35%)"; }}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.transform = "scale(1)")}
|
onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.boxShadow = "0 0 1.5rem color-mix(in hsl, var(--c-other), transparent 55%)"; }}
|
||||||
>
|
>
|
||||||
Explore My Work
|
Explore My Work
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link
|
||||||
to="/contact"
|
to="/contact"
|
||||||
style={{
|
style={{
|
||||||
padding: "0.8em 2em",
|
padding: "0.8em 2.2em", borderRadius: "9999px",
|
||||||
borderRadius: "9999px",
|
background: "color-mix(in hsl, var(--c-background-light), transparent 25%)",
|
||||||
background: "color-mix(in hsl, var(--c-background-light), transparent 30%)",
|
backdropFilter: "blur(12px)",
|
||||||
backdropFilter: "blur(10px)",
|
color: "var(--c-text)", fontWeight: 600, fontSize: "0.95rem",
|
||||||
color: "var(--c-text)",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.95rem",
|
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 55%)",
|
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 55%)",
|
||||||
transition: "transform 0.2s ease, border-color 0.2s ease",
|
transition: "transform 0.2s ease, border-color 0.2s ease, background 0.2s ease",
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = "scale(1.05)";
|
|
||||||
e.currentTarget.style.borderColor = "var(--c-other)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = "scale(1)";
|
|
||||||
e.currentTarget.style.borderColor = "color-mix(in hsl, var(--c-lines), transparent 55%)";
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = "scale(1.06)"; e.currentTarget.style.borderColor = "var(--c-other)"; e.currentTarget.style.background = "color-mix(in hsl, var(--c-background-light), transparent 5%)"; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.borderColor = "color-mix(in hsl, var(--c-lines), transparent 55%)"; e.currentTarget.style.background = "color-mix(in hsl, var(--c-background-light), transparent 25%)"; }}
|
||||||
>
|
>
|
||||||
Let's Talk
|
Let's Talk
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
{/* Scroll indicator */}
|
||||||
<div
|
<div className="animate-bounce-y" style={{
|
||||||
className="animate-bounce-y"
|
position: "absolute", bottom: "2rem", left: "50%", transform: "translateX(-50%)",
|
||||||
style={{ position: "absolute", bottom: "2.5rem", left: "50%", transform: "translateX(-50%)", zIndex: 2, color: "color-mix(in hsl, var(--c-text), transparent 50%)", fontSize: "1.4rem" }}
|
zIndex: 2, color: "color-mix(in hsl, var(--c-text), transparent 55%)", fontSize: "1.2rem",
|
||||||
>
|
}}>
|
||||||
<FaChevronDown />
|
<FaChevronDown />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { FaCopy, FaCheck, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
import { FaCopy, FaCheck, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||||
|
|
||||||
@@ -53,7 +54,6 @@ function CredRow({ label, value }: { label: string; value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DemoModal({ opened, onClose }: Props) {
|
export default function DemoModal({ opened, onClose }: Props) {
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) return;
|
if (!opened) return;
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
@@ -61,13 +61,12 @@ export default function DemoModal({ opened, onClose }: Props) {
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [opened, onClose]);
|
}, [opened, onClose]);
|
||||||
|
|
||||||
// Prevent body scroll when open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = opened ? "hidden" : "";
|
document.body.style.overflow = opened ? "hidden" : "";
|
||||||
return () => { document.body.style.overflow = ""; };
|
return () => { document.body.style.overflow = ""; };
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{opened && (
|
{opened && (
|
||||||
<>
|
<>
|
||||||
@@ -80,8 +79,8 @@ export default function DemoModal({ opened, onClose }: Props) {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, zIndex: 1000,
|
position: "fixed", inset: 0, zIndex: 9000,
|
||||||
background: "rgba(3,29,68,0.75)",
|
background: "rgba(3,29,68,0.78)",
|
||||||
backdropFilter: "blur(8px)",
|
backdropFilter: "blur(8px)",
|
||||||
WebkitBackdropFilter: "blur(8px)",
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
}}
|
}}
|
||||||
@@ -90,19 +89,20 @@ export default function DemoModal({ opened, onClose }: Props) {
|
|||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<motion.div
|
<motion.div
|
||||||
key="modal"
|
key="modal"
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
initial={{ opacity: 0, scale: 0.9, y: 24 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.92, y: 10 }}
|
exit={{ opacity: 0, scale: 0.92, y: 10 }}
|
||||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", top: "50%", left: "50%",
|
position: "fixed",
|
||||||
|
top: "50%", left: "50%",
|
||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
zIndex: 1001,
|
zIndex: 9001,
|
||||||
width: "min(92vw, 420px)",
|
width: "min(92vw, 420px)",
|
||||||
background: "var(--c-background-light)",
|
background: "var(--c-background-light)",
|
||||||
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 60%)",
|
border: "1px solid color-mix(in hsl, var(--c-lines), transparent 60%)",
|
||||||
borderRadius: "1.25rem",
|
borderRadius: "1.25rem",
|
||||||
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
|
boxShadow: "0 24px 64px rgba(0,0,0,0.6)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -177,6 +177,7 @@ export default function DemoModal({ opened, onClose }: Props) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,154 @@ export default function ProjectsSection() {
|
|||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Client websites divider */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
style={{ marginTop: "3rem", marginBottom: "1.5rem", display: "flex", alignItems: "center", gap: "1rem" }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, height: "1px", background: "linear-gradient(90deg, transparent, color-mix(in hsl, var(--c-lines), transparent 65%))" }} />
|
||||||
|
<span style={{ fontSize: "0.78rem", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "color-mix(in hsl, var(--c-text), transparent 50%)", whiteSpace: "nowrap" }}>
|
||||||
|
Client Websites
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, height: "1px", background: "linear-gradient(90deg, color-mix(in hsl, var(--c-lines), transparent 65%), transparent)" }} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true }}
|
||||||
|
style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "1.5rem" }}
|
||||||
|
>
|
||||||
|
{/* davo1.cz — advertising firm, white+gold logo → dark bg */}
|
||||||
|
<motion.div
|
||||||
|
variants={cardVariants}
|
||||||
|
whileHover={{ y: -8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 250, damping: 20 }}
|
||||||
|
className="glass"
|
||||||
|
style={{ display: "flex", flexDirection: "column", position: "relative", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "3px", background: "linear-gradient(90deg, #C9A84C, #8B6914)" }} />
|
||||||
|
|
||||||
|
{/* Logo preview — dark so white+gold shows */}
|
||||||
|
<div style={{
|
||||||
|
background: "#0f0b05",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "1.5rem 2rem", height: "160px", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/portfolio/davo1.png"
|
||||||
|
alt="davo1.cz logo"
|
||||||
|
style={{ maxHeight: "80px", maxWidth: "100%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "1.5rem 2rem 2rem", display: "flex", flexDirection: "column", gap: "1rem", flex: 1 }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: "0 0 0.2rem", fontSize: "1.1rem", fontWeight: 800 }}>davo1.cz</h3>
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "color-mix(in hsl, var(--c-text), transparent 45%)" }}>Advertising agency</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.9rem", lineHeight: 1.65 }}>
|
||||||
|
Presentation website for an advertising firm — clean design, custom graphics, and service showcase built to attract business clients.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||||
|
<TechBadge label="Static HTML/CSS" color="#C9A84C" />
|
||||||
|
<TechBadge label="Custom Design" color="#C9A84C" />
|
||||||
|
<TechBadge label="Branding" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://davo1.cz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: "0.5rem",
|
||||||
|
padding: "0.65em 1.4em", borderRadius: "9999px",
|
||||||
|
background: "color-mix(in hsl, #C9A84C, transparent 78%)",
|
||||||
|
border: "1px solid color-mix(in hsl, #C9A84C, transparent 50%)",
|
||||||
|
color: "#C9A84C", fontWeight: 600, fontSize: "0.88rem",
|
||||||
|
textDecoration: "none", marginTop: "auto",
|
||||||
|
transition: "background 0.2s ease, transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #C9A84C, transparent 55%)"; e.currentTarget.style.transform = "scale(1.03)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #C9A84C, transparent 78%)"; e.currentTarget.style.transform = "scale(1)"; }}
|
||||||
|
>
|
||||||
|
Visit Site <FaArrowRight style={{ fontSize: "0.75rem" }} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Epinger — fences & gates, black+orange logo → white bg */}
|
||||||
|
<motion.div
|
||||||
|
variants={cardVariants}
|
||||||
|
whileHover={{ y: -8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 250, damping: 20 }}
|
||||||
|
className="glass"
|
||||||
|
style={{ display: "flex", flexDirection: "column", position: "relative", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: "3px", background: "linear-gradient(90deg, #ff7b00, #ffb347)" }} />
|
||||||
|
|
||||||
|
{/* Logo preview — white so black text shows */}
|
||||||
|
<div style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "0.5rem 1rem", height: "160px", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/portfolio/epinger.png"
|
||||||
|
alt="Epinger logo"
|
||||||
|
style={{ width: "90%", maxWidth: "360px", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "1.5rem 2rem 2rem", display: "flex", flexDirection: "column", gap: "1rem", flex: 1 }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: "0 0 0.2rem", fontSize: "1.1rem", fontWeight: 800 }}>Epinger — Vstupní systémy</h3>
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "color-mix(in hsl, var(--c-text), transparent 45%)" }}>Construction & access systems</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: 0, color: "color-mix(in hsl, var(--c-text), transparent 30%)", fontSize: "0.9rem", lineHeight: 1.65 }}>
|
||||||
|
Business website for a company specializing in electronic entry systems, automated gates, and classic fencing — product catalog with inquiry forms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||||
|
<TechBadge label="Custom Design" color="#ff7b00" />
|
||||||
|
<TechBadge label="Product Catalog" color="#ff7b00" />
|
||||||
|
<TechBadge label="Contact Forms" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
<a
|
||||||
|
href="http://epinger.cz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: "0.5rem",
|
||||||
|
padding: "0.65em 1.4em", borderRadius: "9999px",
|
||||||
|
background: "color-mix(in hsl, #ff7b00, transparent 82%)",
|
||||||
|
border: "1px solid color-mix(in hsl, #ff7b00, transparent 55%)",
|
||||||
|
color: "#ff9a40", fontWeight: 600, fontSize: "0.88rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "background 0.2s ease, transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #ff7b00, transparent 60%)"; e.currentTarget.style.transform = "scale(1.03)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = "color-mix(in hsl, #ff7b00, transparent 82%)"; e.currentTarget.style.transform = "scale(1)"; }}
|
||||||
|
>
|
||||||
|
Visit Site <FaArrowRight style={{ fontSize: "0.75rem" }} />
|
||||||
|
</a>
|
||||||
|
<span style={{ fontSize: "0.72rem", color: "color-mix(in hsl, var(--c-text), transparent 55%)", display: "flex", alignItems: "center", gap: "0.3rem" }}>
|
||||||
|
<span style={{ color: "#ff7b00" }}>⚠</span> HTTP only — could be migrated to HTTPS on my hosting in a day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DemoModal opened={demoOpen} onClose={() => setDemoOpen(false)} />
|
<DemoModal opened={demoOpen} onClose={() => setDemoOpen(false)} />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TECHS = [
|
|||||||
{ icon: <SiPython />, label: "Python", color: "#3776ab" },
|
{ icon: <SiPython />, label: "Python", color: "#3776ab" },
|
||||||
{ icon: <SiDjango />, label: "Django", color: "#09d3ac" },
|
{ icon: <SiDjango />, label: "Django", color: "#09d3ac" },
|
||||||
{ icon: <SiReact />, label: "React", color: "#61dafb" },
|
{ icon: <SiReact />, label: "React", color: "#61dafb" },
|
||||||
{ icon: <SiDebian />, label: "Debian", color: "#a80030" },
|
{ icon: <SiDebian />, label: "Debian", color: "#d70a53" },
|
||||||
{ icon: <SiPostgresql />, label: "PostgreSQL", color: "#336791" },
|
{ icon: <SiPostgresql />, label: "PostgreSQL", color: "#336791" },
|
||||||
{ icon: <SiRedis />, label: "Redis", color: "#ff4438" },
|
{ icon: <SiRedis />, label: "Redis", color: "#ff4438" },
|
||||||
{ icon: <SiCelery />, label: "Celery", color: "#37b24d" },
|
{ icon: <SiCelery />, label: "Celery", color: "#37b24d" },
|
||||||
@@ -19,55 +19,14 @@ const TECHS = [
|
|||||||
{ icon: <FaBrain />, label: "Ollama", color: "var(--c-lines)", experimental: true },
|
{ icon: <FaBrain />, label: "Ollama", color: "var(--c-lines)", experimental: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TechItem({ icon, label, color, experimental }: { icon: React.ReactNode; label: string; color: string; experimental?: boolean }) {
|
// Four copies so the loop is seamless at any viewport width
|
||||||
return (
|
const TRACK = [...TECHS, ...TECHS, ...TECHS, ...TECHS];
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.2, y: -4 }}
|
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 18 }}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.75rem 1.25rem",
|
|
||||||
borderRadius: "1rem",
|
|
||||||
cursor: "default",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{experimental && (
|
|
||||||
<span style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
fontSize: "0.6rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
padding: "0.15em 0.4em",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
background: "color-mix(in hsl, var(--c-lines), transparent 70%)",
|
|
||||||
color: "var(--c-lines)",
|
|
||||||
letterSpacing: "0.04em",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}>
|
|
||||||
exp
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: "2.5rem", color, filter: `drop-shadow(0 0 0.4rem color-mix(in hsl, ${color}, transparent 65%))` }}>
|
|
||||||
{icon ?? <span style={{ fontSize: "1rem", fontWeight: 800, color }}>{label.split(".")[0]}</span>}
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: "0.78rem", fontWeight: 600, color: "color-mix(in hsl, var(--c-text), transparent 35%)", whiteSpace: "nowrap" }}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TechMarquee() {
|
export default function TechMarquee() {
|
||||||
const doubled = [...TECHS, ...TECHS];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section" style={{ background: "var(--c-background)", overflow: "hidden" }}>
|
<section style={{ background: "var(--c-background)", padding: "4rem 0", overflow: "hidden" }}>
|
||||||
<div className="container" style={{ marginBottom: "2rem" }}>
|
{/* Header */}
|
||||||
|
<div className="container" style={{ marginBottom: "2.5rem" }}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -96,41 +55,87 @@ export default function TechMarquee() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fade edges */}
|
{/* Marquee */}
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<div style={{
|
{/* Fade edges */}
|
||||||
position: "absolute", left: 0, top: 0, bottom: 0, width: "8rem", zIndex: 2,
|
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: "10rem", zIndex: 2, background: "linear-gradient(to right, var(--c-background), transparent)", pointerEvents: "none" }} />
|
||||||
background: "linear-gradient(to right, var(--c-background), transparent)",
|
<div style={{ position: "absolute", right: 0, top: 0, bottom: 0, width: "10rem", zIndex: 2, background: "linear-gradient(to left, var(--c-background), transparent)", pointerEvents: "none" }} />
|
||||||
pointerEvents: "none",
|
|
||||||
}} />
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", right: 0, top: 0, bottom: 0, width: "8rem", zIndex: 2,
|
|
||||||
background: "linear-gradient(to left, var(--c-background), transparent)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Marquee track */}
|
{/* Track wrapper — pause on hover */}
|
||||||
<div
|
<div
|
||||||
style={{ overflow: "hidden" }}
|
className="marquee-track-wrapper"
|
||||||
onMouseEnter={(e) => {
|
style={{ overflow: "hidden", width: "100%" }}
|
||||||
const el = e.currentTarget.firstElementChild as HTMLElement;
|
>
|
||||||
if (el) el.style.animationPlayState = "paused";
|
{/* Scrolling track — CSS animation, no Framer Motion here */}
|
||||||
}}
|
<div
|
||||||
onMouseLeave={(e) => {
|
className="marquee-track"
|
||||||
const el = e.currentTarget.firstElementChild as HTMLElement;
|
style={{
|
||||||
if (el) el.style.animationPlayState = "running";
|
display: "flex",
|
||||||
|
width: "max-content",
|
||||||
|
animation: "marquee 35s linear infinite",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{TRACK.map((tech, i) => (
|
||||||
<div
|
<div
|
||||||
className="animate-marquee"
|
key={i}
|
||||||
style={{ display: "flex", width: "max-content" }}
|
className="tech-item"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.6rem",
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
position: "relative",
|
||||||
|
cursor: "default",
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{doubled.map((tech, i) => (
|
{tech.experimental && (
|
||||||
<TechItem key={`${tech.label}-${i}`} {...tech} />
|
<span style={{
|
||||||
|
position: "absolute", top: 0, right: "0.5rem",
|
||||||
|
fontSize: "0.55rem", fontWeight: 700,
|
||||||
|
padding: "0.12em 0.4em", borderRadius: "9999px",
|
||||||
|
background: "color-mix(in hsl, var(--c-lines), transparent 70%)",
|
||||||
|
color: "var(--c-lines)", letterSpacing: "0.05em",
|
||||||
|
}}>
|
||||||
|
exp
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
fontSize: "2.8rem",
|
||||||
|
color: tech.color,
|
||||||
|
filter: `drop-shadow(0 0 0.5rem color-mix(in hsl, ${tech.color}, transparent 60%))`,
|
||||||
|
lineHeight: 1,
|
||||||
|
transition: "filter 0.2s ease, transform 0.2s ease",
|
||||||
|
}}>
|
||||||
|
{tech.icon ?? (
|
||||||
|
<span style={{ fontSize: "1.1rem", fontWeight: 900, color: tech.color, letterSpacing: "-0.02em" }}>
|
||||||
|
{tech.label.split(".")[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
color: "color-mix(in hsl, var(--c-text), transparent 35%)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{tech.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline styles for hover pause + item hover */}
|
||||||
|
<style>{`
|
||||||
|
.marquee-track-wrapper:hover .marquee-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
.tech-item:hover {
|
||||||
|
transform: translateY(-6px) scale(1.15);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user