feat: implement leaderboard page with top performers and market activity
This commit is contained in:
parent
930d1f41d7
commit
35237c3470
7 changed files with 605 additions and 66 deletions
|
|
@ -17,7 +17,8 @@
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
Wallet
|
Wallet,
|
||||||
|
Trophy
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
@ -35,6 +36,7 @@
|
||||||
{ title: 'Home', url: '/', icon: Home },
|
{ title: 'Home', url: '/', icon: Home },
|
||||||
{ title: 'Market', url: '/market', icon: Store },
|
{ title: 'Market', url: '/market', icon: Store },
|
||||||
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
|
{ title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness },
|
||||||
|
{ title: 'Leaderboard', url: '/leaderboard', icon: Trophy },
|
||||||
{ title: 'Create coin', url: '/coin/create', icon: Coins }
|
{ title: 'Create coin', url: '/coin/create', icon: Coins }
|
||||||
],
|
],
|
||||||
navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }]
|
navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }]
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,42 @@ export function debounce(func: (...args: any[]) => void, wait: number) {
|
||||||
timeout = setTimeout(later, wait);
|
timeout = setTimeout(later, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPrice(price: number): string {
|
||||||
|
if (price < 0.01) {
|
||||||
|
return price.toFixed(6);
|
||||||
|
} else if (price < 1) {
|
||||||
|
return price.toFixed(4);
|
||||||
|
} else {
|
||||||
|
return price.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(value: number): string {
|
||||||
|
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||||
|
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||||
|
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||||
|
return `$${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuantity(value: number): string {
|
||||||
|
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||||
|
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||||
|
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatMarketCap = formatValue;
|
||||||
|
|
@ -70,6 +70,31 @@
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getPageTitle(routeId: string | null): string {
|
||||||
|
if (!routeId) return 'Rugplay';
|
||||||
|
|
||||||
|
const titleMap: Record<string, string> = {
|
||||||
|
'/': 'Home',
|
||||||
|
'/market': 'Market',
|
||||||
|
'/portfolio': 'Portfolio',
|
||||||
|
'/leaderboard': 'Leaderboard',
|
||||||
|
'/coin/create': 'Create Coin',
|
||||||
|
'/settings': 'Settings',
|
||||||
|
'/admin': 'Admin',
|
||||||
|
'/transactions': 'Transactions'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle dynamic routes
|
||||||
|
if (routeId.startsWith('/coin/[coinSymbol]')) {
|
||||||
|
return 'Coin Details';
|
||||||
|
}
|
||||||
|
if (routeId.startsWith('/user/[userId]')) {
|
||||||
|
return 'User Profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return titleMap[routeId] || 'Rugplay';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
|
|
@ -86,11 +111,7 @@
|
||||||
<Sidebar.Trigger class="-ml-1" />
|
<Sidebar.Trigger class="-ml-1" />
|
||||||
|
|
||||||
<h1 class="mr-6 text-base font-medium">
|
<h1 class="mr-6 text-base font-medium">
|
||||||
{#if page.route.id === '/coin/create'}
|
{getPageTitle(page.route.id)}
|
||||||
Coin: Create
|
|
||||||
{:else}
|
|
||||||
test
|
|
||||||
{/if}
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { getTimeBasedGreeting } from '$lib/utils';
|
import { getTimeBasedGreeting, formatPrice, formatMarketCap } from '$lib/utils';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
|
|
@ -29,26 +29,6 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatPrice(price: number): string {
|
|
||||||
if (price < 0.01) {
|
|
||||||
return price.toFixed(6);
|
|
||||||
} else if (price < 1) {
|
|
||||||
return price.toFixed(4);
|
|
||||||
} else {
|
|
||||||
return price.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMarketCap(value: number): string {
|
|
||||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
|
||||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
|
||||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
|
||||||
185
website/src/routes/api/leaderboard/+server.ts
Normal file
185
website/src/routes/api/leaderboard/+server.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user, transaction, userPortfolio, coin } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc, gte, and, sql, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const topRugpullers = await db
|
||||||
|
.select({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
totalSold: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'SELL' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`,
|
||||||
|
totalBought: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'BUY' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`
|
||||||
|
})
|
||||||
|
.from(transaction)
|
||||||
|
.innerJoin(user, eq(transaction.userId, user.id))
|
||||||
|
.where(gte(transaction.timestamp, twentyFourHoursAgo))
|
||||||
|
.groupBy(user.id, user.username, user.name, user.image)
|
||||||
|
.orderBy(desc(sql`SUM(CASE WHEN ${transaction.type} = 'SELL' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END) - SUM(CASE WHEN ${transaction.type} = 'BUY' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END)`))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
const userTransactions = await db
|
||||||
|
.select({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
type: transaction.type,
|
||||||
|
coinId: transaction.coinId,
|
||||||
|
totalAmount: sql<number>`CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC)`,
|
||||||
|
quantity: sql<number>`CAST(${transaction.quantity} AS NUMERIC)`
|
||||||
|
})
|
||||||
|
.from(transaction)
|
||||||
|
.innerJoin(user, eq(transaction.userId, user.id))
|
||||||
|
.where(gte(transaction.timestamp, twentyFourHoursAgo));
|
||||||
|
|
||||||
|
const userNetCalculations = new Map();
|
||||||
|
for (const tx of userTransactions) {
|
||||||
|
if (!userNetCalculations.has(tx.userId)) {
|
||||||
|
userNetCalculations.set(tx.userId, {
|
||||||
|
userId: tx.userId,
|
||||||
|
username: tx.username,
|
||||||
|
name: tx.name,
|
||||||
|
image: tx.image,
|
||||||
|
totalBought: 0,
|
||||||
|
totalSold: 0,
|
||||||
|
coinHoldings: new Map()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = userNetCalculations.get(tx.userId);
|
||||||
|
if (tx.type === 'BUY') {
|
||||||
|
userData.totalBought += Number(tx.totalAmount);
|
||||||
|
const currentHolding = userData.coinHoldings.get(tx.coinId) || 0;
|
||||||
|
userData.coinHoldings.set(tx.coinId, currentHolding + Number(tx.quantity));
|
||||||
|
} else {
|
||||||
|
userData.totalSold += Number(tx.totalAmount);
|
||||||
|
const currentHolding = userData.coinHoldings.get(tx.coinId) || 0;
|
||||||
|
userData.coinHoldings.set(tx.coinId, currentHolding - Number(tx.quantity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique coin IDs
|
||||||
|
const uniqueCoinIds = new Set();
|
||||||
|
for (const userData of userNetCalculations.values()) {
|
||||||
|
for (const [coinId] of userData.coinHoldings.entries()) {
|
||||||
|
uniqueCoinIds.add(coinId);
|
||||||
|
}
|
||||||
|
} // Batch fetch all coin prices
|
||||||
|
const coinPrices = new Map();
|
||||||
|
if (uniqueCoinIds.size > 0) {
|
||||||
|
const coinPricesData = await db
|
||||||
|
.select({ id: coin.id, currentPrice: coin.currentPrice })
|
||||||
|
.from(coin)
|
||||||
|
.where(inArray(coin.id, Array.from(uniqueCoinIds) as number[]));
|
||||||
|
|
||||||
|
for (const coinData of coinPricesData) {
|
||||||
|
coinPrices.set(coinData.id, Number(coinData.currentPrice || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const biggestLosersData = [];
|
||||||
|
for (const userData of userNetCalculations.values()) {
|
||||||
|
let currentHoldingsValue = 0;
|
||||||
|
|
||||||
|
for (const [coinId, quantity] of userData.coinHoldings.entries()) {
|
||||||
|
if (quantity > 0) {
|
||||||
|
const price = coinPrices.get(coinId) || 0;
|
||||||
|
currentHoldingsValue += quantity * price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const netLoss = userData.totalBought - userData.totalSold - currentHoldingsValue;
|
||||||
|
if (netLoss > 0) {
|
||||||
|
biggestLosersData.push({
|
||||||
|
userId: userData.userId,
|
||||||
|
username: userData.username,
|
||||||
|
name: userData.name,
|
||||||
|
image: userData.image,
|
||||||
|
moneySpent: userData.totalBought,
|
||||||
|
moneyReceived: userData.totalSold,
|
||||||
|
currentValue: currentHoldingsValue,
|
||||||
|
totalLoss: netLoss
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cashKings, paperMillionaires] = await Promise.all([
|
||||||
|
db.select({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||||
|
coinValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.leftJoin(userPortfolio, eq(userPortfolio.userId, user.id))
|
||||||
|
.leftJoin(coin, eq(coin.id, userPortfolio.coinId))
|
||||||
|
.groupBy(user.id, user.username, user.name, user.image, user.baseCurrencyBalance)
|
||||||
|
.orderBy(desc(sql`CAST(${user.baseCurrencyBalance} AS NUMERIC)`))
|
||||||
|
.limit(10),
|
||||||
|
|
||||||
|
db.select({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||||
|
coinValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.leftJoin(userPortfolio, eq(userPortfolio.userId, user.id))
|
||||||
|
.leftJoin(coin, eq(coin.id, userPortfolio.coinId))
|
||||||
|
.groupBy(user.id, user.username, user.name, user.image, user.baseCurrencyBalance)
|
||||||
|
.orderBy(desc(sql`CAST(${user.baseCurrencyBalance} AS NUMERIC) + COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`))
|
||||||
|
.limit(10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const processUser = (user: any) => {
|
||||||
|
const baseCurrencyBalance = Number(user.baseCurrencyBalance);
|
||||||
|
const coinValue = Number(user.coinValue);
|
||||||
|
const totalPortfolioValue = baseCurrencyBalance + coinValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
baseCurrencyBalance,
|
||||||
|
coinValue,
|
||||||
|
totalPortfolioValue,
|
||||||
|
liquidityRatio: totalPortfolioValue > 0 ? baseCurrencyBalance / totalPortfolioValue : 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedRugpullers = topRugpullers
|
||||||
|
.map(user => ({ ...user, totalExtracted: Number(user.totalSold) - Number(user.totalBought) }))
|
||||||
|
.filter(user => user.totalExtracted > 0);
|
||||||
|
|
||||||
|
const aggregatedLosers = biggestLosersData
|
||||||
|
.sort((a, b) => b.totalLoss - a.totalLoss)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const processedCashKings = cashKings.map(processUser);
|
||||||
|
const processedPaperMillionaires = paperMillionaires.map(processUser);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
topRugpullers: processedRugpullers,
|
||||||
|
biggestLosers: aggregatedLosers,
|
||||||
|
cashKings: processedCashKings,
|
||||||
|
paperMillionaires: processedPaperMillionaires
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch leaderboard data:', error);
|
||||||
|
return json({
|
||||||
|
topRugpullers: [],
|
||||||
|
biggestLosers: [],
|
||||||
|
cashKings: [],
|
||||||
|
paperMillionaires: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
349
website/src/routes/leaderboard/+page.svelte
Normal file
349
website/src/routes/leaderboard/+page.svelte
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { Trophy, TrendingDown, Crown, Skull, Target, RefreshCw } from 'lucide-svelte';
|
||||||
|
import { getPublicUrl, formatValue } from '$lib/utils';
|
||||||
|
|
||||||
|
let leaderboardData = $state<any>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await fetchLeaderboardData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchLeaderboardData() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/leaderboard');
|
||||||
|
if (response.ok) {
|
||||||
|
leaderboardData = await response.json();
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load leaderboard data');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch leaderboard data:', e);
|
||||||
|
toast.error('Failed to load leaderboard data');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankIcon(index: number) {
|
||||||
|
switch (index) {
|
||||||
|
case 0:
|
||||||
|
return { icon: Crown, color: 'text-yellow-500' };
|
||||||
|
case 1:
|
||||||
|
return { icon: Trophy, color: 'text-gray-400' };
|
||||||
|
case 2:
|
||||||
|
return { icon: Trophy, color: 'text-orange-600' };
|
||||||
|
default:
|
||||||
|
return { icon: Target, color: 'text-muted-foreground' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiquidityWarning(liquidityRatio: number) {
|
||||||
|
if (liquidityRatio < 0.1) return { text: '90%+ illiquid', color: 'text-destructive' };
|
||||||
|
if (liquidityRatio < 0.3) return { text: '70%+ illiquid', color: 'text-orange-600' };
|
||||||
|
if (liquidityRatio < 0.5) return { text: '50%+ illiquid', color: 'text-yellow-600' };
|
||||||
|
return { text: 'Mostly liquid', color: 'text-success' };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Leaderboard - Rugplay</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
|
<header class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Leaderboard</h1>
|
||||||
|
<p class="text-muted-foreground">Top performers and market activity</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={fetchLeaderboardData} disabled={loading}>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4 text-xl">Loading leaderboard...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !leaderboardData}
|
||||||
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-muted-foreground mb-4 text-xl">Failed to load leaderboard</div>
|
||||||
|
<Button onclick={fetchLeaderboardData}>Try Again</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<!-- Top Profit Makers -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-red-600">
|
||||||
|
<Skull class="h-6 w-6" />
|
||||||
|
Top Rugpullers (24h)
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Users who made the most profit from selling coins today
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if leaderboardData.topRugpullers.length === 0}
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="text-muted-foreground">No major profits recorded today</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Rank</Table.Head>
|
||||||
|
<Table.Head>User</Table.Head>
|
||||||
|
<Table.Head>Profit</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each leaderboardData.topRugpullers as user, index}
|
||||||
|
{@const rankInfo = getRankIcon(index)}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/user/${user.userId}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
|
||||||
|
<span class="font-mono text-sm">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
|
||||||
|
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{user.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-success font-mono text-sm font-bold">
|
||||||
|
{formatValue(user.totalExtracted)}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Biggest Losses -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-orange-600">
|
||||||
|
<TrendingDown class="h-6 w-6" />
|
||||||
|
Biggest Losses (24h)
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Users who experienced the largest losses today</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if leaderboardData.biggestLosers.length === 0}
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Everyone's in profit today! 📈 (This won't last...)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Rank</Table.Head>
|
||||||
|
<Table.Head>User</Table.Head>
|
||||||
|
<Table.Head>Loss</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each leaderboardData.biggestLosers as user, index}
|
||||||
|
{@const rankInfo = getRankIcon(index)}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/user/${user.userId}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
|
||||||
|
<span class="font-mono text-sm">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
|
||||||
|
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{user.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-destructive font-mono text-sm font-bold">
|
||||||
|
-{formatValue(user.totalLoss)}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Top Cash Holders -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-green-600">
|
||||||
|
<Crown class="h-6 w-6" />
|
||||||
|
Top Cash Holders
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Users with the highest liquid cash balances</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if leaderboardData.cashKings.length === 0}
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="text-muted-foreground">Everyone's invested! 💸</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Rank</Table.Head>
|
||||||
|
<Table.Head>User</Table.Head>
|
||||||
|
<Table.Head>Cash</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each leaderboardData.cashKings as user, index}
|
||||||
|
{@const rankInfo = getRankIcon(index)}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/user/${user.userId}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
|
||||||
|
<span class="font-mono text-sm">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
|
||||||
|
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{user.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-success font-mono text-sm font-bold">
|
||||||
|
{formatValue(user.baseCurrencyBalance)}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Top Portfolio Values -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-cyan-600">
|
||||||
|
<Trophy class="h-6 w-6" />
|
||||||
|
Highest Portfolio Values
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description
|
||||||
|
>Users with the largest total portfolio valuations (including illiquid)</Card.Description
|
||||||
|
>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if leaderboardData.paperMillionaires.length === 0}
|
||||||
|
<div class="py-8 text-center">
|
||||||
|
<p class="text-muted-foreground">No large portfolios yet! 📉</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Rank</Table.Head>
|
||||||
|
<Table.Head>User</Table.Head>
|
||||||
|
<Table.Head>Portfolio</Table.Head>
|
||||||
|
<Table.Head>Liquidity</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each leaderboardData.paperMillionaires as user, index}
|
||||||
|
{@const rankInfo = getRankIcon(index)}
|
||||||
|
{@const liquidityInfo = getLiquidityWarning(user.liquidityRatio)}
|
||||||
|
<Table.Row
|
||||||
|
class="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/user/${user.userId}`)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
|
||||||
|
<span class="font-mono text-sm">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
|
||||||
|
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{user.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-success font-mono text-sm font-bold">
|
||||||
|
{formatValue(user.totalPortfolioValue)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="secondary" class="text-xs {liquidityInfo.color}">
|
||||||
|
{liquidityInfo.text}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
import { getPublicUrl } from '$lib/utils';
|
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { TrendingUp, DollarSign, Wallet, TrendingDown, Clock, Receipt } from 'lucide-svelte';
|
import { TrendingUp, DollarSign, Wallet, TrendingDown, Clock, Receipt } from 'lucide-svelte';
|
||||||
|
|
@ -51,43 +51,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(price: number): string {
|
|
||||||
if (price < 0.01) {
|
|
||||||
return price.toFixed(6);
|
|
||||||
} else if (price < 1) {
|
|
||||||
return price.toFixed(4);
|
|
||||||
} else {
|
|
||||||
return price.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: number): string {
|
|
||||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
|
||||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
|
||||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatQuantity(value: number): string {
|
|
||||||
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
|
||||||
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
|
||||||
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
|
||||||
return value.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(timestamp: string): string {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPortfolioValue = $derived(portfolioData ? portfolioData.totalValue : 0);
|
let totalPortfolioValue = $derived(portfolioData ? portfolioData.totalValue : 0);
|
||||||
let hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0);
|
let hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0);
|
||||||
let hasTransactions = $derived(transactions.length > 0);
|
let hasTransactions = $derived(transactions.length > 0);
|
||||||
|
|
|
||||||
Reference in a new issue