From e6c5a984a81821c4b6b102fb5b2d9696409c343b Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:52:08 +0800 Subject: [PATCH 1/6] feat: add loading skeleton animations for all pages (#827) --- frontend/src/lib/utils.ts | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 frontend/src/lib/utils.ts diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 00000000..7fb23fb7 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,75 @@ +export function cn(...classes: (string | false | null | undefined)[]): string { + return classes.filter(Boolean).join(' '); +} + +export function formatCurrency(amount: number | string, token = 'FNDRY'): string { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (isNaN(num)) return `0 $${token}`; + + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M $${token}`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K $${token}`; + } + return `${num.toLocaleString()} $${token}`; +} + +export function timeLeft(deadline: string | number | Date): string { + const now = Date.now(); + const target = new Date(deadline).getTime(); + const diff = target - now; + + if (diff <= 0) return 'Expired'; + + const days = Math.floor(diff / 86_400_000); + const hours = Math.floor((diff % 86_400_000) / 3_600_000); + + if (days > 0) return `${days}d ${hours}h left`; + const minutes = Math.floor((diff % 3_600_000) / 60_000); + return `${hours}h ${minutes}m left`; +} + +export const LANG_COLORS: Record = { + TypeScript: '#3178C6', + JavaScript: '#F7DF1E', + Python: '#3572A5', + Rust: '#DEA584', + Solidity: '#363636', + Go: '#00ADD8', + Java: '#B07219', + C: '#555555', + 'C++': '#F34B7D', + Ruby: '#701516', + Move: '#4F46E5', + 'Cairo': '#FF6900', +}; + +export function shortenAddress(address: string, chars = 4): string { + if (address.length <= chars * 2 + 3) return address; + return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`; +} + +export function timeAgo(date: string | number | Date): string { + const now = Date.now(); + const then = new Date(date).getTime(); + const seconds = Math.floor((now - then) / 1000); + + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + return `${months}mo ago`; +} + +/** + * Generate a deterministic skeleton key for React list rendering. + * Avoids rendering real data shapes during loading. + */ +export function skeletonKey(index: number, prefix = 'skeleton'): string { + return `${prefix}-${index}`; +} From 3add6776029555d46dd804cdc338175e72e3b528 Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:52:10 +0800 Subject: [PATCH 2/6] feat: add loading skeleton animations for all pages (#827) --- frontend/src/components/ui/Skeleton.tsx | 268 ++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 frontend/src/components/ui/Skeleton.tsx diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 00000000..efc1a6df --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { cn } from '../../lib/utils'; +import { skeletonPulse } from '../../lib/animations'; + +/* ------------------------------------------------------------------ */ +/* Primitive */ +/* ------------------------------------------------------------------ */ + +interface SkeletonBaseProps { + className?: string; + style?: React.CSSProperties; + /** If true, wraps in framer-motion for animated mount */ + animated?: boolean; +} + +/** + * A single shimmer block. Use this to compose higher-level skeletons. + */ +export function SkeletonBox({ className, style, animated = true }: SkeletonBaseProps) { + const cls = cn( + 'relative isolate overflow-hidden rounded-lg bg-forge-800', + 'animate-shimmer bg-gradient-to-r from-forge-800 via-forge-700 to-forge-800 bg-[length:200%_100%]', + className, + ); + + if (!animated) { + return
; + } + + return ( + + ); +} + +/** A single text-like line */ +export function SkeletonLine({ + width = '100%', + height = '14px', + className, +}: { + width?: string; + height?: string; + className?: string; +}) { + return ( + + ); +} + +/** Circular avatar placeholder */ +export function SkeletonAvatar({ + size = 40, + className, +}: { + size?: number; + className?: string; +}) { + return ( + + ); +} + +/** Badge / pill skeleton */ +export function SkeletonBadge({ + width = 48, + height = 22, + className, +}: { + width?: number; + height?: number; + className?: string; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Composed skeletons */ +/* ------------------------------------------------------------------ */ + +/** + * Matches the shape of `BountyCard`: + * + * 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?[icon] org/repo [T1] 鈹? * 鈹? 鈹? * 鈹? Title text line 鈹? * 鈹? Title line 2 (if long) 鈹? * 鈹? 鈹? * 鈹? 鈼?TypeScript 鈼?Rust 鈹? * 鈹?鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ 鈹? * 鈹? 100,000 $FNDRY 3 PRs 2d l 鈹? * 鈹? 鈼? 鈹? * 鈹斺攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? */ +export function SkeletonBountyCard({ animated = true }: { animated?: boolean }) { + return ( +
+ {/* Row 1: repo + tier */} +
+
+ + +
+ +
+ + {/* Row 2: title */} +
+ + +
+ + {/* Row 3: skill dots */} +
+ + +
+ + {/* Separator */} +
+ + {/* Row 4: reward + meta */} +
+ +
+ + +
+
+ + {/* Status dot */} + +
+ ); +} + +/** + * A grid of bounty-card skeletons. + * Drop-in replacement for the current inline loading state in BountyGrid. + */ +export function SkeletonBountyGrid({ + count = 6, + cols = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', +}: { + count?: number; + cols?: string; +}) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * Matches the shape of `LeaderboardTable` rows: + * + * 鈹屸攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?Rank 鈹?User 鈹?Bounties 鈹?Earned 鈹?Streak 鈹? * 鈹溾攢鈹€鈹€鈹€鈹€鈹€鈹尖攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹尖攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹尖攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹尖攢鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹? * 鈹?#4 鈹?[avatar] username 鈹? 12 鈹?$4.2K 鈹? 馃敟 5 鈹? * 鈹?#5 鈹?[avatar] username 鈹? 8 鈹?$3.1K 鈹? 鈥? 鈹? * ... + */ +export function SkeletonLeaderboardRow() { + return ( +
+ +
+ + +
+ + + +
+ ); +} + +export function SkeletonLeaderboardTable({ rows = 6 }: { rows?: number }) { + return ( +
+ {/* Header */} +
+ +
+ +
+ + + +
+ + {/* Rows */} + {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * Matches the shape of profile sections. + * Generic enough to be reused across profile dashboards and detail pages. + */ +export function SkeletonProfileSection() { + return ( +
+ {/* Header with avatar + name */} +
+ +
+ + +
+
+ + {/* Stats row */} +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ + {/* Body lines */} +
+ + + +
+
+ ); +} + +/** + * Podium skeleton 鈥?matches `PodiumCards` shape. + */ +export function SkeletonPodium() { + return ( +
+ {/* 2nd place */} +
+ + + +
+ {/* 1st place */} +
+ + + +
+ {/* 3rd place */} +
+ + + +
+
+ ); +} From ca826bdc7d5d54fb43536b78becdb1d19bdc7663 Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:52:13 +0800 Subject: [PATCH 3/6] feat: add loading skeleton animations for all pages (#827) --- frontend/src/lib/animations.ts | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 frontend/src/lib/animations.ts diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 00000000..3ea0d53e --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,71 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: 'easeOut' }, + }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 24 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.35, ease: 'easeOut' }, + }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { staggerChildren: 0.06 }, + }, +}; + +export const staggerItem: Variants = { + initial: { opacity: 0, y: 16 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: 'easeOut' }, + }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0 }, + animate: { + opacity: 1, + transition: { duration: 0.3, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; + +export const cardHover: Variants = { + rest: { scale: 1 }, + hover: { scale: 1.02, transition: { duration: 0.2, ease: 'easeOut' } }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { scale: 1.03, transition: { duration: 0.15, ease: 'easeOut' } }, + tap: { scale: 0.97 }, +}; + +// Skeleton-specific animations +export const skeletonPulse: Variants = { + initial: { opacity: 0.6 }, + animate: { + opacity: [0.6, 1, 0.6], + transition: { + duration: 1.5, + repeat: Infinity, + ease: 'easeInOut', + }, + }, +}; From 26026c18347bff3d73c3f05733004acfbe10fa7a Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:53:10 +0800 Subject: [PATCH 4/6] feat: add loading skeleton animations for all pages (#827) --- frontend/src/components/bounty/BountyGrid.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94..d3b4e6c9 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -5,6 +5,7 @@ import { ChevronDown, Loader2, Plus } from 'lucide-react'; import { BountyCard } from './BountyCard'; import { useInfiniteBounties } from '../../hooks/useBounties'; import { staggerContainer, staggerItem } from '../../lib/animations'; +import { SkeletonBountyGrid } from '../ui/Skeleton'; const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript']; @@ -70,25 +71,14 @@ export function BountyGrid() { ))}
- {/* Loading state */} - {isLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
- ))} -
- )} + {/* Loading state 鈥?shimmer skeletons matching BountyCard shape */} + {isLoading && } {/* Error state */} {isError && !isLoading && (

Could not load bounties. Backend may be offline.

-

Running in demo mode — no bounties to display.

+

Running in demo mode 鈥?no bounties to display.

)} From 28d24be77b25d64eb4542b3d1e1d8a27a3e05748 Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:53:11 +0800 Subject: [PATCH 5/6] feat: add loading skeleton animations for all pages (#827) --- frontend/src/pages/LeaderboardPage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/LeaderboardPage.tsx b/frontend/src/pages/LeaderboardPage.tsx index f79caff9..54acdc7f 100644 --- a/frontend/src/pages/LeaderboardPage.tsx +++ b/frontend/src/pages/LeaderboardPage.tsx @@ -6,6 +6,7 @@ import { LeaderboardTable } from '../components/leaderboard/LeaderboardTable'; import { useLeaderboard } from '../hooks/useLeaderboard'; import type { TimePeriod } from '../types/leaderboard'; import { fadeIn } from '../lib/animations'; +import { SkeletonPodium, SkeletonLeaderboardTable } from '../components/ui/Skeleton'; const PERIODS: { label: string; value: TimePeriod }[] = [ { label: '7d', value: '7d' }, @@ -45,10 +46,11 @@ export function LeaderboardPage() {
- {/* Loading */} + {/* Loading 鈥?skeleton podium + table */} {isLoading && ( -
-
+
+ +
)} From 762b75a27648115b6a8af189e2c13832934ea930 Mon Sep 17 00:00:00 2001 From: wingo-blue Date: Wed, 10 Jun 2026 09:53:12 +0800 Subject: [PATCH 6/6] feat: add loading skeleton animations for all pages (#827) --- .../components/profile/ProfileDashboard.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/profile/ProfileDashboard.tsx b/frontend/src/components/profile/ProfileDashboard.tsx index 2c509d7a..68b027ff 100644 --- a/frontend/src/components/profile/ProfileDashboard.tsx +++ b/frontend/src/components/profile/ProfileDashboard.tsx @@ -7,6 +7,7 @@ import { useBounties } from '../../hooks/useBounties'; import { timeAgo, formatCurrency } from '../../lib/utils'; import { fadeIn, staggerContainer, staggerItem } from '../../lib/animations'; import type { Bounty } from '../../types/bounty'; +import { SkeletonProfileSection } from '../ui/Skeleton'; const TABS = ['My Bounties', 'My Submissions', 'Earnings', 'Settings'] as const; type Tab = typeof TABS[number]; @@ -35,15 +36,19 @@ function BountyStatusBadge({ status }: { status: string }) { function MyBountiesTab({ bounties, loading }: { bounties: Bounty[]; loading: boolean }) { if (loading) { - return
Loading...
; + return ( +
+ + +
+ ); } if (!bounties.length) { return (

You haven't created any bounties yet.

- Post your first bounty → - + Post your first bounty 鈫?
); } @@ -76,8 +81,7 @@ function SubmissionsTab() {

No submissions yet.

- Browse open bounties → - + Browse open bounties 鈫?
); } @@ -130,7 +134,7 @@ function SettingsTab() {
Email - {user?.email ?? '—'} + {user?.email ?? '鈥?}
@@ -176,7 +180,7 @@ export function ProfileDashboard() {

{user.username}

- Joined {joinDate} · {myBounties.length} bounties created + Joined {joinDate} 路 {myBounties.length} bounties created