feat: replace animated backgrounds with Aceternity UI system

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

14
package-lock.json generated
View File

@@ -36,12 +36,12 @@
"i18next": "^25.8.4", "i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router": "^7.13.0", "react-router": "^7.13.0",
"react-twemoji": "^0.7.2", "react-twemoji": "^0.7.2",
"simplex-noise": "^4.0.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -5233,12 +5233,6 @@
"node": ">= 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": { "node_modules/onetime": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@@ -6082,6 +6076,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/slice-ansi": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",

View File

@@ -44,12 +44,12 @@
"i18next": "^25.8.4", "i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router": "^7.13.0", "react-router": "^7.13.0",
"react-twemoji": "^0.7.2", "react-twemoji": "^0.7.2",
"simplex-noise": "^4.0.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },

View File

@@ -1,4 +1,8 @@
import apiClient from './client'; 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 { export interface BrandingInfo {
name: string; name: string;
@@ -180,6 +184,25 @@ export const brandingApi = {
return response.data; return response.data;
}, },
// Get animation config (public, no auth required)
getAnimationConfig: async (): Promise<AnimationConfig> => {
try {
const response = await apiClient.get<AnimationConfig>('/cabinet/branding/animation-config');
return response.data;
} catch {
return DEFAULT_ANIMATION_CONFIG;
}
},
// Update animation config (admin only, partial update)
updateAnimationConfig: async (config: Partial<AnimationConfig>): Promise<AnimationConfig> => {
const response = await apiClient.patch<AnimationConfig>(
'/cabinet/branding/animation-config',
config,
);
return response.data;
},
// Get fullscreen enabled (public, no auth required) // Get fullscreen enabled (public, no auth required)
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => { getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
try { try {

View File

@@ -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<boolean | null>(() => 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 (
<div className="wave-bg-container">
<div className="wave-blob wave-blob-1" />
<div className="wave-blob wave-blob-2" />
{!isMobile && (
<>
<div className="wave-blob wave-blob-3" />
<div className="wave-blob wave-blob-4" />
</>
)}
</div>
);
});
export default AnimatedBackground;

View File

@@ -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 (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={numVal}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-24 accent-accent-500"
/>
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
</div>
</div>
);
}
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 (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexForInput}
onChange={(e) => onChange(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
/>
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
</div>
</div>
);
}
if (def.type === 'boolean') {
const boolVal = (value as boolean) ?? (def.default as boolean);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
</div>
);
}
if (def.type === 'select' && def.options) {
const selectVal = (value as string) ?? (def.default as string);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<select
value={selectVal}
onChange={(e) => onChange(e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
>
{def.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
</option>
))}
</select>
</div>
);
}
return null;
}
export function BackgroundEditor() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(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<AnimationConfig | null>(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<AnimationConfig>) => {
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<string, unknown> = {};
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<string, typeof backgroundRegistry>();
for (const def of backgroundRegistry) {
const list = cats.get(def.category) ?? [];
list.push(def);
cats.set(def.category, list);
}
return cats;
}, []);
return (
<div className="space-y-6">
{/* Header with enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
</div>
<Toggle
checked={config.enabled}
onChange={() => updateConfig({ enabled: !config.enabled })}
/>
</div>
{config.enabled && (
<>
{/* Preview */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.preview')}
</label>
<BackgroundPreview
type={config.type}
settings={config.settings}
opacity={config.opacity}
blur={config.blur}
className="h-48"
/>
</div>
{/* Type selector gallery */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.selectType')}
</label>
{/* None option */}
<button
onClick={() => handleTypeChange('none')}
className={cn(
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
config.type === 'none'
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="text-sm font-medium text-dark-200">
{t('admin.backgrounds.none')}
</span>
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
</button>
{/* Background types by category */}
<div className="space-y-4">
{Array.from(categories.entries()).map(([category, defs]) => (
<div key={category}>
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{defs.map((def) => (
<button
key={def.type}
onClick={() => handleTypeChange(def.type)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
config.type === def.type
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="block text-sm font-medium text-dark-200">
{t(def.labelKey)}
</span>
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Per-type settings */}
{currentDef && currentDef.settings.length > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">
{t('admin.backgrounds.settings')}
</h4>
<div className="space-y-3">
{currentDef.settings.map((def) => (
<SettingField
key={def.key}
def={def}
value={config.settings[def.key]}
onChange={(val) => updateSetting(def.key, val)}
t={t}
/>
))}
</div>
</div>
)}
{/* Global settings */}
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">
{t('admin.backgrounds.globalOpacity')}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.opacity}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={20}
step={1}
value={config.blur}
onChange={(e) => updateConfig({ blur: parseInt(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.blur}px
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<label className="text-sm text-dark-300">
{t('admin.backgrounds.reducedOnMobile')}
</label>
<p className="text-xs text-dark-500">
{t('admin.backgrounds.reducedOnMobileDesc')}
</p>
</div>
<Toggle
checked={config.reducedOnMobile}
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
/>
</div>
</div>
</div>
</>
)}
{/* Save button */}
{showSaveButton && (
<button
onClick={handleSave}
disabled={saveMutation.isPending || saveStatus === 'saved'}
className={cn(
'w-full rounded-xl py-3 text-sm font-medium transition-colors',
saveStatus === 'saved'
? 'bg-green-500/20 text-green-400'
: 'bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50',
)}
>
{saveStatus === 'saving'
? t('admin.backgrounds.saving')
: saveStatus === 'saved'
? t('admin.backgrounds.saved')
: t('admin.backgrounds.save')}
</button>
)}
</div>
);
}

View File

@@ -2,10 +2,10 @@ import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding'; import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK'; import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'; import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle'; import { Toggle } from './Toggle';
import { BackgroundEditor } from './BackgroundEditor';
interface BrandingTabProps { interface BrandingTabProps {
accentColor?: string; accentColor?: string;
@@ -25,11 +25,6 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getBranding, queryFn: brandingApi.getBranding,
}); });
const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled,
});
const { data: fullscreenSettings } = useQuery({ const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'], queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled, 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({ const updateFullscreenMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
onSuccess: (data) => { onSuccess: (data) => {
@@ -199,27 +186,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div> </div>
</div> </div>
{/* Animation & Fullscreen toggles */} {/* Animated Background Editor */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<BackgroundEditor />
</div>
{/* Fullscreen & Email toggles */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="mb-4 text-lg font-semibold text-dark-100"> <h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.interfaceOptions')} {t('admin.settings.interfaceOptions')}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div>
<span className="font-medium text-dark-100">
{t('admin.settings.animatedBackground')}
</span>
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
</div>
<Toggle
checked={animationSettings?.enabled ?? true}
onChange={() => updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true))}
disabled={updateAnimationMutation.isPending}
/>
</div>
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div> <div>
<span className="font-medium text-dark-100"> <span className="font-medium text-dark-100">

View File

@@ -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<string, unknown>;
opacity?: number;
blur?: number;
className?: string;
}
export function BackgroundPreview({
type,
settings,
opacity,
blur,
className,
}: BackgroundPreviewProps) {
if (type === 'none') {
return (
<div
className={cn(
'relative overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900',
className,
)}
>
<div className="flex h-full items-center justify-center text-dark-400"></div>
</div>
);
}
const Component = backgroundComponents[type];
if (!Component) return null;
return (
<div
className={cn(
'relative overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900',
className,
)}
>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
}
>
<div
style={{
opacity: opacity != null ? opacity : undefined,
filter: blur && blur > 0 ? `blur(${blur}px)` : undefined,
}}
className="absolute inset-0"
>
<Component settings={settings} />
</div>
</Suspense>
</div>
);
}

View File

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

View File

@@ -23,7 +23,7 @@ import TicketNotificationBell from '@/components/TicketNotificationBell';
import { MobileBottomNav } from './MobileBottomNav'; import { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader'; import { AppHeader } from './AppHeader';
import { Aurora } from './Aurora'; import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
// Desktop nav icons // Desktop nav icons
const HomeIcon = ({ className }: { className?: string }) => ( const HomeIcon = ({ className }: { className?: string }) => (
@@ -279,7 +279,7 @@ export function AppShell({ children }: AppShellProps) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Animated background */} {/* Animated background */}
<Aurora /> <BackgroundRenderer />
{/* Global components */} {/* Global components */}
<WebSocketNotifications /> <WebSocketNotifications />

View File

@@ -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 <AuroraImpl />;
}
function AuroraImpl() {
const containerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number>(0);
const rendererRef = useRef<Renderer | null>(null);
const programRef = useRef<Program | null>(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 раз,
т.к. блюрит только один элемент, а не все слои под ним). */}
<div
ref={containerRef}
className="pointer-events-none fixed inset-0 z-0"
style={{
width: '100%',
height: '100%',
filter: 'blur(20px)',
WebkitFilter: 'blur(20px)',
backgroundColor: blurColor,
contain: 'strict',
}}
/>
</>
);
}

View File

@@ -2,4 +2,3 @@ export { AppShell } from './AppShell';
export { DesktopSidebar } from './DesktopSidebar'; export { DesktopSidebar } from './DesktopSidebar';
export { MobileBottomNav } from './MobileBottomNav'; export { MobileBottomNav } from './MobileBottomNav';
export { AppHeader } from './AppHeader'; export { AppHeader } from './AppHeader';
export { Aurora } from './Aurora';

View File

@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils';
interface Props {
settings: Record<string, unknown>;
}
export default function AuroraBackground({ settings }: Props) {
const speed = (settings.speed as number) ?? 60;
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
return (
<div className="absolute inset-0 overflow-hidden">
<div
className={cn(
`pointer-events-none absolute -inset-[10px] opacity-50 blur-[10px] invert filter will-change-transform after:animate-aurora`,
`[background-image:var(--white-gradient),var(--aurora)] [background-position:50%_50%,50%_50%] [background-size:300%,_200%]`,
`after:absolute after:inset-0 after:mix-blend-difference after:content-[""] after:[background-attachment:fixed] after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%]`,
`dark:invert-0 dark:[background-image:var(--dark-gradient),var(--aurora)] after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
showRadialGradient &&
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`,
)}
style={
{
'--aurora':
'repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)',
'--dark-gradient':
'repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,transparent_10%,transparent_12%,var(--black)_16%)',
'--white-gradient':
'repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,transparent_10%,transparent_12%,var(--white)_16%)',
'--transparent': 'transparent',
animationDuration: `${speed}s`,
} as React.CSSProperties
}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { useRef, useEffect, useState } from 'react';
import { motion } from 'framer-motion';
interface Props {
settings: Record<string, unknown>;
}
interface Beam {
id: number;
x: number;
duration: number;
delay: number;
rotate: number;
}
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
const [beams, setBeams] = useState<Beam[]>([]);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="absolute inset-0 overflow-hidden">
{beams.map((beam) => (
<motion.div
key={beam.id}
className="absolute top-0 h-full w-px"
style={{ left: `${beam.x}%`, rotate: `${beam.rotate}deg` }}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: '200%', opacity: [0, 1, 1, 0] }}
transition={{
duration: beam.duration,
delay: beam.delay,
repeat: Infinity,
ease: 'linear',
}}
>
<div
className="h-32 w-px"
style={{
background:
'linear-gradient(180deg, transparent, rgba(var(--color-accent-500), 0.5), rgba(var(--color-accent-400), 0.8), transparent)',
}}
/>
</motion.div>
))}
{/* Bottom collision line */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-accent-500/20 to-transparent" />
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
interface Props {
settings: Record<string, unknown>;
}
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<SVGSVGElement>(null);
useEffect(() => {
const handleVisibility = () => {
if (svgRef.current) {
svgRef.current.style.display = document.hidden ? 'none' : '';
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
return (
<div className="absolute inset-0 overflow-hidden">
<svg
ref={svgRef}
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 696 316"
fill="none"
preserveAspectRatio="xMidYMid slice"
>
{paths.map((path, i) => (
<motion.path
key={i}
d={path}
stroke={`url(#beam-gradient-${i})`}
strokeWidth="0.5"
strokeOpacity="0.1"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: [0, 0.3, 0.6, 0.3, 0],
}}
transition={{
duration: 4 + i * 0.5,
ease: 'easeInOut',
repeat: Infinity,
delay: i * 0.3,
}}
/>
))}
<defs>
{paths.map((_, i) => (
<linearGradient key={i} id={`beam-gradient-${i}`} gradientUnits="userSpaceOnUse">
<stop stopColor="#18CCFC" stopOpacity="0" />
<stop stopColor="#18CCFC" />
<stop offset="0.325" stopColor="#6344F5" />
<stop offset="1" stopColor="#AE48FF" stopOpacity="0" />
</linearGradient>
))}
</defs>
</svg>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
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 (
<div className="absolute inset-0 overflow-hidden">
<div
className="absolute inset-0"
style={{
display: 'grid',
gridTemplateRows: `repeat(${rows}, 1fr)`,
gridTemplateColumns: `repeat(${cols}, 1fr)`,
transform: 'skewX(-48deg) skewY(14deg) scale(0.675) translateX(10%)',
transformOrigin: 'center center',
}}
>
{boxes.map((box, i) => (
<motion.div
key={i}
className="border border-white/5"
initial={{ opacity: 0 }}
animate={{
opacity: [0, 0.08, 0],
}}
transition={{
duration: 3 + Math.random() * 4,
delay: Math.random() * 5,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{ backgroundColor: box.color }}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { sanitizeColor } from './types';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLDivElement>(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 (
<div
className="absolute inset-0 overflow-hidden"
style={
{
'--first-color': firstColor,
'--second-color': secondColor,
'--third-color': thirdColor,
'--fourth-color': fourthColor,
'--fifth-color': fifthColor,
'--size': size,
'--blending': 'hard-light',
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
} as React.CSSProperties
}
>
<svg className="hidden">
<defs>
<filter id="blurMe">
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
{[
{ 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) => (
<div
key={i}
className={cn(
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-100 mix-blend-hard-light',
blob.anim,
)}
style={{
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
}}
/>
))}
{interactive && (
<div
ref={interactiveRef}
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"
style={{
background: `radial-gradient(circle at center, rgba(140, 100, 255, 0.8) 0, rgba(140, 100, 255, 0) 50%) no-repeat`,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function BackgroundLines({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function BackgroundRipple({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useRef } from 'react';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,34 @@
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
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 (
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle, ${gridColor} ${dotSize}px, transparent ${dotSize}px)`,
backgroundSize: `${gridSize}px ${gridSize}px`,
}}
/>
);
}
return (
<div
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(to right, ${gridColor} 1px, transparent 1px)`,
backgroundSize: `${gridSize}px ${gridSize}px`,
}}
/>
);
}

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
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 (
<div className="absolute inset-0 overflow-hidden">
{meteors.map((meteor) => (
<span
key={meteor.id}
className="absolute top-0 rotate-[215deg] animate-meteor-effect rounded-[9999px]"
style={{
left: meteor.left,
animationDelay: meteor.delay,
animationDuration: meteor.duration,
width: meteor.size,
height: meteor.size,
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
background: meteorColor,
}}
>
<div
className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2"
style={{
background: `linear-gradient(to right, ${meteorColor}, transparent)`,
}}
/>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function NoiseBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(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<number>(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 (
<div className="absolute inset-0" style={{ backgroundColor: baseColor }}>
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
style={{
opacity: noiseOpacity,
imageRendering: 'pixelated',
mixBlendMode: 'overlay',
}}
/>
</div>
);
}

View File

@@ -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<BackgroundType, 'none'>,
ComponentType<{ settings: Record<string, unknown> }>
> = {
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,
},
],
},
];

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useRef, useCallback } from 'react';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const particlesRef = useRef<Particle[]>([]);
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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,54 @@
import { motion } from 'framer-motion';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function SpotlightBg({ settings }: Props) {
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
return (
<div className="absolute inset-0 overflow-hidden">
<motion.div
className="absolute"
animate={{
x: ['-20%', '120%', '-20%'],
y: ['-10%', '80%', '-10%'],
}}
transition={{
duration: 12,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
width: spotlightSize,
height: spotlightSize,
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(60px)',
}}
/>
<motion.div
className="absolute"
animate={{
x: ['120%', '-20%', '120%'],
y: ['80%', '-10%', '80%'],
}}
transition={{
duration: 15,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
width: spotlightSize * 0.8,
height: spotlightSize * 0.8,
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(80px)',
}}
/>
</div>
);
}

View File

@@ -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<string, unknown>;
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[];
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function VortexBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function WavyBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -828,6 +828,96 @@
} }
}, },
"admin": { "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": { "groups": {
"analytics": "Analytics", "analytics": "Analytics",
"marketing": "Marketing", "marketing": "Marketing",

View File

@@ -716,6 +716,96 @@
"youWon": "شما برنده شدید" "youWon": "شما برنده شدید"
}, },
"admin": { "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": { "nav": {
"title": "مدیریت", "title": "مدیریت",
"dashboard": "آمار", "dashboard": "آمار",

View File

@@ -857,6 +857,96 @@
} }
}, },
"admin": { "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": { "nav": {
"title": "Админка", "title": "Админка",
"dashboard": "Статистика", "dashboard": "Статистика",

View File

@@ -716,6 +716,96 @@
} }
}, },
"admin": { "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": { "nav": {
"title": "管理", "title": "管理",
"dashboard": "统计", "dashboard": "统计",

View File

@@ -4,6 +4,17 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { @layer base {
:root { :root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); --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 */ /* User preference: reduce motion */
.reduce-motion, .reduce-motion,
.reduce-motion * { .reduce-motion * {

View File

@@ -164,6 +164,15 @@ export default {
float: 'float 3s ease-in-out infinite', float: 'float 3s ease-in-out infinite',
'glow-pulse': 'glowPulse 2s ease-in-out infinite', 'glow-pulse': 'glowPulse 2s ease-in-out infinite',
spotlight: 'spotlight 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: { keyframes: {
fadeIn: { 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)', '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: { transitionTimingFunction: {
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)', smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',