diff --git a/ui/src/components/containers/base-container.tsx b/ui/src/components/containers/base-container.tsx index ba4f1b77d..9bfb43532 100644 --- a/ui/src/components/containers/base-container.tsx +++ b/ui/src/components/containers/base-container.tsx @@ -1,17 +1,22 @@ +'use client'; + import React from 'react'; import { twMerge } from 'tailwind-merge'; import { BackgroundColor } from '../../interfaces/color'; +import { useGlowHover } from '../use-glow-hover'; type BaseContainerProps = { backgroundColor?: BackgroundColor; + wrapperClassName?: string; + wrapperRef?: React.Ref; } & React.HTMLAttributes; export const BaseContainer = React.forwardRef( - ({ children, className, backgroundColor, ...props }, ref) => { + ({ children, className, backgroundColor, wrapperClassName, wrapperRef, ...props }, ref) => { return ( -
+
-
+
{children}
@@ -19,3 +24,17 @@ export const BaseContainer = React.forwardRef>( + ({ className, ...props }, ref) => { + const refCard = useGlowHover({ lightColor: '#CEFF00' }); + + return ( + } + /> + ); + }, +); diff --git a/ui/src/components/use-glow-hover/glow-hover-effect.ts b/ui/src/components/use-glow-hover/glow-hover-effect.ts new file mode 100644 index 000000000..741eb8ca0 --- /dev/null +++ b/ui/src/components/use-glow-hover/glow-hover-effect.ts @@ -0,0 +1,237 @@ +'use client'; + +import { linearAnimation } from './linear-animation'; + +export type GlowHoverOptions = { + hoverBg?: string; + lightSize?: number; + lightSizeEnterAnimationTime?: number; + lightSizeLeaveAnimationTime?: number; + isElementMovable?: boolean; + customStaticBg?: string; + enableBurst?: boolean; +} & ( + | { + /*preset: keyof typeof presets;*/ + lightColor?: string; + } + | { + /*preset?: never;*/ + lightColor: string; + } +); + +type Coords = { + x: number; + y: number; +}; + +const BURST_TIME = 300; + +export function parseColor(colorToParse: string) { + const div = document.createElement('div'); + div.style.color = colorToParse; + div.style.position = 'absolute'; + div.style.display = 'none'; + document.body.appendChild(div); + const colorFromEl = getComputedStyle(div).color; + document.body.removeChild(div); + const parsedColor = colorFromEl.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i); + if (parsedColor) { + const alpha = typeof parsedColor[4] === 'undefined' ? 1 : parsedColor[4]; + return [parsedColor[1], parsedColor[2], parsedColor[3], alpha]; + } else { + console.error(`Color ${colorToParse} could not be parsed.`); + return [0, 0, 0, 0]; + } +} + +export const glowHoverEffect = (el: HTMLElement, { preset, ...options }: GlowHoverOptions) => { + if (!el) { + return () => {}; + } + + const lightColor = options.lightColor ?? '#CEFF00'; + const lightSize = options.lightSize ?? 130; + const lightSizeEnterAnimationTime = options.lightSizeEnterAnimationTime ?? 100; + const lightSizeLeaveAnimationTime = options.lightSizeLeaveAnimationTime ?? 50; + const isElementMovable = options.isElementMovable ?? false; + const customStaticBg = options.customStaticBg ?? null; + + const enableBurst = options.enableBurst ?? false; + + const getResolvedHoverBg = () => getComputedStyle(el).backgroundColor; + + let resolvedHoverBg = getResolvedHoverBg(); + + // default bg (if not defined) is rgba(0, 0, 0, 0) which is bugged in gradients in Safari + // so we use transparent lightColor instead + const parsedLightColor = parseColor(lightColor); + const parsedLightColorRGBString = parsedLightColor.slice(0, 3).join(','); + const resolvedGradientBg = `rgba(${parsedLightColorRGBString},0)`; + + let isMouseInside = false; + let currentLightSize = 0; + let blownSize = 0; + let lightSizeEnterAnimationId: number = null; + let lightSizeLeaveAnimationId: number = null; + let blownSizeIncreaseAnimationId: number = null; + let blownSizeDecreaseAnimationId: number = null; + let lastMousePos: Coords; + const defaultBox = el.getBoundingClientRect(); + let lastElPos: Coords = { x: defaultBox.left, y: defaultBox.top }; + + const updateGlowEffect = () => { + if (!lastMousePos) { + return; + } + const gradientXPos = lastMousePos.x - lastElPos.x; + const gradientYPos = lastMousePos.y - lastElPos.y; + // we do not use transparent color here because of dirty gradient in Safari (more info: https://stackoverflow.com/questions/38391457/linear-gradient-to-transparent-bug-in-latest-safari) + const gradient = `radial-gradient(circle at ${gradientXPos}px ${gradientYPos}px, ${lightColor} 0%, ${resolvedGradientBg} calc(${ + blownSize * 2.5 + }% + ${currentLightSize}px)) no-repeat`; + + // we duplicate resolvedHoverBg layer here because of transition "blinking" without it + el.style.background = `${gradient} border-box border-box ${resolvedHoverBg}`; + }; + + const updateEffectWithPosition = () => { + if (isMouseInside) { + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + updateGlowEffect(); + } + }; + + const onMouseEnter = (e: MouseEvent) => { + resolvedHoverBg = getResolvedHoverBg(); + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + isMouseInside = true; + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + + // animate currentLightSize from 0 to lightSize + linearAnimation({ + onProgress: (progress) => { + currentLightSize = lightSize * progress; + updateGlowEffect(); + }, + time: lightSizeEnterAnimationTime, + initialProgress: currentLightSize / lightSize, + onIdUpdate: (newId) => (lightSizeEnterAnimationId = newId), + }); + }; + + const onMouseMove = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + if (isElementMovable) { + updateEffectWithPosition(); + } else { + updateGlowEffect(); + } + }; + + const onMouseLeave = () => { + isMouseInside = false; + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate currentLightSize from lightSize to 0 + linearAnimation({ + onProgress: (progress) => { + currentLightSize = lightSize * (1 - progress); + blownSize = Math.min(blownSize, (1 - progress) * 100); + + if (progress < 1) { + updateGlowEffect(); + } else { + el.style.background = customStaticBg ? customStaticBg : ''; + } + }, + time: lightSizeLeaveAnimationTime, + initialProgress: 1 - currentLightSize / lightSize, + onIdUpdate: (newId) => (lightSizeLeaveAnimationId = newId), + }); + }; + + const onMouseDown = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate blownSize from 0 to 100 + linearAnimation({ + onProgress: (progress) => { + blownSize = 100 * progress; + updateGlowEffect(); + }, + time: BURST_TIME, + initialProgress: blownSize / 100, + onIdUpdate: (newId) => (blownSizeIncreaseAnimationId = newId), + }); + }; + + const onMouseUp = (e: MouseEvent) => { + lastMousePos = { x: e.clientX, y: e.clientY }; + const curBox = el.getBoundingClientRect(); + lastElPos = { x: curBox.left, y: curBox.top }; + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + // animate blownSize from 100 to 0 + linearAnimation({ + onProgress: (progress) => { + blownSize = (1 - progress) * 100; + updateGlowEffect(); + }, + time: BURST_TIME, + initialProgress: 1 - blownSize / 100, + onIdUpdate: (newId) => (blownSizeDecreaseAnimationId = newId), + }); + }; + + document.addEventListener('scroll', updateEffectWithPosition); + window.addEventListener('resize', updateEffectWithPosition); + el.addEventListener('mouseenter', onMouseEnter); + el.addEventListener('mousemove', onMouseMove); + el.addEventListener('mouseleave', onMouseLeave); + if (enableBurst) { + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('mouseup', onMouseUp); + } + + let resizeObserver: ResizeObserver; + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(updateEffectWithPosition); + resizeObserver.observe(el); + } + + return () => { + window.cancelAnimationFrame(lightSizeEnterAnimationId); + window.cancelAnimationFrame(lightSizeLeaveAnimationId); + window.cancelAnimationFrame(blownSizeIncreaseAnimationId); + window.cancelAnimationFrame(blownSizeDecreaseAnimationId); + + document.removeEventListener('scroll', updateEffectWithPosition); + window.removeEventListener('resize', updateEffectWithPosition); + el.removeEventListener('mouseenter', onMouseEnter); + el.removeEventListener('mousemove', onMouseMove); + el.removeEventListener('mouseleave', onMouseLeave); + if (enableBurst) { + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('mouseup', onMouseUp); + } + + if (resizeObserver) { + resizeObserver.unobserve(el); + resizeObserver.disconnect(); + } + }; +}; diff --git a/ui/src/components/use-glow-hover/index.ts b/ui/src/components/use-glow-hover/index.ts new file mode 100644 index 000000000..7818a6ed8 --- /dev/null +++ b/ui/src/components/use-glow-hover/index.ts @@ -0,0 +1,4 @@ +export { glowHoverEffect } from './glow-hover-effect'; +export type { GlowHoverOptions } from './glow-hover-effect'; +export { useGlowHover } from './use-glow-hover'; +export type { GlowHoverHookOptions } from './use-glow-hover'; diff --git a/ui/src/components/use-glow-hover/linear-animation.ts b/ui/src/components/use-glow-hover/linear-animation.ts new file mode 100644 index 000000000..595f0cd15 --- /dev/null +++ b/ui/src/components/use-glow-hover/linear-animation.ts @@ -0,0 +1,36 @@ +'use client'; + +interface LinearAnimationParams { + onProgress: (progress: number) => void; + onIdUpdate?: (id: number) => void; + time: number; + initialProgress?: number; +} + +export const linearAnimation = ({ + onProgress, + onIdUpdate = () => {}, + time, + initialProgress = 0, +}: LinearAnimationParams) => { + if (time === 0) { + onProgress(1); + onIdUpdate(null); + return; + } + + let start: number = null; + const step = (timestamp: number) => { + if (!start) start = timestamp; + const progress = Math.min((timestamp - start) / time + initialProgress, 1); + + onProgress(progress); + + if (progress < 1) { + onIdUpdate(window.requestAnimationFrame(step)); + } else { + onIdUpdate(null); + } + }; + onIdUpdate(window.requestAnimationFrame(step)); +}; diff --git a/ui/src/components/use-glow-hover/use-glow-hover.ts b/ui/src/components/use-glow-hover/use-glow-hover.ts new file mode 100644 index 000000000..e86636125 --- /dev/null +++ b/ui/src/components/use-glow-hover/use-glow-hover.ts @@ -0,0 +1,16 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import { glowHoverEffect, GlowHoverOptions } from './glow-hover-effect'; + +export type GlowHoverHookOptions = GlowHoverOptions & { disabled?: boolean }; +export const useGlowHover = ({ disabled = false, ...options }: GlowHoverHookOptions) => { + const ref = useRef(null); + + useEffect( + () => (!disabled && ref.current ? glowHoverEffect(ref.current, options) : () => {}), + [disabled, ...Object.values(options)], + ); + return ref; +}; diff --git a/ui/src/index.ts b/ui/src/index.ts index eedc35e21..8b2e48f95 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -27,3 +27,4 @@ export * from './components/table'; export * from './components/tabs'; export * from './components/tooltip'; export * from './components/typography'; +export * from './components/use-glow-hover'; diff --git a/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx b/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx index f074a7cf0..7fd1d126a 100644 --- a/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx +++ b/website/src/app/[lang]/[region]/(website)/(home)/(sections)/overview.tsx @@ -1,6 +1,6 @@ import { DefaultParams } from '@/app/[lang]/[region]'; import { Translator } from '@socialincome/shared/src/utils/i18n'; -import { BaseContainer, Typography } from '@socialincome/ui'; +import { BaseContainer, GLowHoverContainer, Typography } from '@socialincome/ui'; import { FontColor } from '@socialincome/ui/src/interfaces/color'; export async function Overview({ lang }: DefaultParams) { @@ -10,36 +10,52 @@ export async function Overview({ lang }: DefaultParams) { }); return ( - - - {translator.t('section-2.title-1')} - -
- {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( - - {title.text}{' '} - - ))} -
- - {translator.t('section-2.title-3')} - -
    -
  1. - {translator.t('section-2.text-3.1')} -
  2. -
  3. - {translator.t('section-2.text-3.2')} -
  4. -
- - {translator.t('section-2.title-4')} - - {translator.t('section-2.text-4')} - - {translator.t('section-2.title-5')} - - {translator.t('section-2.text-5')} -
+ <> + {/* TODO: remove the example of GlowHoverContainer usage below before merge */} + + + {translator.t('section-2.title-1')} + +
+ {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( + + {title.text}{' '} + + ))} +
+
+ + + + {translator.t('section-2.title-1')} + +
+ {translator.t<{ text: string; color?: FontColor }[]>('section-2.title-2').map((title, index) => ( + + {title.text}{' '} + + ))} +
+ + {translator.t('section-2.title-3')} + +
    +
  1. + {translator.t('section-2.text-3.1')} +
  2. +
  3. + {translator.t('section-2.text-3.2')} +
  4. +
+ + {translator.t('section-2.title-4')} + + {translator.t('section-2.text-4')} + + {translator.t('section-2.title-5')} + + {translator.t('section-2.text-5')} +
+ ); }