feat: replace animated backgrounds with Aceternity UI system

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

View File

@@ -0,0 +1,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>
);
}