Prototyp fur die Navigationsleiste
Kleine anpassuungen Handy Ansicht
This commit is contained in:
1684
components/HandyAnsicht.css
Normal file
1684
components/HandyAnsicht.css
Normal file
File diff suppressed because it is too large
Load Diff
321
components/HandyAnsicht.tsx
Normal file
321
components/HandyAnsicht.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './HandyAnsicht.css';
|
||||
|
||||
interface HandyAnsichtProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
type GooeyNavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
type?: 'link' | 'logo' | 'menu';
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
|
||||
interface GooeyNavProps {
|
||||
items: GooeyNavItem[];
|
||||
animationTime?: number;
|
||||
particleCount?: number;
|
||||
particleDistances?: [number, number];
|
||||
particleR?: number;
|
||||
timeVariance?: number;
|
||||
colors?: number[];
|
||||
initialActiveIndex?: number;
|
||||
}
|
||||
|
||||
const GooeyNav: React.FC<GooeyNavProps> = ({
|
||||
items,
|
||||
animationTime = 600,
|
||||
particleCount = 15,
|
||||
particleDistances = [90, 10],
|
||||
particleR = 100,
|
||||
timeVariance = 300,
|
||||
colors = [1, 2, 3, 1, 2, 3, 1, 4],
|
||||
initialActiveIndex = 0,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLUListElement | null>(null);
|
||||
const filterRef = useRef<HTMLSpanElement | null>(null);
|
||||
const textRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(initialActiveIndex);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const noise = (n = 1) => n / 2 - Math.random() * n;
|
||||
|
||||
const getXY = (distance: number, pointIndex: number, totalPoints: number) => {
|
||||
const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180);
|
||||
return [distance * Math.cos(angle), distance * Math.sin(angle)];
|
||||
};
|
||||
|
||||
const createParticle = (i: number, t: number, d: [number, number], r: number) => {
|
||||
const rotate = noise(r / 10);
|
||||
return {
|
||||
start: getXY(d[0], particleCount - i, particleCount),
|
||||
end: getXY(d[1] + noise(7), particleCount - i, particleCount),
|
||||
time: t,
|
||||
scale: 1 + noise(0.2),
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10,
|
||||
};
|
||||
};
|
||||
|
||||
const makeParticles = (element: HTMLElement) => {
|
||||
const d = particleDistances;
|
||||
const r = particleR;
|
||||
const bubbleTime = animationTime * 2 + timeVariance;
|
||||
element.style.setProperty('--time', `${bubbleTime}ms`);
|
||||
|
||||
for (let i = 0; i < particleCount; i += 1) {
|
||||
const t = animationTime * 2 + noise(timeVariance * 2);
|
||||
const p = createParticle(i, t, d, r);
|
||||
element.classList.remove('active');
|
||||
|
||||
setTimeout(() => {
|
||||
const particle = document.createElement('span');
|
||||
const point = document.createElement('span');
|
||||
particle.classList.add('particle');
|
||||
particle.style.setProperty('--start-x', `${p.start[0]}px`);
|
||||
particle.style.setProperty('--start-y', `${p.start[1]}px`);
|
||||
particle.style.setProperty('--end-x', `${p.end[0]}px`);
|
||||
particle.style.setProperty('--end-y', `${p.end[1]}px`);
|
||||
particle.style.setProperty('--time', `${p.time}ms`);
|
||||
particle.style.setProperty('--scale', `${p.scale}`);
|
||||
particle.style.setProperty('--color', `var(--color-${p.color}, white)`);
|
||||
particle.style.setProperty('--rotate', `${p.rotate}deg`);
|
||||
|
||||
point.classList.add('point');
|
||||
particle.appendChild(point);
|
||||
element.appendChild(particle);
|
||||
requestAnimationFrame(() => element.classList.add('active'));
|
||||
setTimeout(() => {
|
||||
try {
|
||||
element.removeChild(particle);
|
||||
} catch {
|
||||
/* ignore removal errors */
|
||||
}
|
||||
}, t);
|
||||
}, 30);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEffectPosition = (element: HTMLLIElement) => {
|
||||
if (!containerRef.current || !filterRef.current || !textRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const pos = element.getBoundingClientRect();
|
||||
const styles: Partial<CSSStyleDeclaration> = {
|
||||
left: `${pos.x - containerRect.x}px`,
|
||||
top: `${pos.y - containerRect.y}px`,
|
||||
width: `${pos.width}px`,
|
||||
height: `${pos.height}px`,
|
||||
};
|
||||
Object.assign(filterRef.current.style, styles);
|
||||
Object.assign(textRef.current.style, styles);
|
||||
// Nur Text kopieren wenn es kein Logo und kein Menü-Button ist
|
||||
if (!element.classList.contains('nav-item-logo') && !element.classList.contains('nav-item-menu')) {
|
||||
textRef.current.innerText = element.innerText;
|
||||
} else {
|
||||
textRef.current.innerText = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, index: number, item: GooeyNavItem) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (item.type === 'logo') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setIsMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'menu') {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
const liEl = e.currentTarget.parentElement as HTMLLIElement | null;
|
||||
if (!liEl || activeIndex === index) return;
|
||||
|
||||
// Normal navigation
|
||||
window.location.href = item.href;
|
||||
setIsMenuOpen(false); // Menü schließen nach Klick
|
||||
|
||||
setActiveIndex(index);
|
||||
updateEffectPosition(liEl);
|
||||
|
||||
if (filterRef.current) {
|
||||
const particles = filterRef.current.querySelectorAll('.particle');
|
||||
particles.forEach((p) => filterRef.current?.removeChild(p));
|
||||
}
|
||||
|
||||
if (textRef.current) {
|
||||
textRef.current.classList.remove('active');
|
||||
void textRef.current.offsetWidth; // force reflow
|
||||
textRef.current.classList.add('active');
|
||||
}
|
||||
|
||||
if (filterRef.current) {
|
||||
makeParticles(filterRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>, index: number, item: GooeyNavItem) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const liEl = e.currentTarget.parentElement as HTMLLIElement | null;
|
||||
if (liEl) {
|
||||
handleClick({ currentTarget: e.currentTarget, preventDefault: () => {} } as React.MouseEvent<HTMLAnchorElement>, index, item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!navRef.current || !containerRef.current) return;
|
||||
const activeLi = navRef.current.querySelectorAll('li')[activeIndex] as HTMLLIElement | undefined;
|
||||
if (activeLi && !activeLi.classList.contains('nav-item-logo') && !activeLi.classList.contains('nav-item-menu')) {
|
||||
updateEffectPosition(activeLi);
|
||||
textRef.current?.classList.add('active');
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const currentActiveLi = navRef.current?.querySelectorAll('li')[activeIndex] as HTMLLIElement | undefined;
|
||||
if (currentActiveLi && !currentActiveLi.classList.contains('nav-item-logo') && !currentActiveLi.classList.contains('nav-item-menu')) {
|
||||
updateEffectPosition(currentActiveLi);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeIndex]);
|
||||
|
||||
// Modifizierte Items für die Anzeige (Logo + Menu Button im geschlossenen Zustand)
|
||||
const displayItems = isMenuOpen
|
||||
? items // Zeige alle Items wenn offen (muss im CSS geregelt werden, dass sie untereinander oder anders dargestellt werden)
|
||||
: [items.find(i => i.type === 'logo')!, { label: 'Menu', href: '#', type: 'menu' } as GooeyNavItem];
|
||||
|
||||
return (
|
||||
<div className={`gooey-nav-container ${items.length > 4 ? 'has-contact' : ''} ${isMenuOpen ? 'menu-open' : ''}`} ref={containerRef}>
|
||||
<nav>
|
||||
<ul ref={navRef}>
|
||||
{/* Logo immer anzeigen */}
|
||||
{items.filter(i => i.type === 'logo').map((item, index) => (
|
||||
<li
|
||||
key={item.href}
|
||||
className={`nav-item-logo`}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => handleClick(e, -1, item)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<img src={item.icon} alt={item.label} className="nav-logo-img" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Menü Button (Hamburger) */}
|
||||
<li className="nav-item-menu" style={{ marginLeft: 'auto', cursor: 'pointer' }}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => handleClick(e, -1, { label: 'Menu', href: '#', type: 'menu' })}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{isMenuOpen ? <X size={24} color="white" /> : <Menu size={24} color="white" />}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Dropdown / Expanded Menu Items - Außerhalb von nav, damit position: fixed relativ zum Viewport ist */}
|
||||
<div className={`mobile-menu-items ${isMenuOpen ? 'open' : ''}`}>
|
||||
<ul>
|
||||
{items.filter(i => i.type !== 'logo' && i.type !== 'menu').map((item, index) => (
|
||||
<li key={item.href} onClick={() => {
|
||||
window.location.href = item.href;
|
||||
setIsMenuOpen(false);
|
||||
}}>
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Effect nur für Inline-Items, hier deaktiviert da wir auf Overlay umstellen */}
|
||||
{/* <span className="effect filter" ref={filterRef} />
|
||||
<span className="effect text" ref={textRef} /> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const allGooeyItems: GooeyNavItem[] = [
|
||||
{ label: 'Home', href: '#', type: 'logo', icon: '/WebKlarLogo.png' },
|
||||
{ label: 'Über uns', href: '#about' },
|
||||
{ label: 'Leistungen', href: '#services' },
|
||||
{ label: 'Abläufe', href: '#process' },
|
||||
{ label: 'Kontakt', href: '#contact' },
|
||||
];
|
||||
|
||||
const HandyAnsicht: React.FC<HandyAnsichtProps> = ({ children }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 767px)');
|
||||
const handleMediaChange = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
setIsMobile(e.matches);
|
||||
};
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
} else {
|
||||
mediaQuery.addListener(handleMediaChange);
|
||||
}
|
||||
|
||||
handleMediaChange(mediaQuery);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
} else {
|
||||
mediaQuery.removeListener(handleMediaChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Immer alle Items anzeigen, Kontakt ist sofort da
|
||||
const currentItems = allGooeyItems;
|
||||
|
||||
if (!isClient || !isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="handy-ansicht">
|
||||
<div className="handy-ansicht__container">
|
||||
<div className="handy-ansicht__nav-wrapper">
|
||||
<GooeyNav items={currentItems} />
|
||||
</div>
|
||||
<div className="handy-ansicht__content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HandyAnsicht;
|
||||
|
||||
Reference in New Issue
Block a user