From 1a702a68b9cad0f112a65494250c11758388a91f Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 07:12:59 +0300 Subject: [PATCH] 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)',