feat: replace animated backgrounds with Aceternity UI system

- Add 16 animated background components (aurora, sparkles, vortex, shooting-stars,
  beams, beams-collision, gradient-animation, wavy, lines, boxes, meteors, grid,
  dots, spotlight, noise, ripple, gemini-effect)
- Add full admin BackgroundEditor with live preview, type gallery, per-type settings
- Add BackgroundRenderer with lazy loading, localStorage cache, mobile reduction
- Add input sanitization (sanitizeColor, clampNumber) across all components
- Add translations for en, ru, zh, fa locales
- Remove old Aurora (WebGL/OGL) and AnimatedBackground (CSS wave-blob)
- Remove ogl dependency, add simplex-noise
This commit is contained in:
Fringg
2026-02-25 07:12:59 +03:00
parent 78e70992f1
commit 1a702a68b9
35 changed files with 2919 additions and 653 deletions

View File

@@ -0,0 +1,92 @@
import { Suspense, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '@/api/branding';
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { backgroundComponents } from '@/components/ui/backgrounds/registry';
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
function getCachedConfig(): AnimationConfig | null {
try {
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
}
function setCachedConfig(config: AnimationConfig) {
try {
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
} catch {
// localStorage not available
}
}
export function setCachedAnimationConfig(config: AnimationConfig) {
setCachedConfig(config);
}
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
const reduced = { ...settings };
if (typeof reduced.particleCount === 'number')
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
if (typeof reduced.particleDensity === 'number')
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
if (typeof reduced.number === 'number')
reduced.number = Math.floor((reduced.number as number) / 2);
if ('interactive' in reduced) reduced.interactive = false;
return reduced;
}
export function BackgroundRenderer() {
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const result = await brandingApi.getAnimationConfig();
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
if (!effectiveConfig.enabled || effectiveConfig.type === 'none' || prefersReducedMotion) {
return null;
}
const bgType = effectiveConfig.type as Exclude<BackgroundType, 'none'>;
const Component = backgroundComponents[bgType];
if (!Component) return null;
const isMobile = window.innerWidth < 768;
const settings =
effectiveConfig.reducedOnMobile && isMobile
? reduceMobileSettings(effectiveConfig.settings)
: effectiveConfig.settings;
return (
<div
className="pointer-events-none fixed inset-0 z-0"
style={{
opacity: effectiveConfig.opacity,
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
contain: 'strict',
}}
>
<Suspense fallback={null}>
<Component settings={settings} />
</Suspense>
</div>
);
}