From 1a702a68b9cad0f112a65494250c11758388a91f Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:12:59 +0300 Subject: [PATCH 01/24] 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 --- package-lock.json | 14 +- package.json | 2 +- src/api/branding.ts | 23 + src/components/AnimatedBackground.tsx | 82 --- src/components/admin/BackgroundEditor.tsx | 381 ++++++++++++ src/components/admin/BrandingTab.tsx | 36 +- .../backgrounds/BackgroundPreview.tsx | 63 ++ .../backgrounds/BackgroundRenderer.tsx | 92 +++ src/components/layout/AppShell/AppShell.tsx | 4 +- src/components/layout/AppShell/Aurora.tsx | 363 ------------ src/components/layout/AppShell/index.ts | 1 - .../ui/backgrounds/aurora-background.tsx | 37 ++ .../background-beams-collision.tsx | 63 ++ .../ui/backgrounds/background-beams.tsx | 76 +++ .../ui/backgrounds/background-boxes.tsx | 56 ++ .../background-gradient-animation.tsx | 127 ++++ .../ui/backgrounds/background-lines.tsx | 73 +++ .../ui/backgrounds/background-ripple.tsx | 69 +++ .../ui/backgrounds/google-gemini-effect.tsx | 130 +++++ .../ui/backgrounds/grid-background.tsx | 34 ++ src/components/ui/backgrounds/meteors.tsx | 50 ++ .../ui/backgrounds/noise-background.tsx | 70 +++ src/components/ui/backgrounds/registry.ts | 544 ++++++++++++++++++ .../ui/backgrounds/shooting-stars.tsx | 141 +++++ src/components/ui/backgrounds/sparkles.tsx | 115 ++++ .../ui/backgrounds/spotlight-bg.tsx | 54 ++ src/components/ui/backgrounds/types.ts | 75 +++ src/components/ui/backgrounds/vortex.tsx | 144 +++++ .../ui/backgrounds/wavy-background.tsx | 76 +++ src/locales/en.json | 90 +++ src/locales/fa.json | 90 +++ src/locales/ru.json | 90 +++ src/locales/zh.json | 90 +++ src/styles/globals.css | 179 +----- tailwind.config.js | 38 ++ 35 files changed, 2919 insertions(+), 653 deletions(-) delete mode 100644 src/components/AnimatedBackground.tsx create mode 100644 src/components/admin/BackgroundEditor.tsx create mode 100644 src/components/backgrounds/BackgroundPreview.tsx create mode 100644 src/components/backgrounds/BackgroundRenderer.tsx delete mode 100644 src/components/layout/AppShell/Aurora.tsx create mode 100644 src/components/ui/backgrounds/aurora-background.tsx create mode 100644 src/components/ui/backgrounds/background-beams-collision.tsx create mode 100644 src/components/ui/backgrounds/background-beams.tsx create mode 100644 src/components/ui/backgrounds/background-boxes.tsx create mode 100644 src/components/ui/backgrounds/background-gradient-animation.tsx create mode 100644 src/components/ui/backgrounds/background-lines.tsx create mode 100644 src/components/ui/backgrounds/background-ripple.tsx create mode 100644 src/components/ui/backgrounds/google-gemini-effect.tsx create mode 100644 src/components/ui/backgrounds/grid-background.tsx create mode 100644 src/components/ui/backgrounds/meteors.tsx create mode 100644 src/components/ui/backgrounds/noise-background.tsx create mode 100644 src/components/ui/backgrounds/registry.ts create mode 100644 src/components/ui/backgrounds/shooting-stars.tsx create mode 100644 src/components/ui/backgrounds/sparkles.tsx create mode 100644 src/components/ui/backgrounds/spotlight-bg.tsx create mode 100644 src/components/ui/backgrounds/types.ts create mode 100644 src/components/ui/backgrounds/vortex.tsx create mode 100644 src/components/ui/backgrounds/wavy-background.tsx diff --git a/package-lock.json b/package-lock.json index 3f7db8c..de67f80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,12 +36,12 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", - "ogl": "^1.0.11", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, @@ -5233,12 +5233,6 @@ "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", @@ -6082,6 +6076,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simplex-noise": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", + "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", + "license": "MIT" + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", diff --git a/package.json b/package.json index e4d56f6..4d9b4d1 100644 --- a/package.json +++ b/package.json @@ -44,12 +44,12 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", - "ogl": "^1.0.11", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, diff --git a/src/api/branding.ts b/src/api/branding.ts index 2073e8c..1d1e013 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -1,4 +1,8 @@ import apiClient from './client'; +import type { AnimationConfig } from '@/components/ui/backgrounds/types'; +import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; + +export type { AnimationConfig }; export interface BrandingInfo { name: string; @@ -180,6 +184,25 @@ export const brandingApi = { return response.data; }, + // Get animation config (public, no auth required) + getAnimationConfig: async (): Promise => { + try { + const response = await apiClient.get('/cabinet/branding/animation-config'); + return response.data; + } catch { + return DEFAULT_ANIMATION_CONFIG; + } + }, + + // Update animation config (admin only, partial update) + updateAnimationConfig: async (config: Partial): Promise => { + const response = await apiClient.patch( + '/cabinet/branding/animation-config', + config, + ); + return response.data; + }, + // Get fullscreen enabled (public, no auth required) getFullscreenEnabled: async (): Promise => { try { diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx deleted file mode 100644 index 9892ecf..0000000 --- a/src/components/AnimatedBackground.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useEffect, useState, memo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { brandingApi } from '../api/branding'; - -const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'; - -// Detect if user prefers reduced motion -const isLowPerformance = (): boolean => { - // Only check for reduced motion preference - let animation run everywhere else - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - return prefersReducedMotion; -}; - -// Get cached value from localStorage -const getCachedAnimationEnabled = (): boolean | null => { - try { - const cached = localStorage.getItem(ANIMATION_CACHE_KEY); - if (cached !== null) { - return cached === 'true'; - } - } catch { - // localStorage not available - } - return null; -}; - -// Update cache in localStorage -// eslint-disable-next-line react-refresh/only-export-components -export const setCachedAnimationEnabled = (enabled: boolean) => { - try { - localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled)); - } catch { - // localStorage not available - } -}; - -// Memoized background component to prevent re-renders -const AnimatedBackground = memo(function AnimatedBackground() { - // Start with cached value (null means unknown yet) - const [isEnabled, setIsEnabled] = useState(() => getCachedAnimationEnabled()); - const [isLowPerf] = useState(() => isLowPerformance()); - - const { data: animationSettings } = useQuery({ - queryKey: ['animation-enabled'], - queryFn: brandingApi.getAnimationEnabled, - staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls - refetchOnWindowFocus: false, // Don't refetch on focus - save resources - retry: false, - }); - - // Update state and cache when data arrives - useEffect(() => { - if (animationSettings !== undefined) { - const enabled = animationSettings.enabled; - setIsEnabled(enabled); - setCachedAnimationEnabled(enabled); - } - }, [animationSettings]); - - // Don't render if disabled or on low-performance devices - if (isEnabled !== true || isLowPerf) { - return null; - } - - // Render only 2 blobs on mobile for better performance - const isMobile = window.innerWidth < 768; - - return ( -
-
-
- {!isMobile && ( - <> -
-
- - )} -
- ); -}); - -export default AnimatedBackground; diff --git a/src/components/admin/BackgroundEditor.tsx b/src/components/admin/BackgroundEditor.tsx new file mode 100644 index 0000000..038ead2 --- /dev/null +++ b/src/components/admin/BackgroundEditor.tsx @@ -0,0 +1,381 @@ +import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { brandingApi } from '@/api/branding'; +import { backgroundRegistry } from '@/components/ui/backgrounds/registry'; +import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview'; +import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer'; +import type { + AnimationConfig, + BackgroundType, + SettingDefinition, +} from '@/components/ui/backgrounds/types'; +import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; +import { Toggle } from './Toggle'; +import { cn } from '@/lib/utils'; + +function SettingField({ + def, + value, + onChange, + t, +}: { + def: SettingDefinition; + value: unknown; + onChange: (val: unknown) => void; + t: (key: string) => string; +}) { + if (def.type === 'number') { + const numVal = (value as number) ?? (def.default as number); + const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal); + return ( +
+ +
+ onChange(parseFloat(e.target.value))} + className="w-24 accent-accent-500" + /> + {displayVal} +
+
+ ); + } + + if (def.type === 'color') { + const colorVal = (value as string) ?? (def.default as string); + // HTML color input only supports hex — for rgba defaults, show a neutral hex + const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8'; + return ( +
+ +
+ onChange(e.target.value)} + className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent" + /> + {colorVal} +
+
+ ); + } + + if (def.type === 'boolean') { + const boolVal = (value as boolean) ?? (def.default as boolean); + return ( +
+ + onChange(!boolVal)} /> +
+ ); + } + + if (def.type === 'select' && def.options) { + const selectVal = (value as string) ?? (def.default as string); + return ( +
+ + +
+ ); + } + + return null; +} + +export function BackgroundEditor() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const saveTimerRef = useRef>(undefined); + + // Clear save timer on unmount + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + }; + }, []); + + const { data: serverConfig } = useQuery({ + queryKey: ['animation-config'], + queryFn: brandingApi.getAnimationConfig, + staleTime: 30_000, + }); + + const [localConfig, setLocalConfig] = useState(null); + const config = localConfig ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG; + + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + + const saveMutation = useMutation({ + mutationFn: brandingApi.updateAnimationConfig, + onMutate: () => setSaveStatus('saving'), + onSuccess: (data) => { + setCachedAnimationConfig(data); + queryClient.setQueryData(['animation-config'], data); + setLocalConfig(null); + setSaveStatus('saved'); + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + }, + onError: () => setSaveStatus('idle'), + }); + + const updateConfig = useCallback( + (patch: Partial) => { + setLocalConfig((prev) => ({ + ...(prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG), + ...patch, + })); + }, + [serverConfig], + ); + + // Use functional updater to avoid stale closure when rapidly changing settings + const updateSetting = useCallback( + (key: string, value: unknown) => { + setLocalConfig((prev) => { + const base = prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG; + return { ...base, settings: { ...base.settings, [key]: value } }; + }); + }, + [serverConfig], + ); + + const handleTypeChange = useCallback( + (type: BackgroundType) => { + const def = backgroundRegistry.find((d) => d.type === type); + const defaults: Record = {}; + if (def) { + for (const s of def.settings) { + defaults[s.key] = s.default; + } + } + updateConfig({ type, settings: defaults }); + }, + [updateConfig], + ); + + const handleSave = () => { + saveMutation.mutate(config); + }; + + const isDirty = localConfig !== null; + const showSaveButton = isDirty || saveStatus === 'saved' || saveStatus === 'saving'; + + const currentDef = useMemo( + () => backgroundRegistry.find((d) => d.type === config.type), + [config.type], + ); + + const categories = useMemo(() => { + const cats = new Map(); + for (const def of backgroundRegistry) { + const list = cats.get(def.category) ?? []; + list.push(def); + cats.set(def.category, list); + } + return cats; + }, []); + + return ( +
+ {/* Header with enable toggle */} +
+
+

{t('admin.backgrounds.title')}

+

{t('admin.backgrounds.description')}

