last update
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-07-14 20:10:38 +02:00
parent cc6db94977
commit 1a7b3397f6
14 changed files with 847 additions and 146 deletions
+397
View File
@@ -0,0 +1,397 @@
"use client";
import {
useRef,
useEffect,
useState,
type ReactNode,
type CSSProperties,
} from "react";
import { gsap } from "gsap";
const DEFAULT_PARTICLE_COUNT = 12;
const DEFAULT_SPOTLIGHT_RADIUS = 400;
const DEFAULT_GLOW_COLOR = "220, 68, 22";
const MOBILE_BREAKPOINT = 768;
/* ---------- types ---------- */
type CardData = {
title: string;
description: string;
label: string;
children?: ReactNode;
};
type MagicBentoSectionProps = {
eyebrow: string;
heading: string;
description: string;
cards: CardData[];
glowColor?: string;
spotlightRadius?: number;
particleCount?: number;
enableStars?: boolean;
enableSpotlight?: boolean;
enableBorderGlow?: boolean;
enableTilt?: boolean;
enableMagnetism?: boolean;
clickEffect?: boolean;
textAutoHide?: boolean;
};
/* ---------- helpers ---------- */
function useMobileDetection() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
return isMobile;
}
function calculateSpotlightValues(radius: number) {
return { proximity: radius * 0.5, fadeDistance: radius * 0.75 };
}
function updateCardGlow(
card: HTMLElement,
mx: number,
my: number,
glow: number,
radius: number,
) {
const r = card.getBoundingClientRect();
card.style.setProperty("--glow-x", `${((mx - r.left) / r.width) * 100}%`);
card.style.setProperty("--glow-y", `${((my - r.top) / r.height) * 100}%`);
card.style.setProperty("--glow-intensity", glow.toString());
card.style.setProperty("--glow-radius", `${radius}px`);
}
/* ---------- ParticleCard ---------- */
function ParticleCard({
children,
className = "",
style,
disabled,
particleCount,
glowColor,
enableTilt,
enableMagnetism,
clickEffect,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
disabled: boolean;
particleCount: number;
glowColor: string;
enableTilt: boolean;
enableMagnetism: boolean;
clickEffect: boolean;
}) {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (disabled || !cardRef.current) return;
const el = cardRef.current;
const particles: HTMLDivElement[] = [];
const timeouts: ReturnType<typeof setTimeout>[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let magnetAnim: any = null;
let hovered = false;
const spawnParticles = () => {
const { width, height } = el.getBoundingClientRect();
for (let i = 0; i < particleCount; i++) {
const tid = setTimeout(() => {
if (!hovered) return;
const p = document.createElement("div");
p.style.cssText = `
position:absolute;width:4px;height:4px;border-radius:50%;
background:rgba(${glowColor},0.9);
box-shadow:0 0 6px rgba(${glowColor},0.5);
pointer-events:none;z-index:100;
left:${Math.random() * width}px;top:${Math.random() * height}px;
`;
el.appendChild(p);
particles.push(p);
gsap.fromTo(p, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" });
gsap.to(p, { x: (Math.random() - 0.5) * 100, y: (Math.random() - 0.5) * 100, rotation: Math.random() * 360, duration: 2 + Math.random() * 2, ease: "none", repeat: -1, yoyo: true });
gsap.to(p, { opacity: 0.3, duration: 1.5, ease: "power2.inOut", repeat: -1, yoyo: true });
}, i * 100);
timeouts.push(tid);
}
};
const clearParticles = () => {
timeouts.forEach(clearTimeout);
timeouts.length = 0;
magnetAnim?.kill();
particles.forEach((p) =>
gsap.to(p, { scale: 0, opacity: 0, duration: 0.3, ease: "back.in(1.7)", onComplete: () => p.remove() }),
);
particles.length = 0;
};
const onEnter = () => {
hovered = true;
spawnParticles();
if (enableTilt) {
gsap.to(el, { rotateX: 5, rotateY: 5, duration: 0.3, ease: "power2.out", transformPerspective: 1000 });
}
};
const onLeave = () => {
hovered = false;
clearParticles();
if (enableTilt) gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.3, ease: "power2.out" });
if (enableMagnetism) gsap.to(el, { x: 0, y: 0, duration: 0.3, ease: "power2.out" });
};
const onMove = (e: MouseEvent) => {
if (!enableTilt && !enableMagnetism) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
if (enableTilt) {
gsap.to(el, { rotateX: ((y - cy) / cy) * -10, rotateY: ((x - cx) / cx) * 10, duration: 0.1, ease: "power2.out", transformPerspective: 1000 });
}
if (enableMagnetism) {
magnetAnim = gsap.to(el, { x: (x - cx) * 0.05, y: (y - cy) * 0.05, duration: 0.3, ease: "power2.out" });
}
};
const onClick = (e: MouseEvent) => {
if (!clickEffect) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const maxD = Math.max(
Math.hypot(x, y),
Math.hypot(x - rect.width, y),
Math.hypot(x, y - rect.height),
Math.hypot(x - rect.width, y - rect.height),
);
const ripple = document.createElement("div");
ripple.style.cssText = `
position:absolute;width:${maxD * 2}px;height:${maxD * 2}px;border-radius:50%;
background:radial-gradient(circle,rgba(${glowColor},0.4) 0%,rgba(${glowColor},0.2) 30%,transparent 70%);
left:${x - maxD}px;top:${y - maxD}px;pointer-events:none;z-index:1000;
`;
el.appendChild(ripple);
gsap.fromTo(ripple, { scale: 0, opacity: 1 }, { scale: 1, opacity: 0, duration: 0.8, ease: "power2.out", onComplete: () => ripple.remove() });
};
el.addEventListener("mouseenter", onEnter);
el.addEventListener("mouseleave", onLeave);
el.addEventListener("mousemove", onMove);
el.addEventListener("click", onClick);
return () => {
hovered = false;
el.removeEventListener("mouseenter", onEnter);
el.removeEventListener("mouseleave", onLeave);
el.removeEventListener("mousemove", onMove);
el.removeEventListener("click", onClick);
clearParticles();
};
}, [disabled, particleCount, glowColor, enableTilt, enableMagnetism, clickEffect]);
return (
<div ref={cardRef} className={className} style={{ ...style, position: "relative", overflow: "hidden" }}>
{children}
</div>
);
}
/* ---------- GlobalSpotlight ---------- */
function GlobalSpotlight({
gridRef,
disabled,
spotlightRadius,
glowColor,
}: {
gridRef: React.RefObject<HTMLDivElement | null>;
disabled: boolean;
spotlightRadius: number;
glowColor: string;
}) {
useEffect(() => {
if (disabled || !gridRef.current) return;
const spotlight = document.createElement("div");
spotlight.style.cssText = `
position:fixed;width:800px;height:800px;border-radius:50%;pointer-events:none;
background:radial-gradient(circle,
rgba(${glowColor},0.15) 0%,rgba(${glowColor},0.08) 15%,
rgba(${glowColor},0.04) 25%,rgba(${glowColor},0.02) 40%,
rgba(${glowColor},0.01) 65%,transparent 70%);
z-index:200;opacity:0;transform:translate(-50%,-50%);
mix-blend-mode:screen;will-change:transform,opacity;
`;
document.body.appendChild(spotlight);
const grid = gridRef.current;
const onMove = (e: MouseEvent) => {
const rect = grid.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
const cards = grid.querySelectorAll<HTMLElement>(".mb-card");
if (!inside) {
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
cards.forEach((c) => c.style.setProperty("--glow-intensity", "0"));
return;
}
const { proximity, fadeDistance } = calculateSpotlightValues(spotlightRadius);
let minDist = Infinity;
cards.forEach((card) => {
const cr = card.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
const dist = Math.max(0, Math.hypot(e.clientX - cx, e.clientY - cy) - Math.max(cr.width, cr.height) / 2);
minDist = Math.min(minDist, dist);
const intensity = dist <= proximity ? 1 : dist <= fadeDistance ? (fadeDistance - dist) / (fadeDistance - proximity) : 0;
updateCardGlow(card, e.clientX, e.clientY, intensity, spotlightRadius);
});
gsap.to(spotlight, { left: e.clientX, top: e.clientY, duration: 0.1, ease: "power2.out" });
const opacity = minDist <= proximity ? 0.8 : minDist <= fadeDistance ? ((fadeDistance - minDist) / (fadeDistance - proximity)) * 0.8 : 0;
gsap.to(spotlight, { opacity, duration: opacity > 0 ? 0.2 : 0.5, ease: "power2.out" });
};
const onLeave = () => {
grid.querySelectorAll<HTMLElement>(".mb-card").forEach((c) => c.style.setProperty("--glow-intensity", "0"));
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseleave", onLeave);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseleave", onLeave);
spotlight.remove();
};
}, [disabled, gridRef, spotlightRadius, glowColor]);
return null;
}
/* ---------- MagicBentoSection ---------- */
export function MagicBentoSection({
eyebrow,
heading,
description,
cards,
glowColor = DEFAULT_GLOW_COLOR,
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
particleCount = DEFAULT_PARTICLE_COUNT,
enableStars = true,
enableSpotlight = true,
enableBorderGlow = true,
enableTilt = true,
enableMagnetism = true,
clickEffect = true,
textAutoHide = true,
}: MagicBentoSectionProps) {
const gridRef = useRef<HTMLDivElement>(null);
const isMobile = useMobileDetection();
const disabled = isMobile;
return (
<section className="magic-bento-wrapper space-y-8">
{/* Section heading — matches project's SectionHeading pattern */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</p>
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-[-0.05em] text-foreground sm:text-3xl">
{heading}
</h2>
<p className="max-w-2xl text-sm leading-7 text-muted-foreground">
{description}
</p>
</div>
</div>
{enableSpotlight && (
<GlobalSpotlight
gridRef={gridRef}
disabled={disabled}
spotlightRadius={spotlightRadius}
glowColor={glowColor}
/>
)}
<div ref={gridRef} className="mb-grid" style={{ userSelect: "none" }}>
{cards.map((card, index) => {
const cls = [
"mb-card",
textAutoHide ? "mb-card--autohide" : "",
enableBorderGlow ? "mb-card--glow" : "",
]
.filter(Boolean)
.join(" ");
const cardStyle: CSSProperties = {
"--glow-color": glowColor,
} as CSSProperties;
const content = (
<>
<div className="mb-card__header">
<span className="mb-card__label">{card.label}</span>
</div>
<div className="mb-card__body">
<h3 className="mb-card__title">{card.title}</h3>
{card.description && (
<p className="mb-card__desc">{card.description}</p>
)}
{card.children && (
<div className="mb-card__extra">{card.children}</div>
)}
</div>
</>
);
if (enableStars) {
return (
<ParticleCard
key={index}
className={cls}
style={cardStyle}
disabled={disabled}
particleCount={particleCount}
glowColor={glowColor}
enableTilt={enableTilt}
enableMagnetism={enableMagnetism}
clickEffect={clickEffect}
>
{content}
</ParticleCard>
);
}
return (
<div key={index} className={cls} style={cardStyle}>
{content}
</div>
);
})}
</div>
</section>
);
}
+1 -60
View File
@@ -1,62 +1,3 @@
"use client";
import { motion } from "framer-motion";
export function SiteAmbientBackdrop() {
return (
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
x: [0, 72, -36, 0],
y: [0, -54, 28, 0],
scale: [1, 1.1, 0.95, 1],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute left-[-10%] top-[4%] h-[30rem] w-[30rem] rounded-full bg-brand-primary/12 blur-3xl sm:h-[38rem] sm:w-[38rem] lg:h-[46rem] lg:w-[46rem]"
/>
<motion.div
animate={{
x: [0, -84, 26, 0],
y: [0, 44, -22, 0],
scale: [1, 0.9, 1.08, 1],
}}
transition={{
duration: 22,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute right-[-12%] top-[8%] h-[26rem] w-[26rem] rounded-full bg-brand-secondary/12 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
/>
<motion.div
animate={{
x: [0, 34, -20, 0],
y: [0, -24, 34, 0],
opacity: [0.22, 0.38, 0.16, 0.22],
}}
transition={{
duration: 16,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute bottom-[-12%] left-[22%] h-[20rem] w-[20rem] rounded-full bg-brand-primary/[0.08] blur-3xl sm:h-[28rem] sm:w-[28rem] lg:h-[34rem] lg:w-[34rem]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08)_24%,rgba(255,255,255,0.02)_52%,rgba(255,255,255,0)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.08),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.08),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(160,182,255,0.08),transparent_40%)] dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.01),rgba(255,255,255,0.005)_26%,rgba(255,255,255,0)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.08),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.06),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(109,130,214,0.05),transparent_40%)]" />
<motion.div
animate={{
x: [0, 20, -14, 0],
y: [0, -16, 12, 0],
}}
transition={{
duration: 9,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
className="hero-noise absolute inset-[-6%] opacity-[0.22] mix-blend-soft-light dark:opacity-[0.08]"
/>
</div>
);
return null;
}
+33 -4
View File
@@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Menu, X } from "lucide-react";
import { Menu, ShieldCheck, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
@@ -13,6 +13,7 @@ import { SiteLogo } from "@/components/layout/site-logo";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { buildAdminUrl } from "@/lib/admin-routing";
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
@@ -35,6 +36,13 @@ type SiteHeaderProps = {
lightLogoUrl?: string | null;
darkLogoUrl?: string | null;
defaultLocale: "de" | "en" | "ar";
/**
* Server-computed via `isSuperAdmin()` in the parent layout — this client
* component only decides whether to *render* the Admin shortcut link from
* this flag. It performs no auth check itself and the flag grants no
* access; the real guard lives on each admin page/action server-side.
*/
isSuperAdmin?: boolean;
};
function getDirectionalReveal(direction: "rtl" | "ltr") {
@@ -146,6 +154,7 @@ export function SiteHeader({
lightLogoUrl,
darkLogoUrl,
defaultLocale,
isSuperAdmin = false,
}: SiteHeaderProps) {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
@@ -168,6 +177,11 @@ export function SiteHeader({
"h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground";
const mobileControlButtonClassName =
"h-10 w-10 rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs hover:bg-accent hover:text-foreground";
const adminButtonClassName =
"inline-flex h-9 items-center gap-1.5 rounded-pill border border-border/70 bg-background/80 px-3 text-sm font-medium text-foreground/80 backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
const mobileAdminButtonClassName =
"flex h-10 w-10 items-center justify-center rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs transition-colors hover:bg-accent hover:text-foreground";
const adminHref = buildAdminUrl("/");
useEffect(() => {
const handleScroll = () => {
@@ -201,7 +215,7 @@ export function SiteHeader({
};
return (
<header className="fixed inset-x-0 top-3 z-40 bg-transparent">
<motion.header layoutRoot className="fixed inset-x-0 top-3 z-40 bg-transparent">
<Container className="max-w-[88rem] px-5 sm:px-6 lg:px-8">
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
<motion.div
@@ -290,7 +304,7 @@ export function SiteHeader({
animate={isScrolled ? { opacity: 0, x: 28 } : { opacity: 1, x: 0 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"col-start-3 hidden items-center justify-end lg:flex",
"col-start-3 hidden items-center justify-end gap-2 lg:flex",
isScrolled ? "pointer-events-none invisible" : "",
)}
>
@@ -316,6 +330,12 @@ export function SiteHeader({
className={desktopControlButtonClassName}
/>
</div>
{isSuperAdmin ? (
<a href={adminHref} aria-label="Open admin dashboard" className={adminButtonClassName}>
<ShieldCheck className="h-4 w-4" />
<span className="hidden xl:inline">Admin</span>
</a>
) : null}
</motion.div>
<motion.div
@@ -459,6 +479,15 @@ export function SiteHeader({
variant="ghost"
className={mobileControlButtonClassName}
/>
{isSuperAdmin ? (
<a
href={adminHref}
aria-label="Open admin dashboard"
className={mobileAdminButtonClassName}
>
<ShieldCheck className="h-4 w-4" />
</a>
) : null}
</div>
</div>
</motion.div>
@@ -467,6 +496,6 @@ export function SiteHeader({
</motion.div>
) : null}
</AnimatePresence>
</header>
</motion.header>
);
}
+4 -4
View File
@@ -99,8 +99,8 @@ export function HeroShell({
className={cn(
"hero-surface relative isolate overflow-hidden",
variant === "home"
? "hero-home flex min-h-[92svh] items-center justify-center"
: "hero-page flex min-h-[44svh] items-center sm:min-h-[48svh]",
? "hero-home flex min-h-[76svh] items-center justify-center"
: "hero-page flex min-h-[34svh] items-center sm:min-h-[38svh]",
className,
)}
>
@@ -113,8 +113,8 @@ export function HeroShell({
className={cn(
"relative z-10 w-full",
variant === "home"
? "pb-12 pt-24 sm:pb-14 sm:pt-28 lg:pb-16 lg:pt-32"
: "py-20 sm:py-24 lg:py-28",
? "pb-10 pt-20 sm:pb-12 sm:pt-24 lg:pb-14 lg:pt-28"
: "py-16 sm:py-20 lg:py-24",
containerClassName,
)}
>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ScrollSmoother } from "gsap/ScrollSmoother";
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
const MOBILE_BREAKPOINT = 768;
export function ScrollSmootherProvider() {
const smootherRef = useRef<ScrollSmoother | null>(null);
const pathname = usePathname();
const isFirstRenderRef = useRef(true);
useEffect(() => {
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
if (prefersReduced) return;
const smoother = ScrollSmoother.create({
wrapper: "#smooth-wrapper",
content: "#smooth-content",
smooth: isMobile ? 0.6 : 1.2,
effects: !isMobile,
normalizeScroll: !isMobile,
ignoreMobileResize: true,
});
smootherRef.current = smoother;
return () => {
smoother.kill();
smootherRef.current = null;
};
}, []);
// The layout that mounts this provider persists across client-side route
// changes, so the ScrollSmoother instance above is created once and never
// recreated when navigating between pages. Its internal scroll position
// and measured content height stay tied to whatever page was on screen
// before the navigation. If the next page is a different height, the
// stale height/position pairing snaps into place the instant the new page
// paints — most visible as the fixed header's active-nav-item background
// jumping instead of easing, since it briefly renders against the old,
// now-incorrect scroll transform. Resetting scroll on every pathname
// change and refreshing ScrollTrigger's measurements once the new page has
// painted keeps the smoother in sync with what's actually on screen.
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return;
}
const smoother = smootherRef.current;
if (!smoother) {
return;
}
smoother.scrollTo(0, false);
const refreshFrame = requestAnimationFrame(() => {
ScrollTrigger.refresh();
});
return () => cancelAnimationFrame(refreshFrame);
}, [pathname]);
return null;
}