mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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:
37
src/components/ui/backgrounds/aurora-background.tsx
Normal file
37
src/components/ui/backgrounds/aurora-background.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/ui/backgrounds/background-beams-collision.tsx
Normal file
63
src/components/ui/backgrounds/background-beams-collision.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/backgrounds/background-beams.tsx
Normal file
76
src/components/ui/backgrounds/background-beams.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/ui/backgrounds/background-boxes.tsx
Normal file
56
src/components/ui/backgrounds/background-boxes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/ui/backgrounds/background-gradient-animation.tsx
Normal file
127
src/components/ui/backgrounds/background-gradient-animation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/ui/backgrounds/background-lines.tsx
Normal file
73
src/components/ui/backgrounds/background-lines.tsx
Normal 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" />;
|
||||
}
|
||||
69
src/components/ui/backgrounds/background-ripple.tsx
Normal file
69
src/components/ui/backgrounds/background-ripple.tsx
Normal 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" />;
|
||||
}
|
||||
130
src/components/ui/backgrounds/google-gemini-effect.tsx
Normal file
130
src/components/ui/backgrounds/google-gemini-effect.tsx
Normal 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" />;
|
||||
}
|
||||
34
src/components/ui/backgrounds/grid-background.tsx
Normal file
34
src/components/ui/backgrounds/grid-background.tsx
Normal 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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
src/components/ui/backgrounds/meteors.tsx
Normal file
50
src/components/ui/backgrounds/meteors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/ui/backgrounds/noise-background.tsx
Normal file
70
src/components/ui/backgrounds/noise-background.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
544
src/components/ui/backgrounds/registry.ts
Normal file
544
src/components/ui/backgrounds/registry.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
141
src/components/ui/backgrounds/shooting-stars.tsx
Normal file
141
src/components/ui/backgrounds/shooting-stars.tsx
Normal 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" />;
|
||||
}
|
||||
115
src/components/ui/backgrounds/sparkles.tsx
Normal file
115
src/components/ui/backgrounds/sparkles.tsx
Normal 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" />;
|
||||
}
|
||||
54
src/components/ui/backgrounds/spotlight-bg.tsx
Normal file
54
src/components/ui/backgrounds/spotlight-bg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/ui/backgrounds/types.ts
Normal file
75
src/components/ui/backgrounds/types.ts
Normal 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[];
|
||||
}
|
||||
144
src/components/ui/backgrounds/vortex.tsx
Normal file
144
src/components/ui/backgrounds/vortex.tsx
Normal 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" />;
|
||||
}
|
||||
76
src/components/ui/backgrounds/wavy-background.tsx
Normal file
76
src/components/ui/backgrounds/wavy-background.tsx
Normal 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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user