From 7e345fc7d0431415496f8363959773e99a853b6e Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 10:07:42 +0300 Subject: [PATCH] feat: add fonts, animations, and shared utilities for dashboard redesign Add Outfit + IBM Plex Mono fonts, traffic progress animations (shimmer, unlimited flow/pulse, trial glow), traffic zone utility, and animated number hook. --- index.html | 2 +- src/hooks/useAnimatedNumber.ts | 38 +++++++++++++++++++++++ src/utils/trafficZone.ts | 56 ++++++++++++++++++++++++++++++++++ tailwind.config.js | 24 +++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useAnimatedNumber.ts create mode 100644 src/utils/trafficZone.ts diff --git a/index.html b/index.html index a81ba8b..a79985d 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ diff --git a/src/hooks/useAnimatedNumber.ts b/src/hooks/useAnimatedNumber.ts new file mode 100644 index 0000000..fba5aba --- /dev/null +++ b/src/hooks/useAnimatedNumber.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useAnimatedNumber(target: number, duration = 600): number { + const [current, setCurrent] = useState(target); + const rafRef = useRef(0); + const startRef = useRef(0); + const fromRef = useRef(target); + const currentRef = useRef(target); + const prevTargetRef = useRef(target); + + // Keep currentRef in sync with state + currentRef.current = current; + + useEffect(() => { + if (prevTargetRef.current === target) return; + prevTargetRef.current = target; + fromRef.current = currentRef.current; + startRef.current = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startRef.current; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const value = fromRef.current + (target - fromRef.current) * eased; + setCurrent(value); + currentRef.current = value; + + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate); + } + }; + + rafRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafRef.current); + }, [target, duration]); + + return current; +} diff --git a/src/utils/trafficZone.ts b/src/utils/trafficZone.ts new file mode 100644 index 0000000..fc8c27b --- /dev/null +++ b/src/utils/trafficZone.ts @@ -0,0 +1,56 @@ +export type TrafficZone = 'normal' | 'warning' | 'danger' | 'critical'; + +interface TrafficZoneResult { + zone: TrafficZone; + textClass: string; + dotClass: string; + glowColor: string; + labelKey: string; + gradientFrom: string; + gradientTo: string; +} + +const ZONES: Record> = { + normal: { + textClass: 'text-success-400', + dotClass: 'bg-success-400', + glowColor: 'rgba(var(--color-success-500), 0.5)', + labelKey: 'dashboard.zone.normal', + gradientFrom: 'rgb(var(--color-success-500))', + gradientTo: 'rgb(var(--color-success-400))', + }, + warning: { + textClass: 'text-warning-400', + dotClass: 'bg-warning-400', + glowColor: 'rgba(var(--color-warning-500), 0.5)', + labelKey: 'dashboard.zone.warning', + gradientFrom: 'rgb(var(--color-warning-500))', + gradientTo: 'rgb(var(--color-warning-400))', + }, + danger: { + textClass: 'text-warning-300', + dotClass: 'bg-warning-300', + glowColor: 'rgba(var(--color-warning-400), 0.5)', + labelKey: 'dashboard.zone.danger', + gradientFrom: 'rgb(var(--color-warning-600))', + gradientTo: 'rgb(var(--color-warning-400))', + }, + critical: { + textClass: 'text-error-400', + dotClass: 'bg-error-400', + glowColor: 'rgba(var(--color-error-500), 0.5)', + labelKey: 'dashboard.zone.critical', + gradientFrom: 'rgb(var(--color-error-500))', + gradientTo: 'rgb(var(--color-error-400))', + }, +}; + +export function getTrafficZone(percent: number): TrafficZoneResult { + let zone: TrafficZone; + if (percent >= 90) zone = 'critical'; + else if (percent >= 75) zone = 'danger'; + else if (percent >= 50) zone = 'warning'; + else zone = 'normal'; + + return { zone, ...ZONES[zone] }; +} diff --git a/tailwind.config.js b/tailwind.config.js index d107268..027d948 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -112,6 +112,8 @@ export default { 'Roboto', 'sans-serif', ], + display: ['Outfit', 'Manrope', 'system-ui', 'sans-serif'], + mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'], }, borderRadius: { bento: '24px', @@ -173,6 +175,11 @@ export default { 'move-horizontal': 'moveHorizontal 40s ease infinite', 'move-in-circle-fast': 'moveInCircle 20s ease infinite', 'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards', + // Dashboard traffic animations + 'traffic-shimmer': 'trafficShimmer 2s ease-in-out infinite', + 'unlimited-flow': 'unlimitedFlow 3s linear infinite', + 'unlimited-pulse': 'unlimitedPulse 2s ease-in-out infinite', + 'trial-glow': 'trialGlow 2s ease-in-out infinite', }, keyframes: { fadeIn: { @@ -251,6 +258,23 @@ export default { '0%': { opacity: '0', transform: 'translate(-72%, -62%) scale(0.5)' }, '100%': { opacity: '1', transform: 'translate(-50%, -40%) scale(1)' }, }, + // Dashboard traffic keyframes + trafficShimmer: { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(200%)' }, + }, + unlimitedFlow: { + '0%': { backgroundPosition: '0% 50%' }, + '100%': { backgroundPosition: '200% 50%' }, + }, + unlimitedPulse: { + '0%, 100%': { opacity: '0.6', transform: 'scale(1)' }, + '50%': { opacity: '1', transform: 'scale(1.3)' }, + }, + trialGlow: { + '0%, 100%': { boxShadow: '0 0 15px rgba(var(--color-warning-500), 0.2)' }, + '50%': { boxShadow: '0 0 30px rgba(var(--color-warning-500), 0.4)' }, + }, }, transitionTimingFunction: { smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',