From 3766b033bc3c955e6a0e34a6f3962d489499b513 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sun, 7 Jun 2026 22:08:59 +0200 Subject: [PATCH] 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. --- .../src/components/home/hero/HeroSection.tsx | 230 +++++++++--------- .../components/home/projects/DemoModal.tsx | 21 +- .../home/projects/ProjectsSection.tsx | 148 +++++++++++ .../src/components/home/tech/TechMarquee.tsx | 167 +++++++------ 4 files changed, 358 insertions(+), 208 deletions(-) diff --git a/frontend/src/components/home/hero/HeroSection.tsx b/frontend/src/components/home/hero/HeroSection.tsx index 34f5f5c..5b64dfc 100644 --- a/frontend/src/components/home/hero/HeroSection.tsx +++ b/frontend/src/components/home/hero/HeroSection.tsx @@ -5,190 +5,186 @@ import { FaChevronDown } from "react-icons/fa"; const ROLES = ["Web Developer", "Drone Pilot", "Systems Architect", "Real-Time Engineer"]; -const PARTICLES = Array.from({ length: 10 }, (_, i) => ({ - id: i, - left: `${10 + Math.random() * 80}%`, - top: `${10 + Math.random() * 75}%`, - size: `${4 + Math.random() * 8}px`, - delay: `${Math.random() * 4}s`, - duration: `${4 + Math.random() * 4}s`, -})); +// Fixed seed so no hydration mismatch & no layout shift on re-render +const PARTICLES = [ + { id: 0, left: "12%", top: "18%", size: 5, delay: 0, dur: 5.2 }, + { id: 1, left: "82%", top: "14%", size: 7, delay: 0.8, dur: 6.1 }, + { id: 2, left: "25%", top: "72%", size: 4, delay: 1.5, dur: 4.8 }, + { id: 3, left: "68%", top: "55%", size: 9, delay: 0.3, dur: 7.0 }, + { 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) => ({ - initial: { opacity: 0, y: 30 }, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0.7, ease: "easeOut", delay }, -}); +const stagger = { + container: { + animate: { transition: { staggerChildren: 0.18 } }, + }, + item: { + initial: { opacity: 0, y: 28 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.65, ease: "easeOut" } }, + }, +}; export default function HeroSection() { - const [roleIdx, setRoleIdx] = useState(0); + const [roleIdx, setRoleIdx] = useState(0); const [displayed, setDisplayed] = useState(""); - const [typing, setTyping] = useState(true); + const [typing, setTyping] = useState(true); useEffect(() => { const target = ROLES[roleIdx]; let i = typing ? 0 : target.length; - const speed = typing ? 55 : 30; const timer = setInterval(() => { if (typing) { i++; setDisplayed(target.slice(0, i)); - if (i >= target.length) { - clearInterval(timer); - setTimeout(() => setTyping(false), 1800); - } + if (i >= target.length) { clearInterval(timer); setTimeout(() => setTyping(false), 1800); } } else { i--; setDisplayed(target.slice(0, i)); - if (i <= 0) { - clearInterval(timer); - setRoleIdx((prev) => (prev + 1) % ROLES.length); - setTyping(true); - } + if (i <= 0) { clearInterval(timer); setRoleIdx(p => (p + 1) % ROLES.length); setTyping(true); } } - }, speed); + }, typing ? 55 : 28); return () => clearInterval(timer); }, [roleIdx, typing]); return ( -
- {/* Video background */} -
diff --git a/frontend/src/components/home/projects/DemoModal.tsx b/frontend/src/components/home/projects/DemoModal.tsx index 415c964..a4bc441 100644 --- a/frontend/src/components/home/projects/DemoModal.tsx +++ b/frontend/src/components/home/projects/DemoModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "framer-motion"; 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) { - // Close on Escape useEffect(() => { if (!opened) return; 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); }, [opened, onClose]); - // Prevent body scroll when open useEffect(() => { document.body.style.overflow = opened ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [opened]); - return ( + return createPortal( {opened && ( <> @@ -80,8 +79,8 @@ export default function DemoModal({ opened, onClose }: Props) { transition={{ duration: 0.2 }} onClick={onClose} style={{ - position: "fixed", inset: 0, zIndex: 1000, - background: "rgba(3,29,68,0.75)", + position: "fixed", inset: 0, zIndex: 9000, + background: "rgba(3,29,68,0.78)", backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)", }} @@ -90,19 +89,20 @@ export default function DemoModal({ opened, onClose }: Props) { {/* Panel */} @@ -177,6 +177,7 @@ export default function DemoModal({ opened, onClose }: Props) { )} - + , + document.body, ); } diff --git a/frontend/src/components/home/projects/ProjectsSection.tsx b/frontend/src/components/home/projects/ProjectsSection.tsx index 5734ad3..0970b8b 100644 --- a/frontend/src/components/home/projects/ProjectsSection.tsx +++ b/frontend/src/components/home/projects/ProjectsSection.tsx @@ -230,6 +230,154 @@ export default function ProjectsSection() { + + {/* Client websites divider */} + +
+ + Client Websites + +
+ + + + {/* davo1.cz — advertising firm, white+gold logo → dark bg */} + +
+ + {/* Logo preview — dark so white+gold shows */} +
+ davo1.cz logo +
+ + + + + {/* Epinger — fences & gates, black+orange logo → white bg */} + +
+ + {/* Logo preview — white so black text shows */} +
+ Epinger logo +
+ +
+
+

Epinger — Vstupní systémy

+ Construction & access systems +
+ +

+ Business website for a company specializing in electronic entry systems, automated gates, and classic fencing — product catalog with inquiry forms. +

+ +
+ + + +
+ + +
+ +
setDemoOpen(false)} /> diff --git a/frontend/src/components/home/tech/TechMarquee.tsx b/frontend/src/components/home/tech/TechMarquee.tsx index 9e682e7..27f7c75 100644 --- a/frontend/src/components/home/tech/TechMarquee.tsx +++ b/frontend/src/components/home/tech/TechMarquee.tsx @@ -6,68 +6,27 @@ import { import { FaBrain } from "react-icons/fa"; const TECHS = [ - { icon: , label: "Docker", color: "#2496ed" }, - { icon: , label: "Nginx", color: "#009900" }, - { icon: , label: "Python", color: "#3776ab" }, - { icon: , label: "Django", color: "#09d3ac" }, - { icon: , label: "React", color: "#61dafb" }, - { icon: , label: "Debian", color: "#a80030" }, + { icon: , label: "Docker", color: "#2496ed" }, + { icon: , label: "Nginx", color: "#009900" }, + { icon: , label: "Python", color: "#3776ab" }, + { icon: , label: "Django", color: "#09d3ac" }, + { icon: , label: "React", color: "#61dafb" }, + { icon: , label: "Debian", color: "#d70a53" }, { icon: , label: "PostgreSQL", color: "#336791" }, - { icon: , label: "Redis", color: "#ff4438" }, - { icon: , label: "Celery", color: "#37b24d" }, - { icon: null, label: "Gorse.io", color: "var(--c-other)" }, - { icon: , label: "Ollama", color: "var(--c-lines)", experimental: true }, + { icon: , label: "Redis", color: "#ff4438" }, + { icon: , label: "Celery", color: "#37b24d" }, + { icon: null, label: "Gorse.io", color: "var(--c-other)" }, + { icon: , label: "Ollama", color: "var(--c-lines)", experimental: true }, ]; -function TechItem({ icon, label, color, experimental }: { icon: React.ReactNode; label: string; color: string; experimental?: boolean }) { - return ( - - {experimental && ( - - exp - - )} -
- {icon ?? {label.split(".")[0]}} -
- - {label} - -
- ); -} +// Four copies so the loop is seamless at any viewport width +const TRACK = [...TECHS, ...TECHS, ...TECHS, ...TECHS]; export default function TechMarquee() { - const doubled = [...TECHS, ...TECHS]; - return ( -
-
+
+ {/* Header */} +
- {/* Fade edges */} + {/* Marquee */}
-
-
+ {/* Fade edges */} +
+
- {/* Marquee track */} + {/* Track wrapper — pause on hover */}
{ - const el = e.currentTarget.firstElementChild as HTMLElement; - if (el) el.style.animationPlayState = "paused"; - }} - onMouseLeave={(e) => { - const el = e.currentTarget.firstElementChild as HTMLElement; - if (el) el.style.animationPlayState = "running"; - }} + className="marquee-track-wrapper" + style={{ overflow: "hidden", width: "100%" }} > + {/* Scrolling track — CSS animation, no Framer Motion here */}
- {doubled.map((tech, i) => ( - + {TRACK.map((tech, i) => ( +
+ {tech.experimental && ( + + exp + + )} +
+ {tech.icon ?? ( + + {tech.label.split(".")[0]} + + )} +
+ + {tech.label} + +
))}
+ + {/* Inline styles for hover pause + item hover */} +
); }