feat(portfolio): optimize navbar and experience section for mobile
Task: Seite für Besucher auf Mobilgeräten optimieren (#7) ## Desktop navbar - Added "Mehr" dropdown grouping secondary links (Kompetenzen, Stärken, Skills) to keep the primary nav bar uncluttered (Projekte, Erfahrung, Über mich stay as primary links) - Dropdown is click-activated with chevron indicator and closes on outside click; all links include data-testid attributes - No link wrapping possible with this structure on any common desktop width ## Mobile menu - Replaced flat link list with two logical section groups: - "Profil": Über mich, Kompetenzen, Stärken - "Erfahrung & Skills": Projekte, Erfahrung, Skills - Each group has a small uppercase label as a visual divider - Kontakt CTA remains at the bottom separated by a border - All 6 page sections are now reachable from the mobile menu ## Timeline cards (experience.tsx) - Shifted timeline spine from left-6 to left-5 freeing 4px of card width - Reduced left padding from pl-14 to pl-11 on mobile, giving ~12px extra card width — critical at 320–375px viewports - Added p-3.5 sm:p-5 so cards use tighter inner padding on very small screens (< 640px) and normal padding on larger ones - Added break-words + min-w-0 to role, client, and task text to prevent horizontal overflow from long German compound words - Wrapped task text in a <span> so flex layout doesn't clip overflow Replit-Task-Id: 51a5a369-3f27-4245-903a-4dc59ee1a842 ## No content changes — all section copy, order, and data is unchanged
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
@@ -142,7 +142,7 @@ export function Experience() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-3xl mx-auto">
|
<div className="relative max-w-3xl mx-auto">
|
||||||
<div className="absolute left-6 top-0 bottom-0 w-px bg-border md:left-1/2" />
|
<div className="absolute left-5 top-0 bottom-0 w-px bg-border md:left-1/2" />
|
||||||
|
|
||||||
{stations.map((station, index) => {
|
{stations.map((station, index) => {
|
||||||
const cfg = typeConfig[station.type];
|
const cfg = typeConfig[station.type];
|
||||||
@@ -156,36 +156,34 @@ export function Experience() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-60px" }}
|
viewport={{ once: true, margin: "-60px" }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.08 }}
|
transition={{ duration: 0.5, delay: index * 0.08 }}
|
||||||
className={`relative flex gap-6 mb-10 md:mb-12 ${
|
className={`relative flex gap-6 mb-8 md:mb-12 ${
|
||||||
isLeft ? "md:flex-row" : "md:flex-row-reverse"
|
isLeft ? "md:flex-row" : "md:flex-row-reverse"
|
||||||
}`}
|
}`}
|
||||||
data-testid={`card-experience-${index}`}
|
data-testid={`card-experience-${index}`}
|
||||||
>
|
>
|
||||||
<div className="absolute left-6 -translate-x-1/2 md:left-1/2 z-10">
|
<div className="absolute left-5 -translate-x-1/2 md:left-1/2 z-10">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm ${cfg.dot}`}
|
className={`w-4 h-4 rounded-full border-2 border-white shadow-sm ${cfg.dot}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`pl-14 md:pl-0 w-full md:w-[calc(50%-2rem)] ${isLeft ? "md:pr-8 md:text-right" : "md:pl-8"}`}>
|
<div className={`pl-11 md:pl-0 w-full md:w-[calc(50%-2rem)] ${isLeft ? "md:pr-8 md:text-right" : "md:pl-8"}`}>
|
||||||
<div
|
<div className="p-3.5 sm:p-5 rounded-2xl bg-white border border-border card-hover">
|
||||||
className={`p-5 rounded-2xl bg-white border border-border card-hover`}
|
<div className={`flex items-start gap-2 mb-3 ${isLeft ? "md:flex-row-reverse" : ""}`}>
|
||||||
>
|
<div className={`w-7 h-7 rounded-lg border flex items-center justify-center shrink-0 ${cfg.badge}`}>
|
||||||
<div className={`flex items-start gap-3 mb-3 ${isLeft ? "md:flex-row-reverse" : ""}`}>
|
<Icon className="w-3.5 h-3.5" />
|
||||||
<div className={`w-8 h-8 rounded-lg border flex items-center justify-center shrink-0 ${cfg.badge}`}>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className={isLeft ? "md:text-right" : ""}>
|
<div className={`min-w-0 flex-1 ${isLeft ? "md:text-right" : ""}`}>
|
||||||
<p className="text-xs font-semibold text-muted-foreground mb-0.5">
|
<p className="text-xs font-semibold text-muted-foreground mb-0.5 tabular-nums">
|
||||||
{station.period}
|
{station.period}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-bold text-foreground leading-tight">
|
<p className="text-sm font-bold text-foreground leading-tight break-words">
|
||||||
{station.role}
|
{station.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={`text-xs font-medium text-primary mb-3 ${isLeft ? "md:text-right" : ""}`}>
|
<p className={`text-xs font-medium text-primary mb-3 break-words ${isLeft ? "md:text-right" : ""}`}>
|
||||||
{station.client}
|
{station.client}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -198,8 +196,8 @@ export function Experience() {
|
|||||||
}`}
|
}`}
|
||||||
data-testid={`item-experience-${index}-${tIndex}`}
|
data-testid={`item-experience-${index}-${tIndex}`}
|
||||||
>
|
>
|
||||||
<span className={`w-1 h-1 rounded-full bg-muted-foreground/40 mt-1.5 shrink-0`} />
|
<span className="w-1 h-1 rounded-full bg-muted-foreground/40 mt-1.5 shrink-0" />
|
||||||
{task}
|
<span className="break-words min-w-0">{task}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { motion, useScroll, useMotionValueEvent } from "framer-motion";
|
import { motion, useScroll, useMotionValueEvent } from "framer-motion";
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
const primaryLinks = [
|
||||||
|
{ name: "Projekte", href: "#projects" },
|
||||||
|
{ name: "Erfahrung", href: "#experience" },
|
||||||
|
{ name: "Über mich", href: "#bio" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondaryLinks = [
|
||||||
|
{ name: "Kompetenzen", href: "#competencies" },
|
||||||
|
{ name: "Stärken", href: "#strengths" },
|
||||||
|
{ name: "Skills", href: "#skills" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileGroups = [
|
||||||
|
{
|
||||||
|
label: "Profil",
|
||||||
|
links: [
|
||||||
|
{ name: "Über mich", href: "#bio" },
|
||||||
|
{ name: "Kompetenzen", href: "#competencies" },
|
||||||
|
{ name: "Stärken", href: "#strengths" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Erfahrung & Skills",
|
||||||
|
links: [
|
||||||
|
{ name: "Projekte", href: "#projects" },
|
||||||
|
{ name: "Erfahrung", href: "#experience" },
|
||||||
|
{ name: "Skills", href: "#skills" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const { scrollY } = useScroll();
|
const { scrollY } = useScroll();
|
||||||
|
|
||||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||||
setIsScrolled(latest > 20);
|
setIsScrolled(latest > 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
const navLinks = [
|
useEffect(() => {
|
||||||
{ name: "Projekte", href: "#projects" },
|
function handleClickOutside(e: MouseEvent) {
|
||||||
{ name: "Erfahrung", href: "#experience" },
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
{ name: "Über mich", href: "#bio" },
|
setDropdownOpen(false);
|
||||||
];
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.header
|
<motion.header
|
||||||
@@ -41,7 +78,7 @@ export function Navbar() {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
{navLinks.map((link) => (
|
{primaryLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.name}
|
key={link.name}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -51,6 +88,41 @@ export function Navbar() {
|
|||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-all"
|
||||||
|
data-testid="button-nav-mehr"
|
||||||
|
aria-expanded={dropdownOpen}
|
||||||
|
>
|
||||||
|
Mehr
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${dropdownOpen ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute right-0 top-full mt-1.5 w-40 bg-white border border-border rounded-xl shadow-lg py-1.5"
|
||||||
|
>
|
||||||
|
{secondaryLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
className="block px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
|
||||||
|
data-testid={`link-nav-${link.name.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
@@ -76,25 +148,37 @@ export function Navbar() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -8 }}
|
initial={{ opacity: 0, y: -8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="md:hidden bg-white border-t border-border px-4 py-4 flex flex-col gap-1"
|
className="md:hidden bg-white border-t border-border px-4 py-4 flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{navLinks.map((link) => (
|
{mobileGroups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<p className="px-4 mb-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{group.links.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.name}
|
key={link.name}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-all"
|
className="px-4 py-2.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="pt-1 border-t border-border">
|
||||||
<a
|
<a
|
||||||
href="#contact"
|
href="#contact"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="mt-2 px-4 py-3 bg-primary text-white text-sm font-semibold rounded-lg text-center"
|
className="block px-4 py-3 bg-primary text-white text-sm font-semibold rounded-lg text-center"
|
||||||
>
|
>
|
||||||
Kontakt aufnehmen
|
Kontakt aufnehmen
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.header>
|
</motion.header>
|
||||||
|
|||||||
Reference in New Issue
Block a user