From cffef41f634f940da07645461420f32554e6da9d Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sat, 31 Jan 2026 22:55:46 +0300 Subject: [PATCH] feat: replace MovingGradient with Aurora WebGL background --- package-lock.json | 7 + package.json | 1 + src/components/layout/AppShell/AppShell.tsx | 4 +- src/components/layout/AppShell/Aurora.tsx | 273 ++++++++++++++++++++ src/components/layout/AppShell/index.ts | 2 +- 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 src/components/layout/AppShell/Aurora.tsx diff --git a/package-lock.json b/package-lock.json index ec18674..8ed0fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", @@ -4985,6 +4986,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/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", diff --git a/package.json b/package.json index f1e67af..c5244b9 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4a6d7ea..ffca0d4 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -28,7 +28,7 @@ import TicketNotificationBell from '@/components/TicketNotificationBell'; import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; -import { MovingGradient } from './MovingGradient'; +import { Aurora } from './Aurora'; import { slideUp, slideUpTransition } from '@/components/motion/transitions'; @@ -363,7 +363,7 @@ export function AppShell({ children }: AppShellProps) { return (
{/* Animated background */} - + {/* Global components */} diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx new file mode 100644 index 0000000..058d432 --- /dev/null +++ b/src/components/layout/AppShell/Aurora.tsx @@ -0,0 +1,273 @@ +import { useEffect, useRef, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; +import { brandingApi } from '@/api/branding'; + +const VERT = /* glsl */ `#version 300 es + in vec2 position; + void main() { + gl_Position = vec4(position, 0.0, 1.0); + } +`; + +const FRAG = /* glsl */ `#version 300 es + precision highp float; + + uniform float uTime; + uniform float uAmplitude; + uniform vec3 uColorStops[3]; + uniform vec2 uResolution; + uniform float uBlend; + + out vec4 fragColor; + + vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); } + + float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, 0.366025403784439, + -0.577350269189626, 0.024390243902439); + vec2 i = floor(v + dot(v, C.yy)); + vec2 x0 = v - i + dot(i, C.xx); + vec2 i1; + i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + i = mod(i, 289.0); + + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + + i.x + vec3(0.0, i1.x, 1.0)); + + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), + dot(x12.zw, x12.zw)), 0.0); + m = m * m; + m = m * m; + + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); + } + + struct ColorStop { + vec3 color; + float position; + }; + + #define COLOR_RAMP(colors, factor, finalColor) { \ + int index = 0; \ + for (int i = 0; i < colors.length() - 1; i++) { \ + ColorStop currentColor = cyclingColors[i]; \ + bool isInBetween = cyclingColors[i].position <= factor; \ + index = isInBetween ? i : index; \ + } \ + ColorStop currentColor = cyclingColors[index]; \ + ColorStop nextColor = cyclingColors[index + 1]; \ + float range = cyclingColors[index + 1].position - currentColor.position; \ + float lerpFactor = (factor - currentColor.position) / range; \ + finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \ + } + + void main() { + vec2 uv = gl_FragCoord.xy / uResolution; + + // Build color stops with fixed positions - optimized for visual appeal + ColorStop cyclingColors[3]; + cyclingColors[0] = ColorStop(uColorStops[0], 0.0); + cyclingColors[1] = ColorStop(uColorStops[1], 0.5); + cyclingColors[2] = ColorStop(uColorStops[2], 1.0); + + float noiseValue = snoise(uv * uAmplitude + uTime) * 0.5 + 0.5; + + vec3 rampColor; + COLOR_RAMP(cyclingColors, noiseValue, rampColor); + + fragColor = vec4(rampColor, uBlend); + } +`; + +interface AuroraProps { + colorStops?: string[]; + amplitude?: number; + blend?: number; + speed?: number; +} + +function hexToRgb(hex: string): [number, number, number] { + hex = hex.replace('#', ''); + if (hex.length === 3) { + hex = hex + .split('') + .map((c) => c + c) + .join(''); + } + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + return [r, g, b]; +} + +function getAccentColorFromCSS(): string { + // Get accent-500 RGB value from CSS variable + const root = document.documentElement; + const accentRgb = getComputedStyle(root).getPropertyValue('--color-accent-500').trim(); + + if (accentRgb) { + // Convert "r g b" format to hex + const parts = accentRgb.split(' ').map(Number); + if (parts.length === 3 && parts.every((n) => !isNaN(n))) { + const hex = parts.map((n) => Math.round(n).toString(16).padStart(2, '0')).join(''); + return `#${hex}`; + } + } + + // Default accent color fallback + return '#a78bfa'; +} + +function generateColorStops(baseColor: string): string[] { + const [r, g, b] = hexToRgb(baseColor); + + // Create variations: darker, base, lighter (all very dark for background) + const darkerR = Math.max(0, Math.round(r * 255 * 0.15)); + const darkerG = Math.max(0, Math.round(g * 255 * 0.15)); + const darkerB = Math.max(0, Math.round(b * 255 * 0.15)); + + const baseR = Math.round(r * 255 * 0.25); + const baseG = Math.round(g * 255 * 0.25); + const baseB = Math.round(b * 255 * 0.25); + + const lighterR = Math.round(r * 255 * 0.35); + const lighterG = Math.round(g * 255 * 0.35); + const lighterB = Math.round(b * 255 * 0.35); + + return [ + `#${darkerR.toString(16).padStart(2, '0')}${darkerG.toString(16).padStart(2, '0')}${darkerB.toString(16).padStart(2, '0')}`, + `#${baseR.toString(16).padStart(2, '0')}${baseG.toString(16).padStart(2, '0')}${baseB.toString(16).padStart(2, '0')}`, + `#${lighterR.toString(16).padStart(2, '0')}${lighterG.toString(16).padStart(2, '0')}${lighterB.toString(16).padStart(2, '0')}`, + ]; +} + +export function Aurora({ amplitude = 1.0, blend = 0.5, speed = 0.5 }: AuroraProps) { + const containerRef = useRef(null); + const animationFrameRef = useRef(0); + const rendererRef = useRef(null); + const programRef = useRef(null); + + // Fetch animation setting + const { data: animationSetting } = useQuery({ + queryKey: ['animation-enabled'], + queryFn: brandingApi.getAnimationEnabled, + staleTime: 60000, + }); + + const isEnabled = animationSetting?.enabled ?? false; + + // Get color stops based on theme + const colorStops = useMemo(() => { + if (typeof window === 'undefined') return ['#0a0a0a', '#1a1a2e', '#0a0a0a']; + const accent = getAccentColorFromCSS(); + return generateColorStops(accent); + }, []); + + useEffect(() => { + if (!isEnabled || !containerRef.current) return; + + // Check for reduced motion preference + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion) return; + + const container = containerRef.current; + const renderer = new Renderer({ + alpha: true, + antialias: true, + powerPreference: 'low-power', + }); + rendererRef.current = renderer; + const gl = renderer.gl; + gl.clearColor(0, 0, 0, 0); + container.appendChild(gl.canvas); + + const geometry = new Triangle(gl); + + const colorStopsArray = colorStops + .map((hex) => { + const c = new Color(hex); + return [c.r, c.g, c.b]; + }) + .flat(); + + const program = new Program(gl, { + vertex: VERT, + fragment: FRAG, + uniforms: { + uTime: { value: 0 }, + uAmplitude: { value: amplitude }, + uColorStops: { value: colorStopsArray }, + uResolution: { value: [container.offsetWidth, container.offsetHeight] }, + uBlend: { value: blend }, + }, + }); + programRef.current = program; + + const mesh = new Mesh(gl, { geometry, program }); + + function resize() { + if (!containerRef.current || !rendererRef.current || !programRef.current) return; + const w = containerRef.current.offsetWidth; + const h = containerRef.current.offsetHeight; + rendererRef.current.setSize(w, h); + programRef.current.uniforms.uResolution.value = [w, h]; + } + + window.addEventListener('resize', resize); + resize(); + + let lastTime = 0; + const targetFPS = 30; // Limit to 30fps for performance + const frameInterval = 1000 / targetFPS; + + function animate(currentTime: number) { + animationFrameRef.current = requestAnimationFrame(animate); + + const delta = currentTime - lastTime; + if (delta < frameInterval) return; + + lastTime = currentTime - (delta % frameInterval); + + if (programRef.current && rendererRef.current) { + programRef.current.uniforms.uTime.value += speed * 0.01; + rendererRef.current.render({ scene: mesh }); + } + } + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + window.removeEventListener('resize', resize); + cancelAnimationFrame(animationFrameRef.current); + if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) { + container.removeChild(rendererRef.current.gl.canvas); + } + rendererRef.current = null; + programRef.current = null; + }; + }, [isEnabled, colorStops, amplitude, blend, speed]); + + if (!isEnabled) { + return null; + } + + return ( +
+ ); +} diff --git a/src/components/layout/AppShell/index.ts b/src/components/layout/AppShell/index.ts index b21d562..aeb68cd 100644 --- a/src/components/layout/AppShell/index.ts +++ b/src/components/layout/AppShell/index.ts @@ -2,4 +2,4 @@ export { AppShell } from './AppShell'; export { DesktopSidebar } from './DesktopSidebar'; export { MobileBottomNav } from './MobileBottomNav'; export { AppHeader } from './AppHeader'; -export { MovingGradient } from './MovingGradient'; +export { Aurora } from './Aurora';