feat(ui): animated number counters + navigation progress bar
Animated numbers: - New useAnimatedCounter hook (requestAnimationFrame, ease-out cubic) - New AnimatedNumber component with tabular-nums - Applied to: DashboardPage (4 stats), UserProfilePageHeader (3 stats), StatCard, AdminDashboardStatCard (numeric values auto-animate) Navigation progress bar: - YouTube/GitHub-style thin bar at top of page - Simulated progress on route changes (framer-motion) - Primary color with glow shadow - Integrated into Layout as first child Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ef9089905
commit
8f26a2b3e9
9 changed files with 143 additions and 10 deletions
|
|
@ -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<StatCardProps['color'], string> = {
|
||||
|
|
@ -60,7 +61,11 @@ export function AdminDashboardStatCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-display font-bold text-white mb-1 relative z-10">
|
||||
{value ?? '—'}
|
||||
{typeof value === 'number' ? (
|
||||
<AnimatedNumber value={value} />
|
||||
) : (
|
||||
value ?? '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-widest font-mono relative z-10">
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -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<StatCardProps> = ({
|
|||
{label}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold text-foreground tracking-tight truncate">
|
||||
{value}
|
||||
{typeof value === 'number' ? (
|
||||
<AnimatedNumber value={value} />
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg shrink-0 ${bgMap[color]} ${colorMap[color]}`}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-background text-foreground relative overflow-x-hidden">
|
||||
<NavigationProgress />
|
||||
<AstralBackground />
|
||||
|
||||
<Header />
|
||||
|
|
|
|||
16
apps/web/src/components/ui/AnimatedNumber.tsx
Normal file
16
apps/web/src/components/ui/AnimatedNumber.tsx
Normal file
|
|
@ -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 <span className={cn('tabular-nums', className)}>{display}</span>;
|
||||
}
|
||||
51
apps/web/src/components/ui/NavigationProgress.tsx
Normal file
51
apps/web/src/components/ui/NavigationProgress.tsx
Normal file
|
|
@ -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 (
|
||||
<AnimatePresence>
|
||||
{isNavigating && (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 z-[100] h-0.5"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-primary shadow-[0_0_10px_var(--primary)]"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.icon className={cn("h-4 w-4 transition-all duration-[var(--duration-normal)]", stat.color, stat.shadow, "group-hover:scale-110")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white tracking-tight">{stat.value}</div>
|
||||
<AnimatedNumber value={stat.value} className="text-2xl font-bold text-white tracking-tight" />
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<span className="text-success font-medium">{stat.change}</span> from last month
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
<div className="text-2xl font-bold font-display text-white tabular-nums">
|
||||
{stat.value}
|
||||
</div>
|
||||
<AnimatedNumber value={stat.value} className="text-2xl font-bold font-display text-white" />
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground font-bold">
|
||||
{stat.label}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
53
apps/web/src/hooks/useAnimatedCounter.ts
Normal file
53
apps/web/src/hooks/useAnimatedCounter.ts
Normal file
|
|
@ -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<number>();
|
||||
const startTimeRef = useRef<number>();
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue