diff --git a/apps/web/src/components/admin/admin-dashboard-view/AdminDashboardStatCard.tsx b/apps/web/src/components/admin/admin-dashboard-view/AdminDashboardStatCard.tsx index 265874596..8754a1f9d 100644 --- a/apps/web/src/components/admin/admin-dashboard-view/AdminDashboardStatCard.tsx +++ b/apps/web/src/components/admin/admin-dashboard-view/AdminDashboardStatCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; +import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; import type { StatCardProps } from './types'; const colorClasses: Record = { @@ -60,7 +61,11 @@ export function AdminDashboardStatCard({ )}
- {value ?? '—'} + {typeof value === 'number' ? ( + + ) : ( + value ?? '—' + )}
{label} diff --git a/apps/web/src/components/dashboard/StatCard.tsx b/apps/web/src/components/dashboard/StatCard.tsx index ca64e27cc..03c7809d3 100644 --- a/apps/web/src/components/dashboard/StatCard.tsx +++ b/apps/web/src/components/dashboard/StatCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Card } from '../ui/card'; import { ArrowUp, ArrowDown } from 'lucide-react'; +import { AnimatedNumber } from '../ui/AnimatedNumber'; interface StatCardProps { label: string; @@ -96,7 +97,11 @@ export const StatCard: React.FC = ({ {label}

- {value} + {typeof value === 'number' ? ( + + ) : ( + value + )}

diff --git a/apps/web/src/components/layout/Layout.tsx b/apps/web/src/components/layout/Layout.tsx index 92aa73b9e..26879e63d 100644 --- a/apps/web/src/components/layout/Layout.tsx +++ b/apps/web/src/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import { PageTransition } from './PageTransition'; import { useUIStore } from '@/stores/ui'; import { cn } from '@/lib/utils'; import { AstralBackground } from '../ui/AstralBackground'; +import { NavigationProgress } from '../ui/NavigationProgress'; interface LayoutProps { children: ReactNode; @@ -17,6 +18,7 @@ export function Layout({ children }: LayoutProps) { return (
+
diff --git a/apps/web/src/components/ui/AnimatedNumber.tsx b/apps/web/src/components/ui/AnimatedNumber.tsx new file mode 100644 index 000000000..c9977f5ae --- /dev/null +++ b/apps/web/src/components/ui/AnimatedNumber.tsx @@ -0,0 +1,16 @@ +import { useAnimatedCounter } from '@/hooks/useAnimatedCounter'; +import { cn } from '@/lib/utils'; + +interface AnimatedNumberProps { + value: number; + duration?: number; + className?: string; + format?: (n: number) => string; +} + +export function AnimatedNumber({ value, duration = 1000, className, format }: AnimatedNumberProps) { + const count = useAnimatedCounter({ end: value, duration }); + const display = format ? format(count) : count.toLocaleString(); + + return {display}; +} diff --git a/apps/web/src/components/ui/NavigationProgress.tsx b/apps/web/src/components/ui/NavigationProgress.tsx new file mode 100644 index 000000000..c09c8d7bf --- /dev/null +++ b/apps/web/src/components/ui/NavigationProgress.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; + +export function NavigationProgress() { + const location = useLocation(); + const [isNavigating, setIsNavigating] = useState(false); + const [progress, setProgress] = useState(0); + + useEffect(() => { + setIsNavigating(true); + setProgress(0); + + // Simulate progress + const timer1 = setTimeout(() => setProgress(30), 50); + const timer2 = setTimeout(() => setProgress(60), 150); + const timer3 = setTimeout(() => setProgress(80), 300); + const timer4 = setTimeout(() => { + setProgress(100); + setTimeout(() => setIsNavigating(false), 200); + }, 500); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + clearTimeout(timer3); + clearTimeout(timer4); + }; + }, [location.pathname]); + + return ( + + {isNavigating && ( + + + + )} + + ); +} diff --git a/apps/web/src/features/dashboard/pages/DashboardPage.tsx b/apps/web/src/features/dashboard/pages/DashboardPage.tsx index 7338e2b68..770cfb3d9 100644 --- a/apps/web/src/features/dashboard/pages/DashboardPage.tsx +++ b/apps/web/src/features/dashboard/pages/DashboardPage.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'; import { useEffect } from 'react'; // Added useEffect import import { ErrorDisplay } from '@/components/ui/ErrorDisplay'; import { ContentTransition } from '@/components/ui/content-transition'; +import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; function DashboardPage() { const navigate = useNavigate(); @@ -33,7 +34,7 @@ function DashboardPage() { const stats = [ { title: 'Tracks Listened', - value: '1,234', + value: 1234, change: '+12%', icon: Music, color: 'text-primary', @@ -41,7 +42,7 @@ function DashboardPage() { }, { title: 'Messages Sent', - value: '567', + value: 567, change: '+8%', icon: MessageSquare, color: 'text-success', @@ -49,7 +50,7 @@ function DashboardPage() { }, { title: 'Favorites', - value: '89', + value: 89, change: '+23%', icon: Heart, color: 'text-destructive', @@ -57,7 +58,7 @@ function DashboardPage() { }, { title: 'Active Friends', - value: '45', + value: 45, change: '+5%', icon: Users, color: 'text-magenta-500', @@ -96,7 +97,7 @@ function DashboardPage() { -
{stat.value}
+

{stat.change} from last month

diff --git a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePageHeader.tsx b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePageHeader.tsx index 1e4835bc1..b58af3d66 100644 --- a/apps/web/src/features/profile/pages/user-profile-page/UserProfilePageHeader.tsx +++ b/apps/web/src/features/profile/pages/user-profile-page/UserProfilePageHeader.tsx @@ -2,6 +2,7 @@ import { MapPin, Calendar, User, Music, Library, Users } from 'lucide-react'; import { Avatar } from '@/components/ui/avatar'; import { Card, CardContent } from '@/components/ui/card'; import { FollowButton } from '../../components/FollowButton'; +import { AnimatedNumber } from '@/components/ui/AnimatedNumber'; import type { UserProfile } from '@/services/api/users'; interface UserProfilePageHeaderProps { @@ -113,9 +114,7 @@ export function UserProfilePageHeader({ className="w-4 h-4 mx-auto mb-1.5 text-muted-foreground group-hover/stat:text-primary transition-colors duration-[var(--duration-fast)]" aria-hidden /> -
- {stat.value} -
+
{stat.label}
diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index d25cc2260..deac8b4ab 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -23,6 +23,7 @@ export { useGlobalKeyboardShortcuts } from './useGlobalKeyboardShortcuts'; export { usePreload, usePreloadRoute } from './usePreload'; export { useCopyToClipboard } from './useCopyToClipboard'; export { useUnsavedChanges, useFormDirtyState } from './useUnsavedChanges'; +export { useAnimatedCounter } from './useAnimatedCounter'; // Hook types export type { diff --git a/apps/web/src/hooks/useAnimatedCounter.ts b/apps/web/src/hooks/useAnimatedCounter.ts new file mode 100644 index 000000000..1d19c17ea --- /dev/null +++ b/apps/web/src/hooks/useAnimatedCounter.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from 'react'; + +interface UseAnimatedCounterOptions { + /** Final value to count to */ + end: number; + /** Duration of the animation in ms */ + duration?: number; + /** Whether the animation should start (default: true) */ + enabled?: boolean; + /** Number of decimal places */ + decimals?: number; +} + +export function useAnimatedCounter({ + end, + duration = 1000, + enabled = true, + decimals = 0, +}: UseAnimatedCounterOptions): number { + const [count, setCount] = useState(0); + const frameRef = useRef(); + const startTimeRef = useRef(); + + useEffect(() => { + if (!enabled || end === 0) { + setCount(end); + return; + } + + const animate = (timestamp: number) => { + if (!startTimeRef.current) startTimeRef.current = timestamp; + const progress = Math.min((timestamp - startTimeRef.current) / duration, 1); + + // Ease-out cubic + const eased = 1 - Math.pow(1 - progress, 3); + const current = eased * end; + + setCount(Number(current.toFixed(decimals))); + + if (progress < 1) { + frameRef.current = requestAnimationFrame(animate); + } + }; + + frameRef.current = requestAnimationFrame(animate); + + return () => { + if (frameRef.current) cancelAnimationFrame(frameRef.current); + }; + }, [end, duration, enabled, decimals]); + + return count; +}