Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions frontend/src/components/leaderboard/GamificationBadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Flame, Star, Shield, Zap, Award } from 'lucide-react';
import type { ContributorTier, BadgeType } from '../../lib/gamification';

interface TierIndicatorProps {
tier: string;
className?: string;
}

export function TierIndicator({ tier, className = '' }: TierIndicatorProps) {
// Mapping tiers to specific styling and icons
let Icon = Shield;
let styleClass = 'bg-forge-800 text-text-muted border-border';

switch (tier.toLowerCase()) {
case 'novice':
Icon = Shield;
styleClass = 'bg-forge-800 text-text-muted border-border';
break;
case 'adept':
Icon = Zap;
styleClass = 'bg-emerald/10 text-emerald border-emerald/20';
break;
case 'master':
Icon = Star;
styleClass = 'bg-purple/10 text-purple border-purple/20';
break;
case 'grandmaster':
Icon = Award;
styleClass = 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20 shadow-[0_0_10px_rgba(234,179,8,0.2)]';
break;
}

return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md border text-[10px] font-bold uppercase tracking-wider ${styleClass} ${className}`}>
<Icon className="w-3 h-3" />
{tier}
</span>
);
}

interface ContributorBadgeProps {
badge: string;
className?: string;
}

export function ContributorBadge({ badge, className = '' }: ContributorBadgeProps) {
let styleClass = '';

switch (badge.toLowerCase()) {
case 'gold':
styleClass = 'text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.5)]';
break;
case 'silver':
styleClass = 'text-zinc-300 drop-shadow-[0_0_3px_rgba(212,212,216,0.4)]';
break;
case 'bronze':
styleClass = 'text-amber-600 drop-shadow-[0_0_2px_rgba(217,119,6,0.4)]';
break;
}

return (
<div title={`${badge} Contributor`} className={`flex items-center justify-center ${className}`}>
<svg className={`w-5 h-5 ${styleClass}`} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l2.4 7.4h7.6l-6 4.6 2.3 7.5-6.3-4.8-6.3 4.8 2.3-7.5-6-4.6h7.6z" />
</svg>
</div>
);
}

interface AnimatedStreakProps {
streak: number;
className?: string;
}

export function AnimatedStreak({ streak, className = '' }: AnimatedStreakProps) {
if (streak <= 0) return <span className="text-text-muted">β€”</span>;

const isHot = streak >= 3;
const isOnFire = streak >= 7;

// More intense animation for higher streaks
const pulseAnimation = isOnFire
? { scale: [1, 1.2, 1], opacity: [0.8, 1, 0.8] }
: isHot
? { scale: [1, 1.1, 1] }
: {};

const colorClass = isOnFire
? 'text-status-error drop-shadow-[0_0_6px_rgba(255,82,82,0.8)]'
: isHot
? 'text-orange-500 drop-shadow-[0_0_4px_rgba(249,115,22,0.6)]'
: 'text-status-warning';

return (
<span className={`font-mono text-sm inline-flex items-center gap-1 ${colorClass} ${className}`}>
<motion.div
animate={pulseAnimation}
transition={{ duration: isOnFire ? 0.8 : 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<Flame className={`w-4 h-4 ${isOnFire ? 'fill-current' : ''}`} />
</motion.div>
<span className="font-bold">{streak}</span>
</span>
);
}
95 changes: 50 additions & 45 deletions frontend/src/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Flame } from 'lucide-react';
import type { LeaderboardEntry } from '../../types/leaderboard';
import { LANG_COLORS } from '../../lib/utils';
import { fadeIn } from '../../lib/animations';
import { enrichLeaderboardEntry } from '../../lib/gamification';
import { TierIndicator, ContributorBadge, AnimatedStreak } from './GamificationBadges';

interface LeaderboardTableProps {
entries: LeaderboardEntry[];
Expand Down Expand Up @@ -47,54 +49,57 @@ export function LeaderboardTable({ entries }: LeaderboardTableProps) {
<div className="w-[80px] text-center hidden sm:block">Streak</div>
</div>

{tableEntries.map((entry) => (
<motion.div
key={entry.username}
layout
layoutId={`leaderboard-${entry.username}`}
className="flex items-center px-4 py-3 border-b border-border/30 last:border-b-0 hover:bg-forge-850 transition-colors duration-150 cursor-pointer"
>
<div className="w-[60px] text-center font-mono text-sm text-text-muted">
#{entry.rank}
</div>
<div className="flex-1 flex items-center gap-3 min-w-0">
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" className="w-6 h-6 rounded-full flex-shrink-0" />
) : (
<div className="w-6 h-6 rounded-full bg-forge-700 flex-shrink-0 flex items-center justify-center">
<span className="font-mono text-xs text-text-muted">{entry.username[0]?.toUpperCase()}</span>
</div>
)}
<div className="min-w-0">
<span className="font-sans text-sm font-medium text-text-primary truncate block">
{entry.username}
</span>
{entry.topSkills?.length > 0 && (
<div className="flex items-center gap-1 mt-0.5">
{entry.topSkills.slice(0, 4).map((s) => (
<SkillDot key={s} skill={s} />
))}
{tableEntries.map((rawEntry) => {
const entry = enrichLeaderboardEntry(rawEntry);
return (
<motion.div
key={entry.username}
layout
layoutId={`leaderboard-${entry.username}`}
className="flex items-center px-4 py-3 border-b border-border/30 last:border-b-0 hover:bg-forge-850 transition-colors duration-150 cursor-pointer"
>
<div className="w-[60px] text-center font-mono text-sm text-text-muted">
#{entry.rank}
</div>
<div className="flex-1 flex items-center gap-3 min-w-0">
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" className="w-8 h-8 rounded-full flex-shrink-0 border border-border/50" />
) : (
<div className="w-8 h-8 rounded-full bg-forge-700 flex-shrink-0 flex items-center justify-center border border-border/50">
<span className="font-mono text-xs text-text-muted">{entry.username[0]?.toUpperCase()}</span>
</div>
)}
<div className="min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-sans text-sm font-medium text-text-primary truncate">
{entry.username}
</span>
{entry.tier && <TierIndicator tier={entry.tier} className="hidden sm:inline-flex" />}
{entry.badges?.slice(0, 3).map((badge) => (
<ContributorBadge key={badge} badge={badge} className="hidden md:flex" />
))}
</div>
{entry.topSkills?.length > 0 && (
<div className="flex items-center gap-1.5 mt-0.5">
{entry.topSkills.slice(0, 4).map((s) => (
<SkillDot key={s} skill={s} />
))}
</div>
)}
</div>
</div>
<div className="w-[100px] text-center font-mono text-sm text-text-secondary">
{entry.bountiesCompleted}
</div>
<div className="w-[120px] text-right font-mono text-sm font-semibold text-emerald">
${entry.earningsFndry.toLocaleString()}
</div>
<div className="w-[80px] text-center hidden sm:flex justify-center">
<AnimatedStreak streak={entry.streak ?? 0} />
</div>
</div>
<div className="w-[100px] text-center font-mono text-sm text-text-secondary">
{entry.bountiesCompleted}
</div>
<div className="w-[120px] text-right font-mono text-sm font-semibold text-emerald">
${entry.earningsFndry.toLocaleString()}
</div>
<div className="w-[80px] text-center hidden sm:block">
{entry.streak && entry.streak > 0 ? (
<span className="font-mono text-sm text-status-warning inline-flex items-center gap-1">
<Flame className="w-3.5 h-3.5" /> {entry.streak}
</span>
) : (
<span className="text-text-muted">β€”</span>
)}
</div>
</motion.div>
))}
</motion.div>
);
})}
</motion.div>
);
}
67 changes: 48 additions & 19 deletions frontend/src/components/leaderboard/PodiumCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { motion } from 'framer-motion';
import { Crown } from 'lucide-react';
import type { LeaderboardEntry } from '../../types/leaderboard';
import { staggerContainer, staggerItem } from '../../lib/animations';
import { enrichLeaderboardEntry } from '../../lib/gamification';
import { TierIndicator, ContributorBadge, AnimatedStreak } from './GamificationBadges';

interface PodiumCardsProps {
entries: LeaderboardEntry[];
Expand All @@ -29,37 +31,64 @@ function PodiumCard({ entry, rank }: { entry: LeaderboardEntry; rank: number })
const avatarSize = isGold ? 'w-14 h-14' : 'w-12 h-12';
const padding = isGold ? 'py-8 px-6' : 'py-6 px-6';

const e = enrichLeaderboardEntry(entry);

return (
<motion.div
variants={staggerItem}
className={`relative flex flex-col items-center rounded-xl border bg-forge-900 ${borderClass} ${padding} min-w-[140px]`}
className={`relative flex flex-col items-center rounded-xl border bg-forge-900 ${borderClass} ${padding} min-w-[140px] md:min-w-[160px]`}
>
{/* Crown for #1 */}
{isGold && (
<Crown className="absolute -top-4 text-yellow-400 w-6 h-6" />
<Crown className="absolute -top-4 text-yellow-400 w-6 h-6 z-10" />
)}

<span className={`absolute -top-3 font-display text-sm font-bold ${rankColor}`}>
<span className={`absolute -top-3 font-display text-sm font-bold ${rankColor} z-10 bg-forge-900 px-2 rounded-full border border-inherit`}>
#{rank}
</span>

{entry.avatarUrl ? (
<img
src={entry.avatarUrl}
alt={entry.username}
className={`${avatarSize} rounded-full border-2 ${avatarBorderClass}`}
/>
) : (
<div className={`${avatarSize} rounded-full border-2 ${avatarBorderClass} bg-forge-700 flex items-center justify-center`}>
<span className="font-display text-lg text-text-muted">{entry.username[0]?.toUpperCase()}</span>
</div>
)}
<div className="relative mt-2">
{e.avatarUrl ? (
<img
src={e.avatarUrl}
alt={e.username}
className={`${avatarSize} rounded-full border-2 ${avatarBorderClass}`}
/>
) : (
<div className={`${avatarSize} rounded-full border-2 ${avatarBorderClass} bg-forge-700 flex items-center justify-center`}>
<span className="font-display text-lg text-text-muted">{e.username[0]?.toUpperCase()}</span>
</div>
)}

{/* Absolute top badge if they have one */}
{e.badges && e.badges.length > 0 && (
<div className="absolute -bottom-2 -right-2">
<ContributorBadge badge={e.badges[0]} className="w-6 h-6" />
</div>
)}
</div>

<span className="mt-3 font-sans text-sm font-semibold text-text-primary">{entry.username}</span>
<span className="mt-1 font-mono text-xs text-text-muted">{entry.bountiesCompleted} bounties</span>
<span className="mt-1 font-mono text-lg font-semibold text-emerald">
${entry.earningsFndry.toLocaleString()}
</span>
<div className="mt-4 flex flex-col items-center gap-1">
<span className="font-sans text-sm font-semibold text-text-primary text-center truncate max-w-[120px]">{e.username}</span>
{e.tier && <TierIndicator tier={e.tier} className="mt-1 mb-2" />}
</div>

<div className="flex items-center justify-between w-full mt-2 px-2 border-t border-border/50 pt-3">
<div className="flex flex-col items-start">
<span className="font-mono text-xs text-text-muted">Bounties</span>
<span className="font-mono text-sm font-medium text-text-primary">{e.bountiesCompleted}</span>
</div>
<div className="flex flex-col items-end">
<span className="font-mono text-xs text-text-muted">Streak</span>
<AnimatedStreak streak={e.streak ?? 0} />
</div>
</div>

<div className="mt-3 w-full text-center bg-forge-950/50 py-1.5 rounded-md border border-border/30">
<span className="font-mono text-sm font-semibold text-emerald">
${e.earningsFndry.toLocaleString()}
</span>
</div>
</motion.div>
);
}
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/lib/gamification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { LeaderboardEntry } from '../types/leaderboard';

export type ContributorTier = 'Novice' | 'Adept' | 'Master' | 'Grandmaster';
export type BadgeType = 'Gold' | 'Silver' | 'Bronze';

export function computeTier(reputation: number): ContributorTier {
if (reputation >= 90) return 'Grandmaster';
if (reputation >= 70) return 'Master';
if (reputation >= 30) return 'Adept';
return 'Novice';
}

export function computeBadges(bountiesCompleted: number): BadgeType[] {
const badges: BadgeType[] = [];
if (bountiesCompleted >= 1) badges.push('Bronze');
if (bountiesCompleted >= 5) badges.push('Silver');
if (bountiesCompleted >= 15) badges.push('Gold');
// Sort badges high-to-low so highest is displayed first
return badges.reverse();
}

/**
* Ensures the entry has computed gamification fields if missing from the API.
*/
export function enrichLeaderboardEntry(entry: LeaderboardEntry): LeaderboardEntry {
return {
...entry,
tier: entry.tier ?? computeTier(entry.reputation),
badges: entry.badges ?? computeBadges(entry.bountiesCompleted),
};
}
2 changes: 2 additions & 0 deletions frontend/src/types/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface LeaderboardEntry {
reputation: number;
stakedFndry: number;
reputationBoost: number;
tier?: string;
badges?: string[];
}

export interface PlatformStats {
Expand Down