diff --git a/package-lock.json b/package-lock.json index e701818..d46d86c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "lucide-react": "^0.462.0", "motion": "^12.29.2", "next-themes": "^0.3.0", + "ogl": "^1.0.11", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -6590,6 +6591,12 @@ "node": ">= 6" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 3aa7083..6bcdbc7 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lucide-react": "^0.462.0", "motion": "^12.29.2", "next-themes": "^0.3.0", + "ogl": "^1.0.11", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 2001984..de0791a 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -63,7 +63,7 @@ const Hero = () => { { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return m + ? [ + parseInt(m[1], 16) / 255, + parseInt(m[2], 16) / 255, + parseInt(m[3], 16) / 255, + ] + : [1, 1, 1]; +}; + +type RaysOrigin = + | "top-left" + | "top-right" + | "top-center" + | "left" + | "right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +const getAnchorAndDir = ( + origin: RaysOrigin, + w: number, + h: number +): { anchor: [number, number]; dir: [number, number] } => { + const outside = 0.2; + switch (origin) { + case "top-left": + return { anchor: [0, -outside * h], dir: [0, 1] }; + case "top-right": + return { anchor: [w, -outside * h], dir: [0, 1] }; + case "left": + return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }; + case "right": + return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }; + case "bottom-left": + return { anchor: [0, (1 + outside) * h], dir: [0, -1] }; + case "bottom-center": + return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }; + case "bottom-right": + return { anchor: [w, (1 + outside) * h], dir: [0, -1] }; + default: + return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }; + } +}; + +export interface LightRaysProps { + raysOrigin?: RaysOrigin; + raysColor?: string; + raysSpeed?: number; + lightSpread?: number; + rayLength?: number; + pulsating?: boolean; + fadeDistance?: number; + saturation?: number; + followMouse?: boolean; + mouseInfluence?: number; + noiseAmount?: number; + distortion?: number; + className?: string; +} + +export default function LightRays({ + raysOrigin = "top-center", + raysColor = DEFAULT_COLOR, + raysSpeed = 1, + lightSpread = 1, + rayLength = 2, + pulsating = false, + fadeDistance = 1.0, + saturation = 1.0, + followMouse = true, + mouseInfluence = 0.1, + noiseAmount = 0.0, + distortion = 0.0, + className = "", +}: LightRaysProps) { + const containerRef = useRef(null); + const uniformsRef = useRef | null>(null); + const rendererRef = useRef | null>(null); + const mouseRef = useRef({ x: 0.5, y: 0.5 }); + const smoothMouseRef = useRef({ x: 0.5, y: 0.5 }); + const animationIdRef = useRef(null); + const meshRef = useRef | null>(null); + const cleanupFunctionRef = useRef<(() => void) | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [useFallback, setUseFallback] = useState(false); + const observerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + setIsVisible(entry.isIntersecting); + }, + { threshold: 0.1 } + ); + + observerRef.current.observe(containerRef.current); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!isVisible || !containerRef.current) return; + + setUseFallback(false); + if (cleanupFunctionRef.current) { + cleanupFunctionRef.current(); + cleanupFunctionRef.current = null; + } + + const initializeWebGL = async () => { + if (!containerRef.current) return; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + if (!containerRef.current) return; + + const isMobile = + typeof window !== "undefined" && + (window.innerWidth <= 768 || "ontouchstart" in window); + const dpr = isMobile ? 1 : Math.min(window.devicePixelRatio, 2); + + try { + const renderer = new Renderer({ + dpr, + alpha: true, + }); + rendererRef.current = renderer; + + const gl = renderer.gl; + gl.canvas.style.width = "100%"; + gl.canvas.style.height = "100%"; + + while (containerRef.current.firstChild) { + containerRef.current.removeChild(containerRef.current.firstChild); + } + containerRef.current.appendChild(gl.canvas); + + const vert = ` +attribute vec2 position; +varying vec2 vUv; +void main() { + vUv = position * 0.5 + 0.5; + gl_Position = vec4(position, 0.0, 1.0); +}`; + + const frag = `precision mediump float; + +uniform float iTime; +uniform vec2 iResolution; + +uniform vec2 rayPos; +uniform vec2 rayDir; +uniform vec3 raysColor; +uniform float raysSpeed; +uniform float lightSpread; +uniform float rayLength; +uniform float pulsating; +uniform float fadeDistance; +uniform float saturation; +uniform vec2 mousePos; +uniform float mouseInfluence; +uniform float noiseAmount; +uniform float distortion; + +varying vec2 vUv; + +float noise(vec2 st) { + return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); +} + +float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord, + float seedA, float seedB, float speed) { + vec2 sourceToCoord = coord - raySource; + vec2 dirNorm = normalize(sourceToCoord); + float cosAngle = dot(dirNorm, rayRefDirection); + + float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2; + + float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001)); + + float distance = length(sourceToCoord); + float maxDistance = iResolution.x * rayLength; + float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0); + + float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0); + float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0; + + float baseStrength = clamp( + (0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) + + (0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)), + 0.0, 1.0 + ); + + return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y); + + vec2 finalRayDir = rayDir; + if (mouseInfluence > 0.0) { + vec2 mouseScreenPos = mousePos * iResolution.xy; + vec2 mouseDirection = normalize(mouseScreenPos - rayPos); + finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence)); + } + + vec4 rays1 = vec4(1.0) * + rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349, + 1.5 * raysSpeed); + vec4 rays2 = vec4(1.0) * + rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234, + 1.1 * raysSpeed); + + fragColor = rays1 * 0.5 + rays2 * 0.4; + + if (noiseAmount > 0.0) { + float n = noise(coord * 0.01 + iTime * 0.1); + fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n); + } + + float brightness = 1.0 - (coord.y / iResolution.y); + fragColor.x *= 0.1 + brightness * 0.8; + fragColor.y *= 0.3 + brightness * 0.6; + fragColor.z *= 0.5 + brightness * 0.5; + + if (saturation != 1.0) { + float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114)); + fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation); + } + + fragColor.rgb *= raysColor; +} + +void main() { + vec4 color; + mainImage(color, gl_FragCoord.xy); + gl_FragColor = color; +}`; + + const uniforms = { + iTime: { value: 0 }, + iResolution: { value: [1, 1] as [number, number] }, + + rayPos: { value: [0, 0] as [number, number] }, + rayDir: { value: [0, 1] as [number, number] }, + + raysColor: { value: hexToRgb(raysColor) }, + raysSpeed: { value: raysSpeed }, + lightSpread: { value: lightSpread }, + rayLength: { value: rayLength }, + pulsating: { value: pulsating ? 1.0 : 0.0 }, + fadeDistance: { value: fadeDistance }, + saturation: { value: saturation }, + mousePos: { value: [0.5, 0.5] as [number, number] }, + mouseInfluence: { value: mouseInfluence }, + noiseAmount: { value: noiseAmount }, + distortion: { value: distortion }, + }; + uniformsRef.current = uniforms as Record; + + const geometry = new Triangle(gl); + const program = new Program(gl, { + vertex: vert, + fragment: frag, + uniforms, + }); + const mesh = new Mesh(gl, { geometry, program }); + meshRef.current = mesh; + + const updatePlacement = () => { + if (!containerRef.current || !renderer) return; + + renderer.dpr = isMobile ? 1 : Math.min(window.devicePixelRatio, 2); + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current; + renderer.setSize(wCSS, hCSS); + + const dpr = renderer.dpr; + const w = wCSS * dpr; + const h = hCSS * dpr; + + uniforms.iResolution.value = [w, h]; + + const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h); + uniforms.rayPos.value = anchor; + uniforms.rayDir.value = dir; + }; + + const loop = (t: number) => { + if (!rendererRef.current || !uniformsRef.current || !meshRef.current) { + return; + } + + const uniforms = uniformsRef.current as typeof uniforms; + uniforms.iTime.value = t * 0.001; + + if (followMouse && mouseInfluence > 0.0) { + const smoothing = 0.92; + + smoothMouseRef.current.x = + smoothMouseRef.current.x * smoothing + + mouseRef.current.x * (1 - smoothing); + smoothMouseRef.current.y = + smoothMouseRef.current.y * smoothing + + mouseRef.current.y * (1 - smoothing); + + uniforms.mousePos.value = [ + smoothMouseRef.current.x, + smoothMouseRef.current.y, + ]; + } + + try { + renderer.render({ scene: mesh }); + animationIdRef.current = requestAnimationFrame(loop); + } catch (error) { + console.warn("WebGL rendering error:", error); + return; + } + }; + + window.addEventListener("resize", updatePlacement); + const resizeObserver = + typeof ResizeObserver !== "undefined" && + new ResizeObserver(() => updatePlacement()); + if (resizeObserver && containerRef.current) { + resizeObserver.observe(containerRef.current); + } + updatePlacement(); + animationIdRef.current = requestAnimationFrame(loop); + + cleanupFunctionRef.current = () => { + if (resizeObserver && containerRef.current) { + resizeObserver.unobserve(containerRef.current); + } + if (animationIdRef.current) { + cancelAnimationFrame(animationIdRef.current); + animationIdRef.current = null; + } + + window.removeEventListener("resize", updatePlacement); + + if (renderer) { + try { + const canvas = renderer.gl.canvas; + const loseContextExt = + renderer.gl.getExtension("WEBGL_lose_context"); + if (loseContextExt) { + loseContextExt.loseContext(); + } + + if (canvas && canvas.parentNode) { + canvas.parentNode.removeChild(canvas); + } + } catch (error) { + console.warn("Error during WebGL cleanup:", error); + } + } + + rendererRef.current = null; + uniformsRef.current = null; + meshRef.current = null; + }; + } catch (error) { + console.warn("LightRays WebGL init failed (e.g. on mobile):", error); + setUseFallback(true); + } + }; + + initializeWebGL(); + + return () => { + if (cleanupFunctionRef.current) { + cleanupFunctionRef.current(); + cleanupFunctionRef.current = null; + } + }; + }, [ + isVisible, + raysOrigin, + raysColor, + raysSpeed, + lightSpread, + rayLength, + pulsating, + fadeDistance, + saturation, + followMouse, + mouseInfluence, + noiseAmount, + distortion, + ]); + + useEffect(() => { + if (!uniformsRef.current || !containerRef.current || !rendererRef.current) + return; + + const u = uniformsRef.current as Record; + const renderer = rendererRef.current; + + u.raysColor.value = hexToRgb(raysColor); + u.raysSpeed.value = raysSpeed; + u.lightSpread.value = lightSpread; + u.rayLength.value = rayLength; + u.pulsating.value = pulsating ? 1.0 : 0.0; + u.fadeDistance.value = fadeDistance; + u.saturation.value = saturation; + u.mouseInfluence.value = mouseInfluence; + u.noiseAmount.value = noiseAmount; + u.distortion.value = distortion; + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current; + const dpr = renderer.dpr; + const { anchor, dir } = getAnchorAndDir( + raysOrigin, + wCSS * dpr, + hCSS * dpr + ); + u.rayPos.value = anchor; + u.rayDir.value = dir; + }, [ + raysColor, + raysSpeed, + lightSpread, + raysOrigin, + rayLength, + pulsating, + fadeDistance, + saturation, + mouseInfluence, + noiseAmount, + distortion, + ]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current || !rendererRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + mouseRef.current = { x, y }; + }; + + if (followMouse) { + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + } + }, [followMouse]); + + return ( +
+ {useFallback && ( +
+ )} +
+ ); +} diff --git a/src/components/ProblemSection.tsx b/src/components/ProblemSection.tsx index 0d5a7ed..e4b90a9 100644 --- a/src/components/ProblemSection.tsx +++ b/src/components/ProblemSection.tsx @@ -1,4 +1,6 @@ import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react"; +import { LampTop } from "@/components/ui/lamp"; +import LightRays from "@/components/LightRays"; const ProblemSection = () => { const problems = [ @@ -21,8 +23,25 @@ const ProblemSection = () => { ]; return ( -
-
+
+
+ +
+ +
{/* Section Header */}
Das Problem
diff --git a/src/components/SolutionSection.tsx b/src/components/SolutionSection.tsx index e86cbb1..4cd84cc 100644 --- a/src/components/SolutionSection.tsx +++ b/src/components/SolutionSection.tsx @@ -1,6 +1,8 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowRight, CheckCircle2 } from "lucide-react"; +import { LampTop } from "@/components/ui/lamp"; +import LightRays from "@/components/LightRays"; const SolutionSection = () => { const benefits = [ @@ -10,8 +12,25 @@ const SolutionSection = () => { ]; return ( -
-
+
+
+ +
+ +
{/* Left Content */}
diff --git a/src/components/ui/lamp.tsx b/src/components/ui/lamp.tsx new file mode 100644 index 0000000..4501a80 --- /dev/null +++ b/src/components/ui/lamp.tsx @@ -0,0 +1,39 @@ +"use client"; + +import React from "react"; +import { motion } from "motion/react"; +import { cn } from "@/lib/utils"; + +const S = 2.5; + +export const LampTop = ({ + className, + lineClassName = "bg-red-500", + children, +}: { + className?: string; + lineClassName?: string; + children?: React.ReactNode; +}) => { + return ( +
+ + {children} +
+ ); +}; diff --git a/src/index.css b/src/index.css index 9cb0d43..b2ace32 100644 --- a/src/index.css +++ b/src/index.css @@ -154,6 +154,11 @@ } @layer components { + /* Gemeinsamer Hintergrund für Problem- und Lösungs-Sektion */ + .section-problem-solution { + background-color: hsl(var(--background)); + } + /* Minimal glass nav */ .glass-nav { @apply backdrop-blur-xl border-b;