fix(backgrounds): make beams and beams-collision colors configurable

This commit is contained in:
Boris Kovalskii
2026-06-11 09:47:47 +10:00
parent f1f4281e11
commit acbccc8af8
5 changed files with 73 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sanitizeColor } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop'; import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props { interface Props {
@@ -26,7 +27,11 @@ const BEAMS: BeamOptions[] = [
{ initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' }, { initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' },
]; ];
function Explosion(props: React.HTMLProps<HTMLDivElement>) { function Explosion({
beamColor,
explosionColor,
...props
}: React.HTMLProps<HTMLDivElement> & { beamColor: string; explosionColor: string }) {
const spans = Array.from({ length: 20 }, (_, i) => ({ const spans = Array.from({ length: 20 }, (_, i) => ({
id: i, id: i,
directionX: Math.floor(Math.random() * 80 - 40), directionX: Math.floor(Math.random() * 80 - 40),
@@ -40,7 +45,10 @@ function Explosion(props: React.HTMLProps<HTMLDivElement>) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 1.5, ease: 'easeOut' }} transition={{ duration: 1.5, ease: 'easeOut' }}
className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full bg-gradient-to-r from-transparent via-indigo-500 to-transparent blur-sm" className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full blur-sm"
style={{
background: `linear-gradient(to right, transparent, ${explosionColor}, transparent)`,
}}
/> />
{spans.map((span) => ( {spans.map((span) => (
<motion.span <motion.span
@@ -48,7 +56,10 @@ function Explosion(props: React.HTMLProps<HTMLDivElement>) {
initial={{ x: 0, y: 0, opacity: 1 }} initial={{ x: 0, y: 0, opacity: 1 }}
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }} animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }} transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }}
className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-indigo-500 to-purple-500" className="absolute h-1 w-1 rounded-full"
style={{
background: `linear-gradient(to bottom, ${beamColor}, ${explosionColor})`,
}}
/> />
))} ))}
</div> </div>
@@ -59,10 +70,14 @@ function CollisionMechanism({
containerRef, containerRef,
parentRef, parentRef,
beamOptions, beamOptions,
beamColor,
explosionColor,
}: { }: {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
parentRef: React.RefObject<HTMLDivElement | null>; parentRef: React.RefObject<HTMLDivElement | null>;
beamOptions: BeamOptions; beamOptions: BeamOptions;
beamColor: string;
explosionColor: string;
}) { }) {
const beamRef = useRef<HTMLDivElement>(null); const beamRef = useRef<HTMLDivElement>(null);
const [collision, setCollision] = useState<{ const [collision, setCollision] = useState<{
@@ -92,8 +107,6 @@ function CollisionMechanism({
} }
}, [containerRef, parentRef]); }, [containerRef, parentRef]);
// Throttled collision detection loop.
// Parent unmounts this component when paused, so no visibility handling needed here.
useEffect(() => { useEffect(() => {
let animId = 0; let animId = 0;
let lastCheck = 0; let lastCheck = 0;
@@ -114,7 +127,6 @@ function CollisionMechanism({
}; };
}, [checkCollision]); }, [checkCollision]);
// Collision reset with proper timeout cleanup
useEffect(() => { useEffect(() => {
if (!collision.detected || !collision.coordinates) return; if (!collision.detected || !collision.coordinates) return;
@@ -154,14 +166,19 @@ function CollisionMechanism({
repeatDelay: beamOptions.repeatDelay, repeatDelay: beamOptions.repeatDelay,
}} }}
className={cn( className={cn(
'absolute left-0 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-indigo-500 via-purple-500 to-transparent', 'absolute left-0 top-20 m-auto h-14 w-px rounded-full',
beamOptions.className, beamOptions.className,
)} )}
style={{
background: `linear-gradient(to top, ${beamColor}, ${explosionColor}, transparent)`,
}}
/> />
<AnimatePresence> <AnimatePresence>
{collision.detected && collision.coordinates && ( {collision.detected && collision.coordinates && (
<Explosion <Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`} key={`${collision.coordinates.x}-${collision.coordinates.y}`}
beamColor={beamColor}
explosionColor={explosionColor}
style={{ style={{
left: `${collision.coordinates.x}px`, left: `${collision.coordinates.x}px`,
top: `${collision.coordinates.y}px`, top: `${collision.coordinates.y}px`,
@@ -174,11 +191,14 @@ function CollisionMechanism({
); );
} }
export default function BackgroundBeamsCollision({ settings: _settings }: Props) { export default function BackgroundBeamsCollision({ settings }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const paused = useAnimationPause(); const paused = useAnimationPause();
const beamColor = sanitizeColor(settings.beamColor, '#6366f1');
const explosionColor = sanitizeColor(settings.explosionColor, '#a855f7');
return ( return (
<div ref={parentRef} className="absolute inset-0 overflow-hidden"> <div ref={parentRef} className="absolute inset-0 overflow-hidden">
{!paused && {!paused &&
@@ -188,9 +208,10 @@ export default function BackgroundBeamsCollision({ settings: _settings }: Props)
beamOptions={beam} beamOptions={beam}
containerRef={containerRef} containerRef={containerRef}
parentRef={parentRef} parentRef={parentRef}
beamColor={beamColor}
explosionColor={explosionColor}
/> />
))} ))}
{/* Bottom collision line */}
<div <div
ref={containerRef} ref={containerRef}
className="pointer-events-none absolute inset-x-0 bottom-0 w-full" className="pointer-events-none absolute inset-x-0 bottom-0 w-full"

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { sanitizeColor } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop'; import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props { interface Props {
@@ -99,10 +100,14 @@ function ensureStyles() {
document.head.appendChild(style); document.head.appendChild(style);
} }
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) { export default React.memo(function BackgroundBeams({ settings }: Props) {
const paused = useAnimationPause(); const paused = useAnimationPause();
// Inject CSS once on first render const gradientStart = sanitizeColor(settings.gradientStart, '#18CCFC');
const gradientMid = sanitizeColor(settings.gradientMid, '#6344F5');
const gradientEnd = sanitizeColor(settings.gradientEnd, '#AE48FF');
const staticColor = sanitizeColor(settings.staticColor, '#d4d4d4');
React.useEffect(() => { React.useEffect(() => {
ensureStyles(); ensureStyles();
}, []); }, []);
@@ -117,7 +122,6 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
{/* Static background — all paths at very low opacity */}
<path <path
d={bgPath} d={bgPath}
stroke="url(#paint0_radial_beams)" stroke="url(#paint0_radial_beams)"
@@ -125,7 +129,6 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
strokeWidth="0.5" strokeWidth="0.5"
/> />
{/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */}
{animatedPaths.map(({ path, paramIndex }) => ( {animatedPaths.map(({ path, paramIndex }) => (
<path <path
key={paramIndex} key={paramIndex}
@@ -144,15 +147,13 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
))} ))}
<defs> <defs>
{/* Single shared gradient for all beams (cyan → purple → magenta) */}
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop stopColor="#18CCFC" stopOpacity="0" /> <stop stopColor={gradientStart} stopOpacity="0" />
<stop offset="10%" stopColor="#18CCFC" /> <stop offset="10%" stopColor={gradientStart} />
<stop offset="50%" stopColor="#6344F5" /> <stop offset="50%" stopColor={gradientMid} />
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" /> <stop offset="100%" stopColor={gradientEnd} stopOpacity="0" />
</linearGradient> </linearGradient>
{/* Radial gradient for static background */}
<radialGradient <radialGradient
id="paint0_radial_beams" id="paint0_radial_beams"
cx="0" cx="0"
@@ -161,9 +162,9 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)" gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
> >
<stop offset="0.0666667" stopColor="#d4d4d4" /> <stop offset="0.0666667" stopColor={staticColor} />
<stop offset="0.243243" stopColor="#d4d4d4" /> <stop offset="0.243243" stopColor={staticColor} />
<stop offset="0.43594" stopColor="white" stopOpacity="0" /> <stop offset="0.43594" stopColor={staticColor} stopOpacity="0" />
</radialGradient> </radialGradient>
</defs> </defs>
</svg> </svg>

View File

@@ -234,14 +234,37 @@ export const backgroundRegistry: BackgroundDefinition[] = [
labelKey: 'admin.backgrounds.beams', labelKey: 'admin.backgrounds.beams',
descriptionKey: 'admin.backgrounds.beamsDesc', descriptionKey: 'admin.backgrounds.beamsDesc',
category: 'svg', category: 'svg',
settings: [], settings: [
{
key: 'gradientStart',
label: 'admin.backgrounds.color1',
type: 'color',
default: '#18CCFC',
},
{ key: 'gradientMid', label: 'admin.backgrounds.color2', type: 'color', default: '#6344F5' },
{ key: 'gradientEnd', label: 'admin.backgrounds.color3', type: 'color', default: '#AE48FF' },
{
key: 'staticColor',
label: 'admin.backgrounds.fillColor',
type: 'color',
default: '#d4d4d4',
},
],
}, },
{ {
type: 'background-beams-collision', type: 'background-beams-collision',
labelKey: 'admin.backgrounds.beamsCollision', labelKey: 'admin.backgrounds.beamsCollision',
descriptionKey: 'admin.backgrounds.beamsCollisionDesc', descriptionKey: 'admin.backgrounds.beamsCollisionDesc',
category: 'svg', category: 'svg',
settings: [], settings: [
{ key: 'beamColor', label: 'admin.backgrounds.beamColor', type: 'color', default: '#6366f1' },
{
key: 'explosionColor',
label: 'admin.backgrounds.explosionColor',
type: 'color',
default: '#a855f7',
},
],
}, },
{ {
type: 'gradient-animation', type: 'gradient-animation',

View File

@@ -1170,6 +1170,8 @@
"trailColor": "Trail Color", "trailColor": "Trail Color",
"bgStarColor": "Background Stars Color", "bgStarColor": "Background Stars Color",
"pointerColor": "Pointer Color", "pointerColor": "Pointer Color",
"beamColor": "Beam Color",
"explosionColor": "Explosion Color",
"bgColor": "Background Color", "bgColor": "Background Color",
"fillColor": "Fill Color", "fillColor": "Fill Color",
"color1": "Color 1", "color1": "Color 1",

View File

@@ -1200,6 +1200,8 @@
"trailColor": "Цвет следа", "trailColor": "Цвет следа",
"bgStarColor": "Цвет фоновых звёзд", "bgStarColor": "Цвет фоновых звёзд",
"pointerColor": "Цвет курсорного блоба", "pointerColor": "Цвет курсорного блоба",
"beamColor": "Цвет лучей",
"explosionColor": "Цвет взрыва",
"bgColor": "Цвет фона", "bgColor": "Цвет фона",
"fillColor": "Цвет заливки", "fillColor": "Цвет заливки",
"color1": "Цвет 1", "color1": "Цвет 1",