+
+ updateConfig({ enabled: !config.enabled })} + /> +
+ + {config.enabled && ( + <> + {/* Preview */} +
+ + +
+ + {/* Type selector gallery */} +
+ + + {/* None option */} + + + {/* Background types by category */} +
+ {Array.from(categories.entries()).map(([category, defs]) => ( +
+ + {t(`admin.backgrounds.category${category.toUpperCase()}`)} + +
+ {defs.map((def) => ( + + ))} +
+
+ ))} +
+
+ + {/* Per-type settings */} + {currentDef && currentDef.settings.length > 0 && ( +
+

+ {t('admin.backgrounds.settings')} +

+
+ {currentDef.settings.map((def) => ( + updateSetting(def.key, val)} + t={t} + /> + ))} +
+
+ )} + + {/* Global settings */} +
+
+
+ +
+ updateConfig({ opacity: parseFloat(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.opacity} + +
+
+ +
+ +
+ updateConfig({ blur: parseInt(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.blur}px + +
+
+ +
+
+ +

+ {t('admin.backgrounds.reducedOnMobileDesc')} +

+
+ updateConfig({ reducedOnMobile: !config.reducedOnMobile })} + /> +
+
+
+ + )} + + {/* Save button */} + {showSaveButton && ( + + )} +
+ ); +} diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index 915fa29..5a3c620 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -2,10 +2,10 @@ import { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { brandingApi, setCachedBranding } from '../../api/branding'; -import { setCachedAnimationEnabled } from '../AnimatedBackground'; import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK'; import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'; import { Toggle } from './Toggle'; +import { BackgroundEditor } from './BackgroundEditor'; interface BrandingTabProps { accentColor?: string; @@ -25,11 +25,6 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { queryFn: brandingApi.getBranding, }); - const { data: animationSettings } = useQuery({ - queryKey: ['animation-enabled'], - queryFn: brandingApi.getAnimationEnabled, - }); - const { data: fullscreenSettings } = useQuery({ queryKey: ['fullscreen-enabled'], queryFn: brandingApi.getFullscreenEnabled, @@ -66,14 +61,6 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { }, }); - const updateAnimationMutation = useMutation({ - mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), - onSuccess: (data) => { - setCachedAnimationEnabled(data.enabled); - queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }); - }, - }); - const updateFullscreenMutation = useMutation({ mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), onSuccess: (data) => { @@ -199,27 +186,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
- {/* Animation & Fullscreen toggles */} + {/* Animated Background Editor */} +
+ +
+ + {/* Fullscreen & Email toggles */}

{t('admin.settings.interfaceOptions')}

-
-
- - {t('admin.settings.animatedBackground')} - -

{t('admin.settings.animatedBackgroundDesc')}

-
- updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true))} - disabled={updateAnimationMutation.isPending} - /> -
-
diff --git a/src/components/backgrounds/BackgroundPreview.tsx b/src/components/backgrounds/BackgroundPreview.tsx new file mode 100644 index 0000000..83e9a97 --- /dev/null +++ b/src/components/backgrounds/BackgroundPreview.tsx @@ -0,0 +1,63 @@ +import { Suspense } from 'react'; +import { backgroundComponents } from '@/components/ui/backgrounds/registry'; +import type { BackgroundType } from '@/components/ui/backgrounds/types'; +import { cn } from '@/lib/utils'; + +interface BackgroundPreviewProps { + type: BackgroundType; + settings: Record; + opacity?: number; + blur?: number; + className?: string; +} + +export function BackgroundPreview({ + type, + settings, + opacity, + blur, + className, +}: BackgroundPreviewProps) { + if (type === 'none') { + return ( +
+
+
+ ); + } + + const Component = backgroundComponents[type]; + if (!Component) return null; + + return ( +
+ +
+
+ } + > +
0 ? `blur(${blur}px)` : undefined, + }} + className="absolute inset-0" + > + +
+
+
+ ); +} diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx new file mode 100644 index 0000000..36d6419 --- /dev/null +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -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): Record { + 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; + const Component = backgroundComponents[bgType]; + + if (!Component) return null; + + const isMobile = window.innerWidth < 768; + const settings = + effectiveConfig.reducedOnMobile && isMobile + ? reduceMobileSettings(effectiveConfig.settings) + : effectiveConfig.settings; + + return ( +
0 ? `blur(${effectiveConfig.blur}px)` : undefined, + contain: 'strict', + }} + > + + + +
+ ); +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 0b2de7b..127d177 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -23,7 +23,7 @@ import TicketNotificationBell from '@/components/TicketNotificationBell'; import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; -import { Aurora } from './Aurora'; +import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; // Desktop nav icons const HomeIcon = ({ className }: { className?: string }) => ( @@ -279,7 +279,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 deleted file mode 100644 index 534dc00..0000000 --- a/src/components/layout/AppShell/Aurora.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; -import { brandingApi } from '@/api/branding'; -import { themeColorsApi } from '@/api/themeColors'; -import { useTheme } from '@/hooks/useTheme'; -import { DEFAULT_THEME_COLORS } from '@/types/theme'; - -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; - - 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); - } -`; - -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]; -} - -// Reduce lightness of a hex color for subdued background blobs -function dimAccent(hex: string, factor = 0.45): string { - hex = hex.replace('#', ''); - if (hex.length === 3) { - hex = hex - .split('') - .map((c) => c + c) - .join(''); - } - const r = Math.round(parseInt(hex.substring(0, 2), 16) * factor); - const g = Math.round(parseInt(hex.substring(2, 4), 16) * factor); - const b = Math.round(parseInt(hex.substring(4, 6), 16) * factor); - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; -} - -function generateColorStops(background: string, surface: string, accent: string): string[] { - return [background, surface, dimAccent(accent)]; -} - -let _webglAvailable: boolean | null = null; - -function isWebglAvailable(): boolean { - if (_webglAvailable === null) { - try { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); - _webglAvailable = !!gl; - if (gl) { - const loseCtx = gl.getExtension('WEBGL_lose_context'); - if (loseCtx) loseCtx.loseContext(); - } - } catch { - _webglAvailable = false; - } - } - return _webglAvailable; -} - -export function Aurora() { - if (!isWebglAvailable()) return null; - return ; -} - -function AuroraImpl() { - 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; - - // Subscribe reactively to theme-colors cache so Aurora re-renders on setQueryData - const { data: themeColors = DEFAULT_THEME_COLORS } = useQuery({ - queryKey: ['theme-colors'], - queryFn: themeColorsApi.getColors, - staleTime: 5 * 60 * 1000, - }); - - // Pick background and surface based on current theme - const { isDark } = useTheme(); - const background = isDark ? themeColors.darkBackground : themeColors.lightBackground; - const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface; - - // Refs for initial color values (WebGL context shouldn't recreate on color change) - const backgroundRef = useRef(background); - backgroundRef.current = background; - const surfaceRef = useRef(surface); - surfaceRef.current = surface; - const accentRef = useRef(themeColors.accent); - accentRef.current = themeColors.accent; - - // Initialize WebGL context once (only depends on isEnabled) - 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: false, - 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 colorStops = generateColorStops( - backgroundRef.current, - surfaceRef.current, - accentRef.current, - ); - 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: 1.0 }, - uColorStops: { value: colorStopsArray }, - uResolution: { value: [container.offsetWidth, container.offsetHeight] }, - uBlend: { value: 1.0 }, - }, - }); - programRef.current = program; - - const mesh = new Mesh(gl, { geometry, program }); - - // Рендерим на 10% разрешения — шейдер обрабатывает в ~100 раз меньше пикселей. - // renderer.setSize задаёт и буфер, и CSS-размер канваса, поэтому после него - // принудительно возвращаем CSS на 100% — канвас растягивается браузером, - // а filter: blur() сглаживает артефакты масштабирования. - const RESOLUTION_SCALE = 0.1; - - function resize() { - if (!containerRef.current || !rendererRef.current || !programRef.current) return; - const fullW = containerRef.current.offsetWidth; - const fullH = containerRef.current.offsetHeight; - const bufW = Math.max(Math.ceil(fullW * RESOLUTION_SCALE), 1); - const bufH = Math.max(Math.ceil(fullH * RESOLUTION_SCALE), 1); - rendererRef.current.setSize(bufW, bufH); - // OGL setSize ставит canvas.style.width/height = bufW/bufH px, - // перезаписываем на 100% чтобы CSS растянул маленький буфер на весь экран - const canvas = rendererRef.current.gl.canvas as HTMLCanvasElement; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - programRef.current.uniforms.uResolution.value = [bufW, bufH]; - } - - window.addEventListener('resize', resize); - resize(); - - let lastTime = 0; - const targetFPS = 10; - const frameInterval = 1000 / targetFPS; - const speed = 0.3; - - function animate(currentTime: number) { - const delta = currentTime - lastTime; - if (delta < frameInterval) { - animationFrameRef.current = requestAnimationFrame(animate); - 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); - } - - function handleVisibilityChange() { - if (document.hidden) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = 0; - } else { - lastTime = 0; - animationFrameRef.current = requestAnimationFrame(animate); - } - } - document.addEventListener('visibilitychange', handleVisibilityChange); - - animationFrameRef.current = requestAnimationFrame(animate); - - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('resize', resize); - cancelAnimationFrame(animationFrameRef.current); - - if (rendererRef.current) { - const glCtx = rendererRef.current.gl; - - // Delete GPU resources - if (programRef.current) { - glCtx.deleteProgram(programRef.current.program); - } - - // Force-release the WebGL context - const loseCtx = glCtx.getExtension('WEBGL_lose_context'); - if (loseCtx) loseCtx.loseContext(); - - if (container.contains(glCtx.canvas)) { - container.removeChild(glCtx.canvas); - } - } - - rendererRef.current = null; - programRef.current = null; - }; - }, [isEnabled]); - - // Update color uniforms reactively without recreating WebGL context - useEffect(() => { - if (!programRef.current) return; - const colorStops = generateColorStops(background, surface, themeColors.accent); - const colorStopsArray = colorStops - .map((hex) => { - const c = new Color(hex); - return [c.r, c.g, c.b]; - }) - .flat(); - programRef.current.uniforms.uColorStops.value = colorStopsArray; - }, [themeColors.accent, background, surface]); - - if (!isEnabled) { - return null; - } - - // Blur overlay color from accent (very subtle) - const [r, g, b] = hexToRgb(themeColors.accent); - const blurColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.03)`; - - return ( - <> - {/* WebGL Aurora canvas — рендерится на ~10% разрешения, CSS растягивает обратно. - filter: blur() сглаживает артефакты масштабирования (дешевле backdrop-filter в ~10-20 раз, - т.к. блюрит только один элемент, а не все слои под ним). */} -
- - ); -} diff --git a/src/components/layout/AppShell/index.ts b/src/components/layout/AppShell/index.ts index aeb68cd..233b39d 100644 --- a/src/components/layout/AppShell/index.ts +++ b/src/components/layout/AppShell/index.ts @@ -2,4 +2,3 @@ export { AppShell } from './AppShell'; export { DesktopSidebar } from './DesktopSidebar'; export { MobileBottomNav } from './MobileBottomNav'; export { AppHeader } from './AppHeader'; -export { Aurora } from './Aurora'; diff --git a/src/components/ui/backgrounds/aurora-background.tsx b/src/components/ui/backgrounds/aurora-background.tsx new file mode 100644 index 0000000..b76fad2 --- /dev/null +++ b/src/components/ui/backgrounds/aurora-background.tsx @@ -0,0 +1,37 @@ +import { cn } from '@/lib/utils'; + +interface Props { + settings: Record; +} + +export default function AuroraBackground({ settings }: Props) { + const speed = (settings.speed as number) ?? 60; + const showRadialGradient = (settings.showRadialGradient as boolean) ?? true; + + return ( +
+
+
+ ); +} diff --git a/src/components/ui/backgrounds/background-beams-collision.tsx b/src/components/ui/backgrounds/background-beams-collision.tsx new file mode 100644 index 0000000..7782af7 --- /dev/null +++ b/src/components/ui/backgrounds/background-beams-collision.tsx @@ -0,0 +1,63 @@ +import { useRef, useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +interface Props { + settings: Record; +} + +interface Beam { + id: number; + x: number; + duration: number; + delay: number; + rotate: number; +} + +export default function BackgroundBeamsCollision({ settings: _settings }: Props) { + const [beams, setBeams] = useState([]); + const containerRef = useRef(null); + + useEffect(() => { + const w = containerRef.current?.offsetWidth ?? window.innerWidth; + const count = Math.floor(w / 100); + setBeams( + Array.from({ length: count }, (_, i) => ({ + id: i, + x: (i / count) * 100 + Math.random() * 10, + duration: 4 + Math.random() * 8, + delay: Math.random() * 4, + rotate: -5 + Math.random() * 10, + })), + ); + }, []); + + return ( +
+ {beams.map((beam) => ( + +
+ + ))} + {/* Bottom collision line */} +
+
+ ); +} diff --git a/src/components/ui/backgrounds/background-beams.tsx b/src/components/ui/backgrounds/background-beams.tsx new file mode 100644 index 0000000..9054a89 --- /dev/null +++ b/src/components/ui/backgrounds/background-beams.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; +import { motion } from 'framer-motion'; + +interface Props { + settings: Record; +} + +const paths = [ + 'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875', + 'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867', + 'M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859', + 'M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851', + 'M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843', + 'M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835', + 'M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827', + 'M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819', + 'M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811', + 'M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803', +]; + +export default function BackgroundBeams({ settings: _settings }: Props) { + const svgRef = useRef(null); + + useEffect(() => { + const handleVisibility = () => { + if (svgRef.current) { + svgRef.current.style.display = document.hidden ? 'none' : ''; + } + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, []); + + return ( +
+ + {paths.map((path, i) => ( + + ))} + + {paths.map((_, i) => ( + + + + + + + ))} + + +
+ ); +} diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx new file mode 100644 index 0000000..78decc0 --- /dev/null +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function BackgroundBoxes({ settings }: Props) { + const rows = clampNumber(settings.rows, 2, 30, 12); + const cols = clampNumber(settings.cols, 2, 30, 12); + const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); + + const boxes = useMemo(() => { + const result: { row: number; col: number; color: string }[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + result.push({ row: r, col: c, color: boxColor }); + } + } + return result; + }, [rows, cols, boxColor]); + + return ( +
+
+ {boxes.map((box, i) => ( + + ))} +
+
+ ); +} diff --git a/src/components/ui/backgrounds/background-gradient-animation.tsx b/src/components/ui/backgrounds/background-gradient-animation.tsx new file mode 100644 index 0000000..1b4094f --- /dev/null +++ b/src/components/ui/backgrounds/background-gradient-animation.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; +import { sanitizeColor } from './types'; + +interface Props { + settings: Record; +} + +function hexToRgbString(hex: string): string { + hex = hex.replace('#', ''); + if (hex.length === 3) + hex = hex + .split('') + .map((c) => c + c) + .join(''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + if (isNaN(r) || isNaN(g) || isNaN(b)) return '129, 140, 248'; + return `${r}, ${g}, ${b}`; +} + +export default function BackgroundGradientAnimation({ settings }: Props) { + const interactiveRef = useRef(null); + + const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF')); + const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF')); + const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF')); + const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232')); + const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432')); + const interactive = (settings.interactive as boolean) ?? true; + const size = (settings.size as string) ?? '80%'; + + useEffect(() => { + if (!interactive || !interactiveRef.current) return; + + let curX = 0; + let curY = 0; + let tgX = 0; + let tgY = 0; + let animId = 0; + + const move = () => { + curX += (tgX - curX) / 20; + curY += (tgY - curY) / 20; + if (interactiveRef.current) { + interactiveRef.current.style.transform = `translate(${Math.round(curX)}px, ${Math.round(curY)}px)`; + } + animId = requestAnimationFrame(move); + }; + + const handleMouse = (e: MouseEvent) => { + tgX = e.clientX; + tgY = e.clientY; + }; + + window.addEventListener('mousemove', handleMouse); + animId = requestAnimationFrame(move); + + return () => { + window.removeEventListener('mousemove', handleMouse); + cancelAnimationFrame(animId); + }; + }, [interactive]); + + return ( +
+ + + + + + + + + +
+ {[ + { color: firstColor, anim: 'animate-move-vertical' }, + { color: secondColor, anim: 'animate-move-in-circle' }, + { color: thirdColor, anim: 'animate-move-in-circle-slow' }, + { color: fourthColor, anim: 'animate-move-horizontal' }, + { color: fifthColor, anim: 'animate-move-in-circle-fast' }, + ].map((blob, i) => ( +
+ ))} + {interactive && ( +
+ )} +
+
+ ); +} diff --git a/src/components/ui/backgrounds/background-lines.tsx b/src/components/ui/backgrounds/background-lines.tsx new file mode 100644 index 0000000..e54fd05 --- /dev/null +++ b/src/components/ui/backgrounds/background-lines.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import { createNoise3D } from 'simplex-noise'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function BackgroundLines({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const lineCount = clampNumber(settings.lineCount, 5, 100, 40); + const lineColor = sanitizeColor(settings.lineColor, '#818cf8'); + const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002); + const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const noise3D = createNoise3D(); + let nt = 0; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + }; + + const animate = () => { + nt += speed; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let i = 0; i < lineCount; i++) { + const yBase = (i / lineCount) * canvas.height; + const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI); + + ctx.beginPath(); + ctx.strokeStyle = lineColor; + ctx.globalAlpha = opacity; + ctx.lineWidth = strokeWidth; + + for (let x = 0; x < canvas.width; x += 4) { + const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40; + if (x === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); + } + + ctx.globalAlpha = 1; + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [lineCount, lineColor, speed, strokeWidth]); + + return ; +} diff --git a/src/components/ui/backgrounds/background-ripple.tsx b/src/components/ui/backgrounds/background-ripple.tsx new file mode 100644 index 0000000..228c4da --- /dev/null +++ b/src/components/ui/backgrounds/background-ripple.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function BackgroundRipple({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8'); + const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5); + const speed = clampNumber(settings.speed, 0.1, 5, 0.5); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + }; + + const ripples = Array.from({ length: rippleCount }, (_, i) => ({ + phase: (i / rippleCount) * Math.PI * 2, + maxRadius: 0, + })); + + const animate = (time: number) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const maxR = Math.sqrt(cx * cx + cy * cy); + + for (let i = 0; i < ripples.length; i++) { + const t = ((time * speed) / 1000 + ripples[i].phase) % (Math.PI * 2); + const progress = t / (Math.PI * 2); + const radius = progress * maxR; + const opacity = 0.3 * (1 - progress); + + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = rippleColor; + ctx.globalAlpha = opacity; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + ctx.globalAlpha = 1; + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [rippleColor, rippleCount, speed]); + + return ; +} diff --git a/src/components/ui/backgrounds/google-gemini-effect.tsx b/src/components/ui/backgrounds/google-gemini-effect.tsx new file mode 100644 index 0000000..2aa7530 --- /dev/null +++ b/src/components/ui/backgrounds/google-gemini-effect.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef } from 'react'; + +interface Props { + settings: Record; +} + +const PATHS = [ + 'M0 663C145.5 663 191 666.265 269 647C326.5 630 339.5 621 397.5 566C439 531.5 455 529.5 490 523C509.664 519.348 521 503.736 538 504.236C553.591 504.236 562.429 514.739 584.66 522.749C592.042 525.408 600.2 526.237 607.356 523.019C624.755 515.195 641.446 496.324 657 496.735C673.408 496.735 693.545 519.572 712.903 526.769C718.727 528.934 725.184 528.395 730.902 525.965C751.726 517.115 764.085 497.106 782 496.735', + 'M0 587.5C147 587.5 277 587.5 310 573.5C348 563 392.5 543.5 408 535C434 523.5 426 526.235 479 515.235C494 512.729 523 510.435 534.5 512.735C554.5 516.735 555.5 523.235 576 523.735C592 523.735 616 496.735 633 497.235C648.671 497.235 661.31 515.052 684.774 524.942C692.004 527.989 700.2 528.738 707.349 525.505', + 'M0 514C147.5 514.333 294.5 513.735 380.5 513.735C405.976 514.94 422.849 515.228 436.37 515.123C477.503 514.803 518.631 506.605 559.508 511.197C564.04 511.706 569.162 512.524 575 513.735C588 516.433 616 521.702 627.5 519.402C647.5 515.402 659 499.235 680.5 499.235', + 'M0 438.5C150.5 438.5 261 438.318 323.5 456.5C351 464.5 387.517 484.001 423.5 494.5C447.371 501.465 472 503.735 487 507.735C503.786 512.212 504.5 516.808 523 518.735C547 521.235 564.814 501.235 584.5 501.235', + 'M0.5 364C145.288 362.349 195 361.5 265.5 378C322 391.223 399.182 457.5 411 467.5C424.176 478.649 456.916 491.677 496.259 502.699C498.746 503.396 501.16 504.304 503.511 505.374C517.104 511.558 541.149 520.911 551.5 521.236', +]; + +const COLORS = ['#FFB7C5', '#FFDDB7', '#B1C5FF', '#4FABFF', '#076EFF']; + +export default function GoogleGeminiEffect({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const speed = (settings.speed as number) ?? 0.003; + const lineWidth = (settings.lineWidth as number) ?? 2; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let progress = 0; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + }; + + // Parse SVG path to points + const parsePath = (d: string): { x: number; y: number }[] => { + const points: { x: number; y: number }[] = []; + const commands = d.match(/[MC]\s*[\d.\s,-]+/g); + if (!commands) return points; + + for (const cmd of commands) { + const nums = cmd + .slice(1) + .trim() + .split(/[\s,]+/) + .map(Number); + for (let i = 0; i < nums.length; i += 2) { + if (!isNaN(nums[i]) && !isNaN(nums[i + 1])) { + points.push({ x: nums[i], y: nums[i + 1] }); + } + } + } + return points; + }; + + const allPaths = PATHS.map(parsePath); + + const animate = () => { + progress += speed; + if (progress > 2) progress = 0; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const scaleX = canvas.width / 1440; + const scaleY = canvas.height / 890; + + for (let p = 0; p < allPaths.length; p++) { + const points = allPaths[p]; + if (points.length < 2) continue; + + const pathProgress = Math.max(0, Math.min(1, progress - p * 0.1)); + const drawCount = Math.floor(points.length * pathProgress); + + if (drawCount < 2) continue; + + ctx.beginPath(); + ctx.strokeStyle = COLORS[p % COLORS.length]; + ctx.lineWidth = lineWidth; + ctx.globalAlpha = 0.6; + + ctx.moveTo(points[0].x * scaleX, points[0].y * scaleY); + for (let i = 1; i < drawCount; i++) { + ctx.lineTo(points[i].x * scaleX, points[i].y * scaleY); + } + ctx.stroke(); + } + + // Blur pass + ctx.globalAlpha = 0.3; + ctx.filter = 'blur(5px)'; + for (let p = 0; p < allPaths.length; p++) { + const points = allPaths[p]; + if (points.length < 2) continue; + + const pathProgress = Math.max(0, Math.min(1, progress - p * 0.1)); + const drawCount = Math.floor(points.length * pathProgress); + + if (drawCount < 2) continue; + + ctx.beginPath(); + ctx.strokeStyle = COLORS[p % COLORS.length]; + ctx.lineWidth = lineWidth; + + ctx.moveTo(points[0].x * scaleX, points[0].y * scaleY); + for (let i = 1; i < drawCount; i++) { + ctx.lineTo(points[i].x * scaleX, points[i].y * scaleY); + } + ctx.stroke(); + } + + ctx.filter = 'none'; + ctx.globalAlpha = 1; + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [speed, lineWidth]); + + return ; +} diff --git a/src/components/ui/backgrounds/grid-background.tsx b/src/components/ui/backgrounds/grid-background.tsx new file mode 100644 index 0000000..327e7af --- /dev/null +++ b/src/components/ui/backgrounds/grid-background.tsx @@ -0,0 +1,34 @@ +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function GridBackground({ settings }: Props) { + const variant = (settings.variant as string) ?? 'grid'; + const gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)'); + const gridSize = clampNumber(settings.gridSize, 10, 200, 40); + const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5); + + if (variant === 'dots') { + return ( +
+ ); + } + + return ( +
+ ); +} diff --git a/src/components/ui/backgrounds/meteors.tsx b/src/components/ui/backgrounds/meteors.tsx new file mode 100644 index 0000000..576496e --- /dev/null +++ b/src/components/ui/backgrounds/meteors.tsx @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function Meteors({ settings }: Props) { + const count = clampNumber(settings.count, 1, 50, 20); + const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff'); + + const meteors = useMemo( + () => + Array.from({ length: count }, (_, i) => ({ + id: i, + left: `${Math.random() * 100}%`, + delay: `${Math.random() * 5}s`, + duration: `${2 + Math.random() * 6}s`, + size: `${1 + Math.random()}px`, + })), + [count], + ); + + return ( +
+ {meteors.map((meteor) => ( + +
+ + ))} +
+ ); +} diff --git a/src/components/ui/backgrounds/noise-background.tsx b/src/components/ui/backgrounds/noise-background.tsx new file mode 100644 index 0000000..4418baf --- /dev/null +++ b/src/components/ui/backgrounds/noise-background.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function NoiseBackground({ settings }: Props) { + const canvasRef = useRef(null); + + const noiseOpacity = clampNumber(settings.noiseOpacity, 0.01, 1, 0.15); + const baseColor = sanitizeColor(settings.baseColor, '#0a0a1a'); + const animated = (settings.animated as boolean) ?? false; + const animationRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const size = 128; + canvas.width = size; + canvas.height = size; + + const drawNoise = () => { + const imageData = ctx.createImageData(size, size); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const v = Math.random() * 255; + data[i] = v; + data[i + 1] = v; + data[i + 2] = v; + data[i + 3] = 255; + } + + ctx.putImageData(imageData, 0, 0); + }; + + drawNoise(); + + if (animated) { + const loop = () => { + drawNoise(); + animationRef.current = requestAnimationFrame(loop); + }; + animationRef.current = requestAnimationFrame(loop); + } + + return () => { + if (animated) cancelAnimationFrame(animationRef.current); + }; + }, [animated]); + + return ( +
+ +
+ ); +} diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts new file mode 100644 index 0000000..9421da2 --- /dev/null +++ b/src/components/ui/backgrounds/registry.ts @@ -0,0 +1,544 @@ +import { lazy, type ComponentType } from 'react'; +import type { BackgroundType, BackgroundDefinition } from './types'; + +// Lazy-loaded components — only the selected type is fetched +export const backgroundComponents: Record< + Exclude, + ComponentType<{ settings: Record }> +> = { + aurora: lazy(() => import('./aurora-background')), + sparkles: lazy(() => import('./sparkles')), + vortex: lazy(() => import('./vortex')), + 'shooting-stars': lazy(() => import('./shooting-stars')), + 'background-beams': lazy(() => import('./background-beams')), + 'background-beams-collision': lazy(() => import('./background-beams-collision')), + 'gradient-animation': lazy(() => import('./background-gradient-animation')), + wavy: lazy(() => import('./wavy-background')), + 'background-lines': lazy(() => import('./background-lines')), + boxes: lazy(() => import('./background-boxes')), + meteors: lazy(() => import('./meteors')), + grid: lazy(() => import('./grid-background')), + dots: lazy(() => import('./grid-background')), + spotlight: lazy(() => import('./spotlight-bg')), + noise: lazy(() => import('./noise-background')), + ripple: lazy(() => import('./background-ripple')), + 'gemini-effect': lazy(() => import('./google-gemini-effect')), +}; + +// Registry of all background definitions with settings for the editor +// labelKey/descriptionKey are under "admin.backgrounds.*" +// setting label keys are under "admin.backgrounds.*" +export const backgroundRegistry: BackgroundDefinition[] = [ + { + type: 'aurora', + labelKey: 'admin.backgrounds.aurora', + descriptionKey: 'admin.backgrounds.auroraDesc', + category: 'css', + settings: [ + { key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#00d2ff' }, + { key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#7928ca' }, + { key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#ff0080' }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'select', + default: 'normal', + options: [ + { label: 'admin.backgrounds.slow', value: 'slow' }, + { label: 'admin.backgrounds.normal', value: 'normal' }, + { label: 'admin.backgrounds.fast', value: 'fast' }, + ], + }, + ], + }, + { + type: 'sparkles', + labelKey: 'admin.backgrounds.sparkles', + descriptionKey: 'admin.backgrounds.sparklesDesc', + category: 'canvas', + settings: [ + { + key: 'particleDensity', + label: 'admin.backgrounds.density', + type: 'number', + min: 10, + max: 200, + step: 10, + default: 80, + }, + { + key: 'particleColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#ffffff', + }, + { + key: 'minSize', + label: 'admin.backgrounds.minSize', + type: 'number', + min: 0.2, + max: 3, + step: 0.1, + default: 0.4, + }, + { + key: 'maxSize', + label: 'admin.backgrounds.maxSize', + type: 'number', + min: 1, + max: 5, + step: 0.1, + default: 1.4, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 3, + step: 0.1, + default: 0.6, + }, + ], + }, + { + type: 'vortex', + labelKey: 'admin.backgrounds.vortex', + descriptionKey: 'admin.backgrounds.vortexDesc', + category: 'canvas', + settings: [ + { + key: 'particleCount', + label: 'admin.backgrounds.particles', + type: 'number', + min: 50, + max: 1000, + step: 50, + default: 300, + }, + { + key: 'rangeY', + label: 'admin.backgrounds.rangeY', + type: 'number', + min: 50, + max: 300, + step: 10, + default: 100, + }, + { + key: 'baseHue', + label: 'admin.backgrounds.hue', + type: 'number', + min: 0, + max: 360, + step: 10, + default: 220, + }, + { + key: 'rangeSpeed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.5, + max: 5, + step: 0.5, + default: 1.5, + }, + { + key: 'backgroundColor', + label: 'admin.backgrounds.bgColor', + type: 'color', + default: '#000000', + }, + ], + }, + { + type: 'shooting-stars', + labelKey: 'admin.backgrounds.shootingStars', + descriptionKey: 'admin.backgrounds.shootingStarsDesc', + category: 'canvas', + settings: [ + { key: 'starColor', label: 'admin.backgrounds.starColor', type: 'color', default: '#9E00FF' }, + { + key: 'trailColor', + label: 'admin.backgrounds.trailColor', + type: 'color', + default: '#2EB9DF', + }, + { + key: 'starDensity', + label: 'admin.backgrounds.density', + type: 'number', + min: 0.00005, + max: 0.0005, + step: 0.00005, + default: 0.00015, + }, + { + key: 'minSpeed', + label: 'admin.backgrounds.minSpeed', + type: 'number', + min: 5, + max: 30, + step: 1, + default: 10, + }, + { + key: 'maxSpeed', + label: 'admin.backgrounds.maxSpeed', + type: 'number', + min: 15, + max: 60, + step: 1, + default: 30, + }, + ], + }, + { + type: 'background-beams', + labelKey: 'admin.backgrounds.beams', + descriptionKey: 'admin.backgrounds.beamsDesc', + category: 'svg', + settings: [], + }, + { + type: 'background-beams-collision', + labelKey: 'admin.backgrounds.beamsCollision', + descriptionKey: 'admin.backgrounds.beamsCollisionDesc', + category: 'svg', + settings: [], + }, + { + type: 'gradient-animation', + labelKey: 'admin.backgrounds.gradientAnimation', + descriptionKey: 'admin.backgrounds.gradientAnimationDesc', + category: 'css', + settings: [ + { key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#1271FF' }, + { key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#DD4AFF' }, + { key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#64DCFF' }, + { key: 'fourthColor', label: 'admin.backgrounds.color4', type: 'color', default: '#C83232' }, + { key: 'fifthColor', label: 'admin.backgrounds.color5', type: 'color', default: '#B4B432' }, + { + key: 'interactive', + label: 'admin.backgrounds.interactive', + type: 'boolean', + default: true, + }, + { + key: 'size', + label: 'admin.backgrounds.size', + type: 'select', + default: '80%', + options: [ + { label: '60%', value: '60%' }, + { label: '80%', value: '80%' }, + { label: '100%', value: '100%' }, + ], + }, + ], + }, + { + type: 'wavy', + labelKey: 'admin.backgrounds.wavy', + descriptionKey: 'admin.backgrounds.wavyDesc', + category: 'canvas', + settings: [ + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'select', + default: 'fast', + options: [ + { label: 'admin.backgrounds.slow', value: 'slow' }, + { label: 'admin.backgrounds.fast', value: 'fast' }, + ], + }, + { + key: 'waveWidth', + label: 'admin.backgrounds.waveWidth', + type: 'number', + min: 10, + max: 100, + step: 5, + default: 50, + }, + { + key: 'blur', + label: 'admin.backgrounds.blurAmount', + type: 'number', + min: 0, + max: 30, + step: 1, + default: 10, + }, + { + key: 'waveOpacity', + label: 'admin.backgrounds.waveOpacity', + type: 'number', + min: 0.1, + max: 1, + step: 0.05, + default: 0.5, + }, + { + key: 'backgroundFill', + label: 'admin.backgrounds.bgColor', + type: 'color', + default: '#000000', + }, + ], + }, + { + type: 'background-lines', + labelKey: 'admin.backgrounds.lines', + descriptionKey: 'admin.backgrounds.linesDesc', + category: 'canvas', + settings: [ + { + key: 'lineCount', + label: 'admin.backgrounds.count', + type: 'number', + min: 10, + max: 80, + step: 5, + default: 40, + }, + { + key: 'lineColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#818cf8', + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.001, + max: 0.01, + step: 0.001, + default: 0.002, + }, + { + key: 'strokeWidth', + label: 'admin.backgrounds.waveWidth', + type: 'number', + min: 0.5, + max: 3, + step: 0.5, + default: 1, + }, + ], + }, + { + type: 'boxes', + labelKey: 'admin.backgrounds.boxes', + descriptionKey: 'admin.backgrounds.boxesDesc', + category: 'css', + settings: [ + { + key: 'rows', + label: 'admin.backgrounds.rows', + type: 'number', + min: 4, + max: 20, + step: 1, + default: 12, + }, + { + key: 'cols', + label: 'admin.backgrounds.cols', + type: 'number', + min: 4, + max: 20, + step: 1, + default: 12, + }, + { key: 'boxColor', label: 'admin.backgrounds.fillColor', type: 'color', default: '#818cf8' }, + ], + }, + { + type: 'meteors', + labelKey: 'admin.backgrounds.meteors', + descriptionKey: 'admin.backgrounds.meteorsDesc', + category: 'css', + settings: [ + { + key: 'count', + label: 'admin.backgrounds.count', + type: 'number', + min: 5, + max: 50, + step: 5, + default: 20, + }, + { + key: 'meteorColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#ffffff', + }, + ], + }, + { + type: 'grid', + labelKey: 'admin.backgrounds.grid', + descriptionKey: 'admin.backgrounds.gridDesc', + category: 'css', + settings: [ + { + key: 'gridColor', + label: 'admin.backgrounds.fillColor', + type: 'color', + default: 'rgba(255,255,255,0.05)', + }, + { + key: 'gridSize', + label: 'admin.backgrounds.gridSize', + type: 'number', + min: 10, + max: 80, + step: 5, + default: 40, + }, + ], + }, + { + type: 'dots', + labelKey: 'admin.backgrounds.dots', + descriptionKey: 'admin.backgrounds.dotsDesc', + category: 'css', + settings: [ + { + key: 'variant', + label: 'admin.backgrounds.size', + type: 'select', + default: 'dots', + options: [{ label: 'admin.backgrounds.dots', value: 'dots' }], + }, + { + key: 'gridColor', + label: 'admin.backgrounds.fillColor', + type: 'color', + default: 'rgba(255,255,255,0.1)', + }, + { + key: 'gridSize', + label: 'admin.backgrounds.gridSize', + type: 'number', + min: 10, + max: 60, + step: 5, + default: 20, + }, + { + key: 'dotSize', + label: 'admin.backgrounds.size', + type: 'number', + min: 0.5, + max: 4, + step: 0.5, + default: 1.5, + }, + ], + }, + { + type: 'spotlight', + labelKey: 'admin.backgrounds.spotlight', + descriptionKey: 'admin.backgrounds.spotlightDesc', + category: 'css', + settings: [ + { + key: 'spotlightColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#818cf8', + }, + { + key: 'spotlightSize', + label: 'admin.backgrounds.size', + type: 'number', + min: 200, + max: 800, + step: 50, + default: 400, + }, + ], + }, + { + type: 'noise', + labelKey: 'admin.backgrounds.noise', + descriptionKey: 'admin.backgrounds.noiseDesc', + category: 'canvas', + settings: [ + { + key: 'noiseOpacity', + label: 'admin.backgrounds.intensity', + type: 'number', + min: 0.05, + max: 0.5, + step: 0.05, + default: 0.15, + }, + { key: 'baseColor', label: 'admin.backgrounds.bgColor', type: 'color', default: '#0a0a1a' }, + { key: 'animated', label: 'admin.backgrounds.interactive', type: 'boolean', default: false }, + ], + }, + { + type: 'ripple', + labelKey: 'admin.backgrounds.ripple', + descriptionKey: 'admin.backgrounds.rippleDesc', + category: 'canvas', + settings: [ + { + key: 'rippleColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#818cf8', + }, + { + key: 'rippleCount', + label: 'admin.backgrounds.count', + type: 'number', + min: 3, + max: 10, + step: 1, + default: 5, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 2, + step: 0.1, + default: 0.5, + }, + ], + }, + { + type: 'gemini-effect', + labelKey: 'admin.backgrounds.geminiEffect', + descriptionKey: 'admin.backgrounds.geminiEffectDesc', + category: 'canvas', + settings: [ + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.001, + max: 0.01, + step: 0.001, + default: 0.003, + }, + { + key: 'lineWidth', + label: 'admin.backgrounds.waveWidth', + type: 'number', + min: 1, + max: 5, + step: 0.5, + default: 2, + }, + ], + }, +]; diff --git a/src/components/ui/backgrounds/shooting-stars.tsx b/src/components/ui/backgrounds/shooting-stars.tsx new file mode 100644 index 0000000..a67aaed --- /dev/null +++ b/src/components/ui/backgrounds/shooting-stars.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +interface Star { + x: number; + y: number; + angle: number; + scale: number; + speed: number; + distance: number; + opacity: number; +} + +interface BgStar { + x: number; + y: number; + radius: number; + opacity: number; + twinkleSpeed: number | null; +} + +export default function ShootingStarsBackground({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const starColor = sanitizeColor(settings.starColor, '#9E00FF'); + const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF'); + const starDensity = clampNumber(settings.starDensity, 0.00001, 0.001, 0.00015); + const minSpeed = clampNumber(settings.minSpeed, 1, 50, 10); + const maxSpeed = clampNumber(settings.maxSpeed, 5, 100, 30); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let shootingStars: Star[] = []; + let bgStars: BgStar[] = []; + let lastShootingTime = 0; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + initBgStars(); + }; + + const initBgStars = () => { + const count = Math.floor(canvas.width * canvas.height * starDensity); + bgStars = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + radius: Math.random() * 1.2 + 0.3, + opacity: Math.random(), + twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null, + })); + }; + + const spawnShootingStar = () => { + shootingStars.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height * 0.5, + angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3, + scale: 0.5 + Math.random() * 0.5, + speed: minSpeed + Math.random() * (maxSpeed - minSpeed), + distance: 0, + opacity: 1, + }); + }; + + const animate = (time: number) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Background stars + for (const s of bgStars) { + let opacity = s.opacity; + if (s.twinkleSpeed) { + opacity = 0.5 + 0.5 * Math.sin((time / 1000) * s.twinkleSpeed * Math.PI * 2); + } + ctx.beginPath(); + ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${opacity})`; + ctx.fill(); + } + + // Spawn shooting stars + if (time - lastShootingTime > 4200 + Math.random() * 4500) { + spawnShootingStar(); + lastShootingTime = time; + } + + // Draw shooting stars + shootingStars = shootingStars.filter((star) => { + star.distance += star.speed; + star.opacity = Math.max(0, 1 - star.distance / 500); + + if (star.opacity <= 0) return false; + + const x2 = star.x + Math.cos(star.angle) * star.distance; + const y2 = star.y + Math.sin(star.angle) * star.distance; + const tailX = star.x + Math.cos(star.angle) * Math.max(0, star.distance - 80); + const tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80); + + const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2); + gradient.addColorStop(0, 'transparent'); + gradient.addColorStop(0.5, trailColor); + gradient.addColorStop(1, starColor); + + ctx.save(); + ctx.strokeStyle = gradient; + ctx.lineWidth = star.scale * 2; + ctx.globalAlpha = star.opacity; + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + + return true; + }); + + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [starColor, trailColor, starDensity, minSpeed, maxSpeed]); + + return ; +} diff --git a/src/components/ui/backgrounds/sparkles.tsx b/src/components/ui/backgrounds/sparkles.tsx new file mode 100644 index 0000000..80357f3 --- /dev/null +++ b/src/components/ui/backgrounds/sparkles.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +interface Particle { + x: number; + y: number; + size: number; + speedX: number; + speedY: number; + opacity: number; + opacityDirection: number; + opacitySpeed: number; +} + +export default function Sparkles({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + const particlesRef = useRef([]); + + const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800); + const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4); + const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4); + const speed = clampNumber(settings.speed, 0.1, 10, 2); + const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF'); + + const hexToRgb = useCallback((hex: string) => { + hex = hex.replace('#', ''); + if (hex.length === 3) + hex = hex + .split('') + .map((c) => c + c) + .join(''); + return { + r: parseInt(hex.substring(0, 2), 16), + g: parseInt(hex.substring(2, 4), 16), + b: parseInt(hex.substring(4, 6), 16), + }; + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + initParticles(); + }; + + const initParticles = () => { + const area = canvas.width * canvas.height; + const count = Math.floor((area / 1000000) * particleDensity); + particlesRef.current = Array.from({ length: count }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + size: minSize + Math.random() * (maxSize - minSize), + speedX: (Math.random() - 0.5) * speed * 0.2, + speedY: (Math.random() - 0.5) * speed * 0.2, + opacity: Math.random(), + opacityDirection: Math.random() > 0.5 ? 1 : -1, + opacitySpeed: 0.005 + Math.random() * 0.01 * speed, + })); + }; + + const rgb = hexToRgb(particleColor); + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const p of particlesRef.current) { + p.x += p.speedX; + p.y += p.speedY; + p.opacity += p.opacityDirection * p.opacitySpeed; + + if (p.opacity <= 0) { + p.opacity = 0; + p.opacityDirection = 1; + } else if (p.opacity >= 1) { + p.opacity = 1; + p.opacityDirection = -1; + } + + if (p.x < 0) p.x = canvas.width; + if (p.x > canvas.width) p.x = 0; + if (p.y < 0) p.y = canvas.height; + if (p.y > canvas.height) p.y = 0; + + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`; + ctx.fill(); + } + + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]); + + return ; +} diff --git a/src/components/ui/backgrounds/spotlight-bg.tsx b/src/components/ui/backgrounds/spotlight-bg.tsx new file mode 100644 index 0000000..dd6a5aa --- /dev/null +++ b/src/components/ui/backgrounds/spotlight-bg.tsx @@ -0,0 +1,54 @@ +import { motion } from 'framer-motion'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function SpotlightBg({ settings }: Props) { + const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8'); + const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400); + + return ( +
+ + +
+ ); +} diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts new file mode 100644 index 0000000..e6cba62 --- /dev/null +++ b/src/components/ui/backgrounds/types.ts @@ -0,0 +1,75 @@ +export type BackgroundType = + | 'aurora' + | 'sparkles' + | 'vortex' + | 'shooting-stars' + | 'background-beams' + | 'background-beams-collision' + | 'gradient-animation' + | 'wavy' + | 'background-lines' + | 'boxes' + | 'meteors' + | 'grid' + | 'dots' + | 'spotlight' + | 'noise' + | 'ripple' + | 'gemini-effect' + | 'none'; + +export interface AnimationConfig { + enabled: boolean; + type: BackgroundType; + settings: Record; + opacity: number; + blur: number; + reducedOnMobile: boolean; +} + +export const DEFAULT_ANIMATION_CONFIG: AnimationConfig = { + enabled: true, + type: 'aurora', + settings: {}, + opacity: 1.0, + blur: 0, + reducedOnMobile: true, +}; + +/** Sanitize color values to prevent CSS injection. Allows hex, rgb/rgba, hsl/hsla, and named colors. */ +export function sanitizeColor(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + // Allow hex colors + if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed; + // Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, % + if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed; + // Allow CSS named colors (alphanumeric only) + if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed; + return fallback; +} + +/** Clamp a numeric value within bounds */ +export function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + const n = typeof value === 'number' ? value : fallback; + return Math.max(min, Math.min(max, n)); +} + +export interface SettingDefinition { + key: string; + label: string; + type: 'number' | 'color' | 'boolean' | 'select'; + min?: number; + max?: number; + step?: number; + default: unknown; + options?: { label: string; value: string }[]; +} + +export interface BackgroundDefinition { + type: BackgroundType; + labelKey: string; + descriptionKey: string; + category: 'css' | 'canvas' | 'svg'; + settings: SettingDefinition[]; +} diff --git a/src/components/ui/backgrounds/vortex.tsx b/src/components/ui/backgrounds/vortex.tsx new file mode 100644 index 0000000..c345e56 --- /dev/null +++ b/src/components/ui/backgrounds/vortex.tsx @@ -0,0 +1,144 @@ +import { useEffect, useRef } from 'react'; +import { createNoise3D } from 'simplex-noise'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function VortexBackground({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const particleCount = clampNumber(settings.particleCount, 50, 2000, 500); + const rangeY = clampNumber(settings.rangeY, 10, 500, 100); + const baseHue = clampNumber(settings.baseHue, 0, 360, 220); + const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5); + const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000'); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const noise3D = createNoise3D(); + const particlePropCount = 9; + const particlePropsLength = particleCount * particlePropCount; + let particleProps = new Float32Array(particlePropsLength); + let tick = 0; + let center: [number, number] = [0, 0]; + + const rand = (n: number) => n * Math.random(); + const randRange = (n: number) => n - rand(2 * n); + const TAU = 2 * Math.PI; + const fadeInOut = (t: number, m: number) => { + const hm = 0.5 * m; + return Math.abs(((t + hm) % m) - hm) / hm; + }; + const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + center = [0.5 * canvas.width, 0.5 * canvas.height]; + }; + + const initParticle = (i: number) => { + const x = rand(canvas.width); + const y = center[1] + randRange(rangeY); + const life = 0; + const ttl = 50 + rand(150); + const speed = rand(rangeSpeed); + const radius = 1 + rand(2); + const hue = baseHue + rand(100); + particleProps.set([x, y, 0, 0, life, ttl, speed, radius, hue], i); + }; + + const initParticles = () => { + tick = 0; + particleProps = new Float32Array(particlePropsLength); + for (let i = 0; i < particlePropsLength; i += particlePropCount) { + initParticle(i); + } + }; + + const drawParticle = ( + x: number, + y: number, + x2: number, + y2: number, + life: number, + ttl: number, + radius: number, + hue: number, + ) => { + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineWidth = radius; + ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.closePath(); + ctx.restore(); + }; + + const draw = () => { + tick++; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + for (let i = 0; i < particlePropsLength; i += particlePropCount) { + const x = particleProps[i]; + const y = particleProps[i + 1]; + const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU; + const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5); + const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5); + const life = particleProps[i + 4]; + const ttl = particleProps[i + 5]; + const speed = particleProps[i + 6]; + const x2 = x + vx * speed; + const y2 = y + vy * speed; + const radius = particleProps[i + 7]; + const hue = particleProps[i + 8]; + + drawParticle(x, y, x2, y2, life, ttl, radius, hue); + + particleProps[i] = x2; + particleProps[i + 1] = y2; + particleProps[i + 2] = vx; + particleProps[i + 3] = vy; + particleProps[i + 4] = life + 1; + + if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) { + initParticle(i); + } + } + + // Glow effect + ctx.save(); + ctx.filter = 'blur(8px) brightness(200%)'; + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(canvas, 0, 0); + ctx.restore(); + + animationRef.current = requestAnimationFrame(draw); + }; + + resize(); + initParticles(); + animationRef.current = requestAnimationFrame(draw); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]); + + return ; +} diff --git a/src/components/ui/backgrounds/wavy-background.tsx b/src/components/ui/backgrounds/wavy-background.tsx new file mode 100644 index 0000000..84ef17a --- /dev/null +++ b/src/components/ui/backgrounds/wavy-background.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react'; +import { createNoise3D } from 'simplex-noise'; +import { sanitizeColor, clampNumber } from './types'; + +interface Props { + settings: Record; +} + +export default function WavyBackground({ settings }: Props) { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + const speed = (settings.speed as string) ?? 'fast'; + const waveWidth = clampNumber(settings.waveWidth, 5, 200, 50); + const blur = clampNumber(settings.blur, 0, 50, 10); + const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5); + const backgroundFill = sanitizeColor(settings.backgroundFill, '#000000'); + const colors = ['#38bdf8', '#818cf8', '#c084fc', '#e879f9', '#22d3ee']; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const noise3D = createNoise3D(); + let nt = 0; + const speedVal = speed === 'slow' ? 0.001 : 0.002; + const waveCount = 5; + + const resize = () => { + canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; + canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; + ctx.filter = `blur(${blur}px)`; + }; + + const drawWave = (_n: number) => { + nt += speedVal; + for (let i = 0; i < waveCount; i++) { + ctx.beginPath(); + ctx.lineWidth = waveWidth; + ctx.strokeStyle = colors[i % colors.length]; + ctx.globalAlpha = waveOpacity; + + for (let x = 0; x < canvas.width; x += 5) { + const y = noise3D(x / 800, 0.3 * i, nt) * 100; + ctx.lineTo(x, y + canvas.height * 0.5); + } + + ctx.stroke(); + ctx.closePath(); + } + }; + + const animate = () => { + ctx.globalAlpha = 1; + ctx.fillStyle = backgroundFill; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + drawWave(5); + animationRef.current = requestAnimationFrame(animate); + }; + + resize(); + animationRef.current = requestAnimationFrame(animate); + window.addEventListener('resize', resize); + + return () => { + cancelAnimationFrame(animationRef.current); + window.removeEventListener('resize', resize); + }; + }, [speed, waveWidth, blur, waveOpacity, backgroundFill]); + + return ; +} diff --git a/src/locales/en.json b/src/locales/en.json index 6906b3b..e7490fe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -828,6 +828,96 @@ } }, "admin": { + "backgrounds": { + "title": "Animated Background", + "description": "Choose a type and configure animation settings", + "selectType": "Background Type", + "preview": "Preview", + "settings": "Settings", + "save": "Save", + "saved": "Saved", + "saving": "Saving...", + "enabled": "Background enabled", + "disabled": "Background disabled", + "reducedOnMobile": "Reduced mode on mobile", + "reducedOnMobileDesc": "Reduces particle count and disables interactivity", + "globalOpacity": "Opacity", + "globalBlur": "Blur", + "categoryCSS": "CSS", + "categoryCanvas": "Canvas", + "categorySVG": "SVG", + "aurora": "Aurora", + "auroraDesc": "Northern lights with smooth gradients", + "sparkles": "Sparkles", + "sparklesDesc": "Twinkling particles on the background", + "vortex": "Vortex", + "vortexDesc": "Spinning particle vortex", + "shootingStars": "Shooting Stars", + "shootingStarsDesc": "Falling stars on a starry sky", + "beams": "Beams", + "beamsDesc": "Light beams along SVG paths", + "beamsCollision": "Beams Collision", + "beamsCollisionDesc": "Exploding light beams", + "gradientAnimation": "Gradient Animation", + "gradientAnimationDesc": "Smoothly moving gradient blobs", + "wavy": "Wavy", + "wavyDesc": "Waves based on simplex noise", + "lines": "Lines", + "linesDesc": "Animated SVG waves", + "boxes": "Boxes", + "boxesDesc": "Grid of interactive blocks", + "meteors": "Meteors", + "meteorsDesc": "Flying meteors", + "grid": "Grid", + "gridDesc": "Minimalist grid pattern", + "dots": "Dots", + "dotsDesc": "Dot pattern", + "spotlight": "Spotlight", + "spotlightDesc": "Spotlight beam", + "noise": "Noise", + "noiseDesc": "Noise gradients with motion", + "ripple": "Ripple", + "rippleDesc": "Ripple wave effect", + "geminiEffect": "Gemini Effect", + "geminiEffectDesc": "SVG effect like Google Gemini", + "speed": "Speed", + "density": "Particle Density", + "particles": "Particle Count", + "count": "Count", + "hue": "Hue", + "rangeY": "Y Range", + "minSize": "Min Size", + "maxSize": "Max Size", + "minSpeed": "Min Speed", + "maxSpeed": "Max Speed", + "particleColor": "Particle Color", + "starColor": "Star Color", + "trailColor": "Trail Color", + "bgColor": "Background Color", + "fillColor": "Fill Color", + "color1": "Color 1", + "color2": "Color 2", + "color3": "Color 3", + "color4": "Color 4", + "color5": "Color 5", + "interactive": "Interactive (mouse)", + "size": "Size", + "radialGradient": "Radial Gradient", + "waveWidth": "Wave Width", + "blurAmount": "Blur", + "waveOpacity": "Wave Opacity", + "duration": "Duration", + "intensity": "Intensity", + "rows": "Rows", + "cols": "Columns", + "gridSize": "Grid Size", + "small": "Small", + "normal": "Normal", + "slow": "Slow", + "fast": "Fast", + "none": "No Background", + "noneDesc": "Disable animated background" + }, "groups": { "analytics": "Analytics", "marketing": "Marketing", diff --git a/src/locales/fa.json b/src/locales/fa.json index 95b18ff..838ad66 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -716,6 +716,96 @@ "youWon": "شما برنده شدید" }, "admin": { + "backgrounds": { + "title": "پس‌زمینه متحرک", + "description": "نوع را انتخاب کنید و تنظیمات انیمیشن را پیکربندی کنید", + "selectType": "نوع پس‌زمینه", + "preview": "پیش‌نمایش", + "settings": "تنظیمات", + "save": "ذخیره", + "saved": "ذخیره شد", + "saving": "در حال ذخیره...", + "enabled": "پس‌زمینه فعال", + "disabled": "پس‌زمینه غیرفعال", + "reducedOnMobile": "حالت سبک در موبایل", + "reducedOnMobileDesc": "تعداد ذرات را کاهش داده و تعامل را غیرفعال می‌کند", + "globalOpacity": "شفافیت", + "globalBlur": "تاری", + "categoryCSS": "CSS", + "categoryCanvas": "Canvas", + "categorySVG": "SVG", + "aurora": "شفق قطبی", + "auroraDesc": "شفق شمالی با گرادیان‌های نرم", + "sparkles": "درخشش", + "sparklesDesc": "ذرات درخشان در پس‌زمینه", + "vortex": "گرداب", + "vortexDesc": "گرداب چرخان ذرات", + "shootingStars": "ستاره‌های دنباله‌دار", + "shootingStarsDesc": "ستاره‌های در حال سقوط در آسمان پرستاره", + "beams": "پرتوها", + "beamsDesc": "پرتوهای نوری در مسیرهای SVG", + "beamsCollision": "برخورد پرتوها", + "beamsCollisionDesc": "پرتوهای نوری منفجرشونده", + "gradientAnimation": "انیمیشن گرادیان", + "gradientAnimationDesc": "بلوب‌های گرادیانی با حرکت نرم", + "wavy": "موجی", + "wavyDesc": "امواج مبتنی بر نویز", + "lines": "خطوط", + "linesDesc": "امواج SVG متحرک", + "boxes": "جعبه‌ها", + "boxesDesc": "شبکه‌ای از بلوک‌های تعاملی", + "meteors": "شهاب‌سنگ", + "meteorsDesc": "شهاب‌سنگ‌های پرنده", + "grid": "شبکه", + "gridDesc": "الگوی شبکه‌ای مینیمال", + "dots": "نقاط", + "dotsDesc": "الگوی نقطه‌ای", + "spotlight": "نورافکن", + "spotlightDesc": "پرتو نورافکن", + "noise": "نویز", + "noiseDesc": "گرادیان‌های نویزی با حرکت", + "ripple": "موج", + "rippleDesc": "اثر موج دایره‌ای", + "geminiEffect": "افکت Gemini", + "geminiEffectDesc": "افکت SVG مشابه Google Gemini", + "speed": "سرعت", + "density": "تراکم ذرات", + "particles": "تعداد ذرات", + "count": "تعداد", + "hue": "رنگ‌مایه", + "rangeY": "محدوده Y", + "minSize": "حداقل اندازه", + "maxSize": "حداکثر اندازه", + "minSpeed": "حداقل سرعت", + "maxSpeed": "حداکثر سرعت", + "particleColor": "رنگ ذرات", + "starColor": "رنگ ستاره", + "trailColor": "رنگ دنباله", + "bgColor": "رنگ پس‌زمینه", + "fillColor": "رنگ پر کردن", + "color1": "رنگ ۱", + "color2": "رنگ ۲", + "color3": "رنگ ۳", + "color4": "رنگ ۴", + "color5": "رنگ ۵", + "interactive": "تعاملی (ماوس)", + "size": "اندازه", + "radialGradient": "گرادیان شعاعی", + "waveWidth": "عرض موج", + "blurAmount": "تاری", + "waveOpacity": "شفافیت موج", + "duration": "مدت زمان", + "intensity": "شدت", + "rows": "ردیف‌ها", + "cols": "ستون‌ها", + "gridSize": "اندازه شبکه", + "small": "کوچک", + "normal": "معمولی", + "slow": "آهسته", + "fast": "سریع", + "none": "بدون پس‌زمینه", + "noneDesc": "غیرفعال کردن پس‌زمینه متحرک" + }, "nav": { "title": "مدیریت", "dashboard": "آمار", diff --git a/src/locales/ru.json b/src/locales/ru.json index f46066e..935ff27 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -857,6 +857,96 @@ } }, "admin": { + "backgrounds": { + "title": "Анимированный фон", + "description": "Выберите тип и настройте параметры анимации", + "selectType": "Тип фона", + "preview": "Предпросмотр", + "settings": "Настройки", + "save": "Сохранить", + "saved": "Сохранено", + "saving": "Сохранение...", + "enabled": "Фон включён", + "disabled": "Фон выключен", + "reducedOnMobile": "Облегчённый режим на мобильных", + "reducedOnMobileDesc": "Уменьшает количество частиц и отключает интерактивность", + "globalOpacity": "Прозрачность", + "globalBlur": "Размытие", + "categoryCSS": "CSS", + "categoryCanvas": "Canvas", + "categorySVG": "SVG", + "aurora": "Aurora", + "auroraDesc": "Северное сияние с плавными переливами", + "sparkles": "Sparkles", + "sparklesDesc": "Мерцающие частицы на фоне", + "vortex": "Vortex", + "vortexDesc": "Вращающийся водоворот частиц", + "shootingStars": "Shooting Stars", + "shootingStarsDesc": "Падающие звёзды на звёздном небе", + "beams": "Beams", + "beamsDesc": "Световые лучи по SVG-путям", + "beamsCollision": "Beams Collision", + "beamsCollisionDesc": "Взрывающиеся световые лучи", + "gradientAnimation": "Gradient Animation", + "gradientAnimationDesc": "Плавно движущиеся градиентные блобы", + "wavy": "Wavy", + "wavyDesc": "Волны на основе simplex noise", + "lines": "Lines", + "linesDesc": "Анимированные SVG-волны", + "boxes": "Boxes", + "boxesDesc": "Сетка интерактивных блоков", + "meteors": "Meteors", + "meteorsDesc": "Летящие метеоры", + "grid": "Grid", + "gridDesc": "Минималистичная сетка", + "dots": "Dots", + "dotsDesc": "Точечный паттерн", + "spotlight": "Spotlight", + "spotlightDesc": "Прожекторный луч", + "noise": "Noise", + "noiseDesc": "Шумовые градиенты с движением", + "ripple": "Ripple", + "rippleDesc": "Волновой эффект при клике", + "geminiEffect": "Gemini Effect", + "geminiEffectDesc": "SVG-эффект как на Google Gemini", + "speed": "Скорость", + "density": "Плотность частиц", + "particles": "Количество частиц", + "count": "Количество", + "hue": "Оттенок", + "rangeY": "Диапазон Y", + "minSize": "Мин. размер", + "maxSize": "Макс. размер", + "minSpeed": "Мин. скорость", + "maxSpeed": "Макс. скорость", + "particleColor": "Цвет частиц", + "starColor": "Цвет звёзд", + "trailColor": "Цвет следа", + "bgColor": "Цвет фона", + "fillColor": "Цвет заливки", + "color1": "Цвет 1", + "color2": "Цвет 2", + "color3": "Цвет 3", + "color4": "Цвет 4", + "color5": "Цвет 5", + "interactive": "Интерактивность (мышь)", + "size": "Размер", + "radialGradient": "Радиальный градиент", + "waveWidth": "Ширина волны", + "blurAmount": "Размытие", + "waveOpacity": "Прозрачность волн", + "duration": "Длительность", + "intensity": "Интенсивность", + "rows": "Строки", + "cols": "Столбцы", + "gridSize": "Размер сетки", + "small": "Мелкая", + "normal": "Обычная", + "slow": "Медленно", + "fast": "Быстро", + "none": "Без фона", + "noneDesc": "Отключить анимированный фон" + }, "nav": { "title": "Админка", "dashboard": "Статистика", diff --git a/src/locales/zh.json b/src/locales/zh.json index b42fac8..da947c9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -716,6 +716,96 @@ } }, "admin": { + "backgrounds": { + "title": "动画背景", + "description": "选择类型并配置动画参数", + "selectType": "背景类型", + "preview": "预览", + "settings": "设置", + "save": "保存", + "saved": "已保存", + "saving": "保存中...", + "enabled": "背景已启用", + "disabled": "背景已禁用", + "reducedOnMobile": "移动端轻量模式", + "reducedOnMobileDesc": "减少粒子数量并禁用交互", + "globalOpacity": "透明度", + "globalBlur": "模糊", + "categoryCSS": "CSS", + "categoryCanvas": "Canvas", + "categorySVG": "SVG", + "aurora": "极光", + "auroraDesc": "柔和渐变的北极光", + "sparkles": "闪烁", + "sparklesDesc": "背景中闪烁的粒子", + "vortex": "漩涡", + "vortexDesc": "旋转的粒子漩涡", + "shootingStars": "流星", + "shootingStarsDesc": "星空中的流星", + "beams": "光束", + "beamsDesc": "沿SVG路径的光线", + "beamsCollision": "碰撞光束", + "beamsCollisionDesc": "爆炸的光束", + "gradientAnimation": "渐变动画", + "gradientAnimationDesc": "平滑移动的渐变色块", + "wavy": "波浪", + "wavyDesc": "基于噪声的波浪", + "lines": "线条", + "linesDesc": "动画SVG波浪", + "boxes": "方块", + "boxesDesc": "交互式方块网格", + "meteors": "流星雨", + "meteorsDesc": "飞行的流星", + "grid": "网格", + "gridDesc": "极简网格图案", + "dots": "圆点", + "dotsDesc": "圆点图案", + "spotlight": "聚光灯", + "spotlightDesc": "聚光灯光束", + "noise": "噪声", + "noiseDesc": "带运动的噪声渐变", + "ripple": "涟漪", + "rippleDesc": "涟漪波纹效果", + "geminiEffect": "Gemini效果", + "geminiEffectDesc": "类似Google Gemini的SVG效果", + "speed": "速度", + "density": "粒子密度", + "particles": "粒子数量", + "count": "数量", + "hue": "色调", + "rangeY": "Y范围", + "minSize": "最小尺寸", + "maxSize": "最大尺寸", + "minSpeed": "最小速度", + "maxSpeed": "最大速度", + "particleColor": "粒子颜色", + "starColor": "星星颜色", + "trailColor": "尾迹颜色", + "bgColor": "背景颜色", + "fillColor": "填充颜色", + "color1": "颜色1", + "color2": "颜色2", + "color3": "颜色3", + "color4": "颜色4", + "color5": "颜色5", + "interactive": "交互(鼠标)", + "size": "大小", + "radialGradient": "径向渐变", + "waveWidth": "波浪宽度", + "blurAmount": "模糊", + "waveOpacity": "波浪透明度", + "duration": "持续时间", + "intensity": "强度", + "rows": "行", + "cols": "列", + "gridSize": "网格大小", + "small": "小", + "normal": "普通", + "slow": "慢", + "fast": "快", + "none": "无背景", + "noneDesc": "禁用动画背景" + }, "nav": { "title": "管理", "dashboard": "统计", diff --git a/src/styles/globals.css b/src/styles/globals.css index 15cec3f..9130d39 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4,6 +4,17 @@ @tailwind components; @tailwind utilities; +/* Aceternity UI background CSS variables */ +:root { + --blue-500: #3b82f6; + --indigo-300: #a5b4fc; + --blue-300: #93c5fd; + --violet-200: #ddd6fe; + --blue-400: #60a5fa; + --black: #000000; + --white: #ffffff; +} + @layer base { :root { --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); @@ -1445,174 +1456,6 @@ input[type='checkbox']:hover:not(:checked) { } } -/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */ -.wave-bg-container { - position: fixed; - inset: 0; - z-index: -1; - overflow: hidden; - pointer-events: none; - contain: strict; /* Performance: isolate repaints */ -} - -.wave-blob { - position: absolute; - border-radius: 50%; - filter: blur(60px); - opacity: 0.5; - transform: translateZ(0); - backface-visibility: hidden; -} - -/* Smaller sizes for better performance */ -.wave-blob-1 { - width: 400px; - height: 400px; - background: radial-gradient(circle, rgba(var(--color-accent-800), 0.6) 0%, transparent 70%); - top: -15%; - left: -10%; - animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */ -} - -.wave-blob-2 { - width: 350px; - height: 350px; - background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%); - bottom: -10%; - right: -10%; - animation: wave2 25s ease-in-out infinite; -} - -.wave-blob-3 { - width: 300px; - height: 300px; - background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%); - top: 40%; - left: 40%; - animation: wave3 30s ease-in-out infinite; -} - -.wave-blob-4 { - width: 250px; - height: 250px; - background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%); - bottom: 25%; - left: 15%; - animation: wave4 22s ease-in-out infinite; -} - -/* Simplified keyframes - fewer steps = better performance */ -@keyframes wave1 { - 0%, - 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 50% { - transform: translate3d(8%, 12%, 0) scale(1.05); - } -} - -@keyframes wave2 { - 0%, - 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 50% { - transform: translate3d(-10%, -8%, 0) scale(1.08); - } -} - -@keyframes wave3 { - 0%, - 100% { - transform: translate3d(-50%, -50%, 0) scale(1); - } - 50% { - transform: translate3d(-45%, -55%, 0) scale(1.1); - } -} - -@keyframes wave4 { - 0%, - 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 50% { - transform: translate3d(12%, -8%, 0) scale(1.12); - } -} - -/* Light theme adjustments */ -.light .wave-blob { - opacity: 0.35; - filter: blur(80px); -} - -.light .wave-blob-1 { - background: radial-gradient(circle, rgba(var(--color-accent-800), 0.7) 0%, transparent 70%); -} - -/* Mobile: brighter and faster animations */ -@media (max-width: 768px) { - .wave-blob { - opacity: 0.7; - filter: blur(50px); - } - - .wave-blob-1 { - width: 300px; - height: 300px; - background: radial-gradient(circle, rgba(var(--color-accent-800), 0.8) 0%, transparent 70%); - animation: wave1-mobile 12s ease-in-out infinite; - } - - .wave-blob-2 { - width: 280px; - height: 280px; - background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%); - animation: wave2-mobile 15s ease-in-out infinite; - } - - @keyframes wave1-mobile { - 0%, - 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 25% { - transform: translate3d(15%, 20%, 0) scale(1.1); - } - 50% { - transform: translate3d(5%, 35%, 0) scale(1.15); - } - 75% { - transform: translate3d(20%, 10%, 0) scale(1.05); - } - } - - @keyframes wave2-mobile { - 0%, - 100% { - transform: translate3d(0, 0, 0) scale(1); - } - 25% { - transform: translate3d(-20%, -15%, 0) scale(1.15); - } - 50% { - transform: translate3d(-10%, -30%, 0) scale(1.1); - } - 75% { - transform: translate3d(-25%, -5%, 0) scale(1.05); - } - } -} - -/* Disable animations for reduced motion preference */ -@media (prefers-reduced-motion: reduce) { - .wave-blob { - animation: none !important; - } -} - /* User preference: reduce motion */ .reduce-motion, .reduce-motion * { diff --git a/tailwind.config.js b/tailwind.config.js index e87b095..d107268 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -164,6 +164,15 @@ export default { float: 'float 3s ease-in-out infinite', 'glow-pulse': 'glowPulse 2s ease-in-out infinite', spotlight: 'spotlight 2s ease-in-out infinite', + // Aceternity UI background animations + aurora: 'aurora 60s linear infinite', + 'meteor-effect': 'meteor 5s linear infinite', + 'move-vertical': 'moveVertical 30s ease infinite', + 'move-in-circle': 'moveInCircle 20s reverse infinite', + 'move-in-circle-slow': 'moveInCircle 40s linear infinite', + 'move-horizontal': 'moveHorizontal 40s ease infinite', + 'move-in-circle-fast': 'moveInCircle 20s ease infinite', + 'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards', }, keyframes: { fadeIn: { @@ -213,6 +222,35 @@ export default { '0 0 0 8px rgba(var(--color-accent-500), 0.2), 0 0 40px rgba(var(--color-accent-500), 0.5)', }, }, + // Aceternity UI keyframes + aurora: { + from: { backgroundPosition: '50% 50%, 50% 50%' }, + to: { backgroundPosition: '350% 50%, 350% 50%' }, + }, + meteor: { + '0%': { transform: 'rotate(215deg) translateX(0)', opacity: '1' }, + '70%': { opacity: '1' }, + '100%': { transform: 'rotate(215deg) translateX(-500px)', opacity: '0' }, + }, + moveVertical: { + '0%': { transform: 'translateY(-50%)' }, + '50%': { transform: 'translateY(50%)' }, + '100%': { transform: 'translateY(-50%)' }, + }, + moveInCircle: { + '0%': { transform: 'rotate(0deg)' }, + '50%': { transform: 'rotate(180deg)' }, + '100%': { transform: 'rotate(360deg)' }, + }, + moveHorizontal: { + '0%': { transform: 'translateX(-50%) translateY(-10%)' }, + '50%': { transform: 'translateX(50%) translateY(10%)' }, + '100%': { transform: 'translateX(-50%) translateY(-10%)' }, + }, + spotlightAce: { + '0%': { opacity: '0', transform: 'translate(-72%, -62%) scale(0.5)' }, + '100%': { opacity: '1', transform: 'translate(-50%, -40%) scale(1)' }, + }, }, transitionTimingFunction: { smooth: 'cubic-bezier(0.4, 0, 0.2, 1)', From de97a030d2ebffcfe957e179f32b2857d13465dc Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:31:16 +0300 Subject: [PATCH 02/24] fix: rewrite 5 broken background components from Aceternity sources - Aurora: remove invert filter (broken on dark bg), use dark gradient directly - Beams: use animated linearGradient positions instead of pathLength animation - Beams Collision: add collision detection, explosion particles, AnimatePresence - Gemini Effect: rewrite as SVG motion.path with animated pathLength (was canvas) - Noise: change mixBlendMode from overlay to screen (overlay invisible on dark bg) --- .../ui/backgrounds/aurora-background.tsx | 33 ++- .../background-beams-collision.tsx | 212 ++++++++++++++---- .../ui/backgrounds/background-beams.tsx | 105 ++++++--- .../ui/backgrounds/google-gemini-effect.tsx | 182 ++++++--------- .../ui/backgrounds/noise-background.tsx | 4 +- 5 files changed, 320 insertions(+), 216 deletions(-) diff --git a/src/components/ui/backgrounds/aurora-background.tsx b/src/components/ui/backgrounds/aurora-background.tsx index b76fad2..4e10331 100644 --- a/src/components/ui/backgrounds/aurora-background.tsx +++ b/src/components/ui/backgrounds/aurora-background.tsx @@ -5,32 +5,29 @@ interface Props { } export default function AuroraBackground({ settings }: Props) { - const speed = (settings.speed as number) ?? 60; const showRadialGradient = (settings.showRadialGradient as boolean) ?? true; return ( -
+
); diff --git a/src/components/ui/backgrounds/background-beams-collision.tsx b/src/components/ui/backgrounds/background-beams-collision.tsx index 7782af7..64c50da 100644 --- a/src/components/ui/backgrounds/background-beams-collision.tsx +++ b/src/components/ui/backgrounds/background-beams-collision.tsx @@ -1,63 +1,179 @@ -import { useRef, useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; +import React, { useRef, useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '@/lib/utils'; interface Props { settings: Record; } -interface Beam { - id: number; - x: number; +interface BeamOptions { + initialX: number; + translateX: number; duration: number; - delay: number; - rotate: number; + repeatDelay: number; + delay?: number; + className?: string; } -export default function BackgroundBeamsCollision({ settings: _settings }: Props) { - const [beams, setBeams] = useState([]); - const containerRef = useRef(null); +const BEAMS: BeamOptions[] = [ + { initialX: 10, translateX: 10, duration: 7, repeatDelay: 3, delay: 2 }, + { initialX: 600, translateX: 600, duration: 3, repeatDelay: 3, delay: 4 }, + { initialX: 100, translateX: 100, duration: 7, repeatDelay: 7, className: 'h-6' }, + { initialX: 400, translateX: 400, duration: 5, repeatDelay: 14, delay: 4 }, + { initialX: 800, translateX: 800, duration: 11, repeatDelay: 2, className: 'h-20' }, + { initialX: 1000, translateX: 1000, duration: 4, repeatDelay: 2, className: 'h-12' }, + { initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' }, +]; - useEffect(() => { - const w = containerRef.current?.offsetWidth ?? window.innerWidth; - const count = Math.floor(w / 100); - setBeams( - Array.from({ length: count }, (_, i) => ({ - id: i, - x: (i / count) * 100 + Math.random() * 10, - duration: 4 + Math.random() * 8, - delay: Math.random() * 4, - rotate: -5 + Math.random() * 10, - })), - ); - }, []); +function Explosion(props: React.HTMLProps) { + const spans = Array.from({ length: 20 }, (_, i) => ({ + id: i, + directionX: Math.floor(Math.random() * 80 - 40), + directionY: Math.floor(Math.random() * -50 - 10), + })); return ( -
- {beams.map((beam) => ( - -
- +
+ + {spans.map((span) => ( + ))} - {/* Bottom collision line */} -
+
+ ); +} + +function CollisionMechanism({ + containerRef, + parentRef, + beamOptions, +}: { + containerRef: React.RefObject; + parentRef: React.RefObject; + beamOptions: BeamOptions; +}) { + const beamRef = useRef(null); + const [collision, setCollision] = useState<{ + detected: boolean; + coordinates: { x: number; y: number } | null; + }>({ detected: false, coordinates: null }); + const [beamKey, setBeamKey] = useState(0); + const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false); + + useEffect(() => { + const checkCollision = () => { + if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) { + const beamRect = beamRef.current.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + const parentRect = parentRef.current.getBoundingClientRect(); + + if (beamRect.bottom >= containerRect.top) { + const relativeX = beamRect.left - parentRect.left + beamRect.width / 2; + const relativeY = beamRect.bottom - parentRect.top; + + setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } }); + setCycleCollisionDetected(true); + } + } + }; + + const interval = setInterval(checkCollision, 50); + return () => clearInterval(interval); + }, [cycleCollisionDetected, containerRef, parentRef]); + + useEffect(() => { + if (collision.detected && collision.coordinates) { + setTimeout(() => { + setCollision({ detected: false, coordinates: null }); + setCycleCollisionDetected(false); + }, 2000); + setTimeout(() => { + setBeamKey((prev) => prev + 1); + }, 2000); + } + }, [collision]); + + return ( + <> + + + {collision.detected && collision.coordinates && ( + + )} + + + ); +} + +export default function BackgroundBeamsCollision({ settings: _settings }: Props) { + const containerRef = useRef(null); + const parentRef = useRef(null); + + return ( +
+ {BEAMS.map((beam) => ( + + ))} + {/* Bottom collision line */} +
); } diff --git a/src/components/ui/backgrounds/background-beams.tsx b/src/components/ui/backgrounds/background-beams.tsx index 9054a89..e36b828 100644 --- a/src/components/ui/backgrounds/background-beams.tsx +++ b/src/components/ui/backgrounds/background-beams.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import React from 'react'; import { motion } from 'framer-motion'; interface Props { @@ -16,61 +16,94 @@ const paths = [ 'M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819', 'M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811', 'M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803', + 'M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795', + 'M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787', + 'M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779', + 'M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771', + 'M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763', + 'M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755', + 'M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747', + 'M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739', + 'M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731', + 'M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723', ]; -export default function BackgroundBeams({ settings: _settings }: Props) { - const svgRef = useRef(null); - - useEffect(() => { - const handleVisibility = () => { - if (svgRef.current) { - svgRef.current.style.display = document.hidden ? 'none' : ''; - } - }; - document.addEventListener('visibilitychange', handleVisibility); - return () => document.removeEventListener('visibilitychange', handleVisibility); - }, []); +// Static background paths (subset for the radial gradient overlay) +const bgPath = + 'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803'; +export default React.memo(function BackgroundBeams({ settings: _settings }: Props) { return (
- {paths.map((path, i) => ( + {/* Static background paths */} + + + {/* Animated beam paths with moving gradients */} + {paths.map((path, index) => ( ))} + - {paths.map((_, i) => ( - + {/* Animated linear gradients that move along the paths */} + {paths.map((_, index) => ( + - - - + + + ))} + + {/* Radial gradient for static background */} + + + + +
); -} +}); diff --git a/src/components/ui/backgrounds/google-gemini-effect.tsx b/src/components/ui/backgrounds/google-gemini-effect.tsx index 2aa7530..71ae9d1 100644 --- a/src/components/ui/backgrounds/google-gemini-effect.tsx +++ b/src/components/ui/backgrounds/google-gemini-effect.tsx @@ -1,130 +1,88 @@ -import { useEffect, useRef } from 'react'; +import { motion, useMotionValue, useTransform, animate } from 'framer-motion'; +import { useEffect } from 'react'; interface Props { settings: Record; } +const transition = { duration: 0, ease: 'linear' as const }; + const PATHS = [ - 'M0 663C145.5 663 191 666.265 269 647C326.5 630 339.5 621 397.5 566C439 531.5 455 529.5 490 523C509.664 519.348 521 503.736 538 504.236C553.591 504.236 562.429 514.739 584.66 522.749C592.042 525.408 600.2 526.237 607.356 523.019C624.755 515.195 641.446 496.324 657 496.735C673.408 496.735 693.545 519.572 712.903 526.769C718.727 528.934 725.184 528.395 730.902 525.965C751.726 517.115 764.085 497.106 782 496.735', - 'M0 587.5C147 587.5 277 587.5 310 573.5C348 563 392.5 543.5 408 535C434 523.5 426 526.235 479 515.235C494 512.729 523 510.435 534.5 512.735C554.5 516.735 555.5 523.235 576 523.735C592 523.735 616 496.735 633 497.235C648.671 497.235 661.31 515.052 684.774 524.942C692.004 527.989 700.2 528.738 707.349 525.505', - 'M0 514C147.5 514.333 294.5 513.735 380.5 513.735C405.976 514.94 422.849 515.228 436.37 515.123C477.503 514.803 518.631 506.605 559.508 511.197C564.04 511.706 569.162 512.524 575 513.735C588 516.433 616 521.702 627.5 519.402C647.5 515.402 659 499.235 680.5 499.235', - 'M0 438.5C150.5 438.5 261 438.318 323.5 456.5C351 464.5 387.517 484.001 423.5 494.5C447.371 501.465 472 503.735 487 507.735C503.786 512.212 504.5 516.808 523 518.735C547 521.235 564.814 501.235 584.5 501.235', - 'M0.5 364C145.288 362.349 195 361.5 265.5 378C322 391.223 399.182 457.5 411 467.5C424.176 478.649 456.916 491.677 496.259 502.699C498.746 503.396 501.16 504.304 503.511 505.374C517.104 511.558 541.149 520.911 551.5 521.236', + 'M0 663C145.5 663 191 666.265 269 647C326.5 630 339.5 621 397.5 566C439 531.5 455 529.5 490 523C509.664 519.348 521 503.736 538 504.236C553.591 504.236 562.429 514.739 584.66 522.749C592.042 525.408 600.2 526.237 607.356 523.019C624.755 515.195 641.446 496.324 657 496.735C673.408 496.735 693.545 519.572 712.903 526.769C718.727 528.934 725.184 528.395 730.902 525.965C751.726 517.115 764.085 497.106 782 496.735C794.831 496.47 804.103 508.859 822.469 518.515C835.13 525.171 850.214 526.815 862.827 520.069C875.952 513.049 889.748 502.706 903.5 503.736C922.677 505.171 935.293 510.562 945.817 515.673C954.234 519.76 963.095 522.792 972.199 524.954C996.012 530.611 1007.42 534.118 1034 549C1077.5 573.359 1082.5 594.5 1140 629C1206 670 1328.5 662.5 1440 662.5', + 'M0 587.5C147 587.5 277 587.5 310 573.5C348 563 392.5 543.5 408 535C434 523.5 426 526.235 479 515.235C494 512.729 523 510.435 534.5 512.735C554.5 516.735 555.5 523.235 576 523.735C592 523.735 616 496.735 633 497.235C648.671 497.235 661.31 515.052 684.774 524.942C692.004 527.989 700.2 528.738 707.349 525.505C724.886 517.575 741.932 498.33 757.5 498.742C773.864 498.742 791.711 520.623 810.403 527.654C816.218 529.841 822.661 529.246 828.451 526.991C849.246 518.893 861.599 502.112 879.5 501.742C886.47 501.597 896.865 506.047 907.429 510.911C930.879 521.707 957.139 519.639 982.951 520.063C1020.91 520.686 1037.5 530.797 1056.5 537C1102.24 556.627 1116.5 570.704 1180.5 579.235C1257.5 589.5 1279 587 1440 588', + 'M0 514C147.5 514.333 294.5 513.735 380.5 513.735C405.976 514.94 422.849 515.228 436.37 515.123C477.503 514.803 518.631 506.605 559.508 511.197C564.04 511.706 569.162 512.524 575 513.735C588 516.433 616 521.702 627.5 519.402C647.5 515.402 659 499.235 680.5 499.235C700.5 499.235 725 529.235 742 528.735C757.654 528.735 768.77 510.583 791.793 500.59C798.991 497.465 807.16 496.777 814.423 499.745C832.335 507.064 850.418 524.648 866 524.235C882.791 524.235 902.316 509.786 921.814 505.392C926.856 504.255 932.097 504.674 937.176 505.631C966.993 511.248 970.679 514.346 989.5 514.735C1006.3 515.083 1036.5 513.235 1055.5 513.235C1114.5 513.235 1090.5 513.235 1124 513.235C1177.5 513.235 1178.99 514.402 1241 514.402C1317.5 514.402 1274.5 512.568 1440 513.235', + 'M0 438.5C150.5 438.5 261 438.318 323.5 456.5C351 464.5 387.517 484.001 423.5 494.5C447.371 501.465 472 503.735 487 507.735C503.786 512.212 504.5 516.808 523 518.735C547 521.235 564.814 501.235 584.5 501.235C604.5 501.235 626 529.069 643 528.569C658.676 528.569 672.076 511.63 695.751 501.972C703.017 499.008 711.231 498.208 718.298 501.617C735.448 509.889 751.454 529.98 767 529.569C783.364 529.569 801.211 507.687 819.903 500.657C825.718 498.469 832.141 499.104 837.992 501.194C859.178 508.764 873.089 523.365 891 523.735C907.8 524.083 923 504.235 963 506.735C1034.5 506.735 1047.5 492.68 1071 481.5C1122.5 457 1142.23 452.871 1185 446.5C1255.5 436 1294 439 1439.5 439', + 'M0.5 364C145.288 362.349 195 361.5 265.5 378C322 391.223 399.182 457.5 411 467.5C424.176 478.649 456.916 491.677 496.259 502.699C498.746 503.396 501.16 504.304 503.511 505.374C517.104 511.558 541.149 520.911 551.5 521.236C571.5 521.236 590 498.736 611.5 498.736C631.5 498.736 652.5 529.236 669.5 528.736C685.171 528.736 697.81 510.924 721.274 501.036C728.505 497.988 736.716 497.231 743.812 500.579C761.362 508.857 778.421 529.148 794 528.736C810.375 528.736 829.35 508.68 848.364 502.179C854.243 500.169 860.624 500.802 866.535 502.718C886.961 509.338 898.141 519.866 916 520.236C932.8 520.583 934.5 510.236 967.5 501.736C1011.5 491 1007.5 493.5 1029.5 480C1069.5 453.5 1072 440.442 1128.5 403.5C1180.5 369.5 1275 360.374 1439 364', ]; const COLORS = ['#FFB7C5', '#FFDDB7', '#B1C5FF', '#4FABFF', '#076EFF']; export default function GoogleGeminiEffect({ settings }: Props) { - const canvasRef = useRef(null); - const animationRef = useRef(0); + const speed = (settings.speed as number) ?? 8; - const speed = (settings.speed as number) ?? 0.003; - const lineWidth = (settings.lineWidth as number) ?? 2; + // Animated scroll substitute: cycle 0 → 1 over time + const progress = useMotionValue(0); + + const pathLengths = [ + useTransform(progress, [0, 0.8], [0, 1.2]), + useTransform(progress, [0, 0.8], [0, 1.1]), + useTransform(progress, [0, 0.8], [0, 1.0]), + useTransform(progress, [0, 0.8], [0, 0.9]), + useTransform(progress, [0, 0.8], [0, 0.85]), + ]; useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const controls = animate(progress, [0, 1, 0], { + duration: speed, + repeat: Infinity, + ease: 'easeInOut', + }); + return () => controls.stop(); + }, [progress, speed]); - const ctx = canvas.getContext('2d'); - if (!ctx) return; + return ( +
+ + {/* Animated colored paths */} + {PATHS.map((d, i) => ( + + ))} - let progress = 0; + {/* Blurred glow copies */} + {PATHS.map((d, i) => ( + + ))} - const resize = () => { - canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth; - canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight; - }; - - // Parse SVG path to points - const parsePath = (d: string): { x: number; y: number }[] => { - const points: { x: number; y: number }[] = []; - const commands = d.match(/[MC]\s*[\d.\s,-]+/g); - if (!commands) return points; - - for (const cmd of commands) { - const nums = cmd - .slice(1) - .trim() - .split(/[\s,]+/) - .map(Number); - for (let i = 0; i < nums.length; i += 2) { - if (!isNaN(nums[i]) && !isNaN(nums[i + 1])) { - points.push({ x: nums[i], y: nums[i + 1] }); - } - } - } - return points; - }; - - const allPaths = PATHS.map(parsePath); - - const animate = () => { - progress += speed; - if (progress > 2) progress = 0; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const scaleX = canvas.width / 1440; - const scaleY = canvas.height / 890; - - for (let p = 0; p < allPaths.length; p++) { - const points = allPaths[p]; - if (points.length < 2) continue; - - const pathProgress = Math.max(0, Math.min(1, progress - p * 0.1)); - const drawCount = Math.floor(points.length * pathProgress); - - if (drawCount < 2) continue; - - ctx.beginPath(); - ctx.strokeStyle = COLORS[p % COLORS.length]; - ctx.lineWidth = lineWidth; - ctx.globalAlpha = 0.6; - - ctx.moveTo(points[0].x * scaleX, points[0].y * scaleY); - for (let i = 1; i < drawCount; i++) { - ctx.lineTo(points[i].x * scaleX, points[i].y * scaleY); - } - ctx.stroke(); - } - - // Blur pass - ctx.globalAlpha = 0.3; - ctx.filter = 'blur(5px)'; - for (let p = 0; p < allPaths.length; p++) { - const points = allPaths[p]; - if (points.length < 2) continue; - - const pathProgress = Math.max(0, Math.min(1, progress - p * 0.1)); - const drawCount = Math.floor(points.length * pathProgress); - - if (drawCount < 2) continue; - - ctx.beginPath(); - ctx.strokeStyle = COLORS[p % COLORS.length]; - ctx.lineWidth = lineWidth; - - ctx.moveTo(points[0].x * scaleX, points[0].y * scaleY); - for (let i = 1; i < drawCount; i++) { - ctx.lineTo(points[i].x * scaleX, points[i].y * scaleY); - } - ctx.stroke(); - } - - ctx.filter = 'none'; - ctx.globalAlpha = 1; - animationRef.current = requestAnimationFrame(animate); - }; - - resize(); - animationRef.current = requestAnimationFrame(animate); - window.addEventListener('resize', resize); - - return () => { - cancelAnimationFrame(animationRef.current); - window.removeEventListener('resize', resize); - }; - }, [speed, lineWidth]); - - return ; + + + + + + +
+ ); } diff --git a/src/components/ui/backgrounds/noise-background.tsx b/src/components/ui/backgrounds/noise-background.tsx index 4418baf..ec26007 100644 --- a/src/components/ui/backgrounds/noise-background.tsx +++ b/src/components/ui/backgrounds/noise-background.tsx @@ -50,7 +50,7 @@ export default function NoiseBackground({ settings }: Props) { } return () => { - if (animated) cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current); }; }, [animated]); @@ -62,7 +62,7 @@ export default function NoiseBackground({ settings }: Props) { style={{ opacity: noiseOpacity, imageRendering: 'pixelated', - mixBlendMode: 'overlay', + mixBlendMode: 'screen', }} />
From 79ff7412cbc374b7ac085b6c8d3fd5f34de8ce37 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:43:41 +0300 Subject: [PATCH 03/24] fix: remove gemini-effect and noise backgrounds, fix aurora animation - Delete google-gemini-effect.tsx and noise-background.tsx - Remove from types, registry, and backend whitelist - Rewrite aurora: use animate-aurora directly on element instead of invisible pseudo-element with mix-blend-difference --- .../ui/backgrounds/aurora-background.tsx | 24 ++--- .../ui/backgrounds/google-gemini-effect.tsx | 88 ------------------- .../ui/backgrounds/noise-background.tsx | 70 --------------- src/components/ui/backgrounds/registry.ts | 47 ---------- src/components/ui/backgrounds/types.ts | 2 - 5 files changed, 8 insertions(+), 223 deletions(-) delete mode 100644 src/components/ui/backgrounds/google-gemini-effect.tsx delete mode 100644 src/components/ui/backgrounds/noise-background.tsx diff --git a/src/components/ui/backgrounds/aurora-background.tsx b/src/components/ui/backgrounds/aurora-background.tsx index 4e10331..24cc061 100644 --- a/src/components/ui/backgrounds/aurora-background.tsx +++ b/src/components/ui/backgrounds/aurora-background.tsx @@ -8,26 +8,18 @@ export default function AuroraBackground({ settings }: Props) { const showRadialGradient = (settings.showRadialGradient as boolean) ?? true; return ( -
+
); diff --git a/src/components/ui/backgrounds/google-gemini-effect.tsx b/src/components/ui/backgrounds/google-gemini-effect.tsx deleted file mode 100644 index 71ae9d1..0000000 --- a/src/components/ui/backgrounds/google-gemini-effect.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { motion, useMotionValue, useTransform, animate } from 'framer-motion'; -import { useEffect } from 'react'; - -interface Props { - settings: Record; -} - -const transition = { duration: 0, ease: 'linear' as const }; - -const PATHS = [ - 'M0 663C145.5 663 191 666.265 269 647C326.5 630 339.5 621 397.5 566C439 531.5 455 529.5 490 523C509.664 519.348 521 503.736 538 504.236C553.591 504.236 562.429 514.739 584.66 522.749C592.042 525.408 600.2 526.237 607.356 523.019C624.755 515.195 641.446 496.324 657 496.735C673.408 496.735 693.545 519.572 712.903 526.769C718.727 528.934 725.184 528.395 730.902 525.965C751.726 517.115 764.085 497.106 782 496.735C794.831 496.47 804.103 508.859 822.469 518.515C835.13 525.171 850.214 526.815 862.827 520.069C875.952 513.049 889.748 502.706 903.5 503.736C922.677 505.171 935.293 510.562 945.817 515.673C954.234 519.76 963.095 522.792 972.199 524.954C996.012 530.611 1007.42 534.118 1034 549C1077.5 573.359 1082.5 594.5 1140 629C1206 670 1328.5 662.5 1440 662.5', - 'M0 587.5C147 587.5 277 587.5 310 573.5C348 563 392.5 543.5 408 535C434 523.5 426 526.235 479 515.235C494 512.729 523 510.435 534.5 512.735C554.5 516.735 555.5 523.235 576 523.735C592 523.735 616 496.735 633 497.235C648.671 497.235 661.31 515.052 684.774 524.942C692.004 527.989 700.2 528.738 707.349 525.505C724.886 517.575 741.932 498.33 757.5 498.742C773.864 498.742 791.711 520.623 810.403 527.654C816.218 529.841 822.661 529.246 828.451 526.991C849.246 518.893 861.599 502.112 879.5 501.742C886.47 501.597 896.865 506.047 907.429 510.911C930.879 521.707 957.139 519.639 982.951 520.063C1020.91 520.686 1037.5 530.797 1056.5 537C1102.24 556.627 1116.5 570.704 1180.5 579.235C1257.5 589.5 1279 587 1440 588', - 'M0 514C147.5 514.333 294.5 513.735 380.5 513.735C405.976 514.94 422.849 515.228 436.37 515.123C477.503 514.803 518.631 506.605 559.508 511.197C564.04 511.706 569.162 512.524 575 513.735C588 516.433 616 521.702 627.5 519.402C647.5 515.402 659 499.235 680.5 499.235C700.5 499.235 725 529.235 742 528.735C757.654 528.735 768.77 510.583 791.793 500.59C798.991 497.465 807.16 496.777 814.423 499.745C832.335 507.064 850.418 524.648 866 524.235C882.791 524.235 902.316 509.786 921.814 505.392C926.856 504.255 932.097 504.674 937.176 505.631C966.993 511.248 970.679 514.346 989.5 514.735C1006.3 515.083 1036.5 513.235 1055.5 513.235C1114.5 513.235 1090.5 513.235 1124 513.235C1177.5 513.235 1178.99 514.402 1241 514.402C1317.5 514.402 1274.5 512.568 1440 513.235', - 'M0 438.5C150.5 438.5 261 438.318 323.5 456.5C351 464.5 387.517 484.001 423.5 494.5C447.371 501.465 472 503.735 487 507.735C503.786 512.212 504.5 516.808 523 518.735C547 521.235 564.814 501.235 584.5 501.235C604.5 501.235 626 529.069 643 528.569C658.676 528.569 672.076 511.63 695.751 501.972C703.017 499.008 711.231 498.208 718.298 501.617C735.448 509.889 751.454 529.98 767 529.569C783.364 529.569 801.211 507.687 819.903 500.657C825.718 498.469 832.141 499.104 837.992 501.194C859.178 508.764 873.089 523.365 891 523.735C907.8 524.083 923 504.235 963 506.735C1034.5 506.735 1047.5 492.68 1071 481.5C1122.5 457 1142.23 452.871 1185 446.5C1255.5 436 1294 439 1439.5 439', - 'M0.5 364C145.288 362.349 195 361.5 265.5 378C322 391.223 399.182 457.5 411 467.5C424.176 478.649 456.916 491.677 496.259 502.699C498.746 503.396 501.16 504.304 503.511 505.374C517.104 511.558 541.149 520.911 551.5 521.236C571.5 521.236 590 498.736 611.5 498.736C631.5 498.736 652.5 529.236 669.5 528.736C685.171 528.736 697.81 510.924 721.274 501.036C728.505 497.988 736.716 497.231 743.812 500.579C761.362 508.857 778.421 529.148 794 528.736C810.375 528.736 829.35 508.68 848.364 502.179C854.243 500.169 860.624 500.802 866.535 502.718C886.961 509.338 898.141 519.866 916 520.236C932.8 520.583 934.5 510.236 967.5 501.736C1011.5 491 1007.5 493.5 1029.5 480C1069.5 453.5 1072 440.442 1128.5 403.5C1180.5 369.5 1275 360.374 1439 364', -]; - -const COLORS = ['#FFB7C5', '#FFDDB7', '#B1C5FF', '#4FABFF', '#076EFF']; - -export default function GoogleGeminiEffect({ settings }: Props) { - const speed = (settings.speed as number) ?? 8; - - // Animated scroll substitute: cycle 0 → 1 over time - const progress = useMotionValue(0); - - const pathLengths = [ - useTransform(progress, [0, 0.8], [0, 1.2]), - useTransform(progress, [0, 0.8], [0, 1.1]), - useTransform(progress, [0, 0.8], [0, 1.0]), - useTransform(progress, [0, 0.8], [0, 0.9]), - useTransform(progress, [0, 0.8], [0, 0.85]), - ]; - - useEffect(() => { - const controls = animate(progress, [0, 1, 0], { - duration: speed, - repeat: Infinity, - ease: 'easeInOut', - }); - return () => controls.stop(); - }, [progress, speed]); - - return ( -
- - {/* Animated colored paths */} - {PATHS.map((d, i) => ( - - ))} - - {/* Blurred glow copies */} - {PATHS.map((d, i) => ( - - ))} - - - - - - - -
- ); -} diff --git a/src/components/ui/backgrounds/noise-background.tsx b/src/components/ui/backgrounds/noise-background.tsx deleted file mode 100644 index ec26007..0000000 --- a/src/components/ui/backgrounds/noise-background.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { sanitizeColor, clampNumber } from './types'; - -interface Props { - settings: Record; -} - -export default function NoiseBackground({ settings }: Props) { - const canvasRef = useRef(null); - - const noiseOpacity = clampNumber(settings.noiseOpacity, 0.01, 1, 0.15); - const baseColor = sanitizeColor(settings.baseColor, '#0a0a1a'); - const animated = (settings.animated as boolean) ?? false; - const animationRef = useRef(0); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const size = 128; - canvas.width = size; - canvas.height = size; - - const drawNoise = () => { - const imageData = ctx.createImageData(size, size); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const v = Math.random() * 255; - data[i] = v; - data[i + 1] = v; - data[i + 2] = v; - data[i + 3] = 255; - } - - ctx.putImageData(imageData, 0, 0); - }; - - drawNoise(); - - if (animated) { - const loop = () => { - drawNoise(); - animationRef.current = requestAnimationFrame(loop); - }; - animationRef.current = requestAnimationFrame(loop); - } - - return () => { - cancelAnimationFrame(animationRef.current); - }; - }, [animated]); - - return ( -
- -
- ); -} diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 9421da2..7f1925a 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -20,9 +20,7 @@ export const backgroundComponents: Record< grid: lazy(() => import('./grid-background')), dots: lazy(() => import('./grid-background')), spotlight: lazy(() => import('./spotlight-bg')), - noise: lazy(() => import('./noise-background')), ripple: lazy(() => import('./background-ripple')), - 'gemini-effect': lazy(() => import('./google-gemini-effect')), }; // Registry of all background definitions with settings for the editor @@ -464,25 +462,6 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, - { - type: 'noise', - labelKey: 'admin.backgrounds.noise', - descriptionKey: 'admin.backgrounds.noiseDesc', - category: 'canvas', - settings: [ - { - key: 'noiseOpacity', - label: 'admin.backgrounds.intensity', - type: 'number', - min: 0.05, - max: 0.5, - step: 0.05, - default: 0.15, - }, - { key: 'baseColor', label: 'admin.backgrounds.bgColor', type: 'color', default: '#0a0a1a' }, - { key: 'animated', label: 'admin.backgrounds.interactive', type: 'boolean', default: false }, - ], - }, { type: 'ripple', labelKey: 'admin.backgrounds.ripple', @@ -515,30 +494,4 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, - { - type: 'gemini-effect', - labelKey: 'admin.backgrounds.geminiEffect', - descriptionKey: 'admin.backgrounds.geminiEffectDesc', - category: 'canvas', - settings: [ - { - key: 'speed', - label: 'admin.backgrounds.speed', - type: 'number', - min: 0.001, - max: 0.01, - step: 0.001, - default: 0.003, - }, - { - key: 'lineWidth', - label: 'admin.backgrounds.waveWidth', - type: 'number', - min: 1, - max: 5, - step: 0.5, - default: 2, - }, - ], - }, ]; diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index e6cba62..4bcabda 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -13,9 +13,7 @@ export type BackgroundType = | 'grid' | 'dots' | 'spotlight' - | 'noise' | 'ripple' - | 'gemini-effect' | 'none'; export interface AnimationConfig { From 94ddf319bd242211cbebf74e89a6052856f84f60 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:48:49 +0300 Subject: [PATCH 04/24] fix: animation config not updating for users after admin change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove refetchOnWindowFocus: false — allow refetch on miniapp return - Add initialDataUpdatedAt: 0 — treat localStorage cache as stale, refetch immediately instead of serving cached data for 60s - Lower staleTime to 30s --- src/components/backgrounds/BackgroundRenderer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index 36d6419..17137b6 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -54,8 +54,8 @@ export function BackgroundRenderer() { return result; }, initialData: getCachedConfig() ?? undefined, - staleTime: 60_000, - refetchOnWindowFocus: false, + initialDataUpdatedAt: 0, + staleTime: 30_000, }); const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG; From f16f96e442506484eae9434ae51c5f0f2fc45729 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:53:30 +0300 Subject: [PATCH 05/24] fix: boxes background not covering full screen - Use original Aceternity positioning: translate(-40%,-60%) with -top-1/4 left-1/4 to cover viewport after skew transform - Switch from CSS grid to flex layout matching original structure - Add cross (+) SVG markers at grid intersections - Use pastel color palette from original, animate opacity per cell - Increase default rows to 20 for better coverage --- .../ui/backgrounds/background-boxes.tsx | 94 ++++++++++++------- src/components/ui/backgrounds/registry.ts | 12 +-- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 78decc0..0f000dc 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { motion } from 'framer-motion'; import { sanitizeColor, clampNumber } from './types'; @@ -6,17 +6,35 @@ interface Props { settings: Record; } -export default function BackgroundBoxes({ settings }: Props) { - const rows = clampNumber(settings.rows, 2, 30, 12); - const cols = clampNumber(settings.cols, 2, 30, 12); +const COLORS = [ + '#93c5fd', + '#f9a8d4', + '#86efac', + '#fde047', + '#fca5a5', + '#d8b4fe', + '#a5b4fc', + '#c4b5fd', +]; + +export default React.memo(function BackgroundBoxes({ settings }: Props) { + const rows = clampNumber(settings.rows, 4, 30, 20); + const cols = clampNumber(settings.cols, 4, 30, 12); const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); - const boxes = useMemo(() => { - const result: { row: number; col: number; color: string }[] = []; - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - result.push({ row: r, col: c, color: boxColor }); + const grid = useMemo(() => { + const result: { color: string; delay: number; duration: number }[][] = []; + for (let i = 0; i < rows; i++) { + const row: { color: string; delay: number; duration: number }[] = []; + for (let j = 0; j < cols; j++) { + row.push({ + color: + boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, + delay: Math.random() * 8, + duration: 3 + Math.random() * 4, + }); } + result.push(row); } return result; }, [rows, cols, boxColor]); @@ -24,33 +42,45 @@ export default function BackgroundBoxes({ settings }: Props) { return (
- {boxes.map((box, i) => ( - + {grid.map((row, i) => ( +
+ {row.map((cell, j) => ( + + {j % 2 === 0 && i % 2 === 0 && ( + + + + )} + + ))} +
))}
); -} +}); diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 7f1925a..45aa514 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -337,17 +337,17 @@ export const backgroundRegistry: BackgroundDefinition[] = [ key: 'rows', label: 'admin.backgrounds.rows', type: 'number', - min: 4, - max: 20, - step: 1, - default: 12, + min: 10, + max: 40, + step: 5, + default: 20, }, { key: 'cols', label: 'admin.backgrounds.cols', type: 'number', - min: 4, - max: 20, + min: 5, + max: 25, step: 1, default: 12, }, From 65afb292747b0e57865bc4c0d5df320fbc58b261 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:57:56 +0300 Subject: [PATCH 06/24] fix: boxes background not covering viewport - Use 300% oversized CSS grid container instead of fixed-size flex cells - Grid with 1fr units auto-fills the container, skew+scale covers viewport - Simplified to flat cell array, removed SVG crosses for cleaner look --- .../ui/backgrounds/background-boxes.tsx | 84 ++++++++----------- 1 file changed, 35 insertions(+), 49 deletions(-) diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 0f000dc..766b6fd 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -18,23 +18,19 @@ const COLORS = [ ]; export default React.memo(function BackgroundBoxes({ settings }: Props) { - const rows = clampNumber(settings.rows, 4, 30, 20); - const cols = clampNumber(settings.cols, 4, 30, 12); + const rows = clampNumber(settings.rows, 4, 30, 15); + const cols = clampNumber(settings.cols, 4, 30, 15); const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); - const grid = useMemo(() => { - const result: { color: string; delay: number; duration: number }[][] = []; - for (let i = 0; i < rows; i++) { - const row: { color: string; delay: number; duration: number }[] = []; - for (let j = 0; j < cols; j++) { - row.push({ - color: - boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, - delay: Math.random() * 8, - duration: 3 + Math.random() * 4, - }); - } - result.push(row); + const cells = useMemo(() => { + const result: { color: string; delay: number; duration: number }[] = []; + for (let i = 0; i < rows * cols; i++) { + result.push({ + color: + boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, + delay: Math.random() * 8, + duration: 3 + Math.random() * 4, + }); } return result; }, [rows, cols, boxColor]); @@ -42,43 +38,33 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) { return (
- {grid.map((row, i) => ( -
- {row.map((cell, j) => ( - - {j % 2 === 0 && i % 2 === 0 && ( - - - - )} - - ))} -
+ {cells.map((cell, i) => ( + ))}
From 44d88f76532e5b9f7364210fafd3fc6c376c03cc Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 08:14:13 +0300 Subject: [PATCH 07/24] perf: prefetch background chunk on page load from localStorage cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add prefetchBackground() to registry — triggers dynamic import without creating React component - Call prefetch at module init in BackgroundRenderer using cached config from localStorage, before React renders - Background JS chunk starts downloading immediately instead of waiting for Suspense to trigger lazy() import --- .../backgrounds/BackgroundRenderer.tsx | 10 ++++++- src/components/ui/backgrounds/registry.ts | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index 17137b6..54f790c 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -3,7 +3,7 @@ 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'; +import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry'; const ANIMATION_CACHE_KEY = 'cabinet_animation_config'; @@ -16,6 +16,14 @@ function getCachedConfig(): AnimationConfig | null { } } +// Prefetch the background JS chunk immediately based on localStorage cache. +// This starts the download before React even renders, so by the time +// Suspense needs the component, the chunk is already loaded. +const cachedConfig = getCachedConfig(); +if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') { + prefetchBackground(cachedConfig.type); +} + function setCachedConfig(config: AnimationConfig) { try { localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config)); diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 45aa514..18638d7 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -1,6 +1,32 @@ import { lazy, type ComponentType } from 'react'; import type { BackgroundType, BackgroundDefinition } from './types'; +// Raw import functions for prefetching (doesn't create React components) +const backgroundImports: Record, () => Promise> = { + aurora: () => import('./aurora-background'), + sparkles: () => import('./sparkles'), + vortex: () => import('./vortex'), + 'shooting-stars': () => import('./shooting-stars'), + 'background-beams': () => import('./background-beams'), + 'background-beams-collision': () => import('./background-beams-collision'), + 'gradient-animation': () => import('./background-gradient-animation'), + wavy: () => import('./wavy-background'), + 'background-lines': () => import('./background-lines'), + boxes: () => import('./background-boxes'), + meteors: () => import('./meteors'), + grid: () => import('./grid-background'), + dots: () => import('./grid-background'), + spotlight: () => import('./spotlight-bg'), + ripple: () => import('./background-ripple'), +}; + +/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ +export function prefetchBackground(type: BackgroundType): void { + if (type !== 'none' && backgroundImports[type]) { + backgroundImports[type](); + } +} + // Lazy-loaded components — only the selected type is fetched export const backgroundComponents: Record< Exclude, From 962dd43756438779b6cc1821f3ee6b8147113646 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 08:19:26 +0300 Subject: [PATCH 08/24] =?UTF-8?q?perf:=20improve=20LCP=20=E2=80=94=20move?= =?UTF-8?q?=20font=20loading=20to=20HTML,=20defer=20logo=20preload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Google Fonts from CSS @import (render-blocking) to in index.html with preconnect for earlier discovery - Remove @import from globals.css — browser no longer waits for CSS parse -> @import fetch -> font CSS parse before first paint - Defer initLogoPreload() to requestIdleCallback so logo fetch doesn't compete with critical resources during page load --- index.html | 6 ++++++ src/main.tsx | 8 ++++++-- src/styles/globals.css | 2 -- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 50ae845..a81ba8b 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,12 @@ Loading... + + +