diff --git a/website/src/lib/components/self/AppSidebar.svelte b/website/src/lib/components/self/AppSidebar.svelte index 312d82a..a028a2a 100644 --- a/website/src/lib/components/self/AppSidebar.svelte +++ b/website/src/lib/components/self/AppSidebar.svelte @@ -17,7 +17,8 @@ CreditCardIcon, BellIcon, LogOutIcon, - Wallet + Wallet, + Trophy } from 'lucide-svelte'; import { mode, setMode } from 'mode-watcher'; import type { HTMLAttributes } from 'svelte/elements'; @@ -35,6 +36,7 @@ { title: 'Home', url: '/', icon: Home }, { title: 'Market', url: '/market', icon: Store }, { title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness }, + { title: 'Leaderboard', url: '/leaderboard', icon: Trophy }, { title: 'Create coin', url: '/coin/create', icon: Coins } ], navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }] diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 2c32eb4..c7f3226 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -42,4 +42,43 @@ export function debounce(func: (...args: any[]) => void, wait: number) { clearTimeout(timeout); timeout = setTimeout(later, wait); }; -} \ No newline at end of file +} + +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; \ No newline at end of file diff --git a/website/src/routes/+layout.svelte b/website/src/routes/+layout.svelte index cccf9a0..028cd6e 100644 --- a/website/src/routes/+layout.svelte +++ b/website/src/routes/+layout.svelte @@ -70,6 +70,31 @@ invalidateAll(); } }); + + function getPageTitle(routeId: string | null): string { + if (!routeId) return 'Rugplay'; + + const titleMap: Record = { + '/': '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'; + } @@ -86,11 +111,7 @@

- {#if page.route.id === '/coin/create'} - Coin: Create - {:else} - test - {/if} + {getPageTitle(page.route.id)}

diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index 52a44e4..2ba6a62 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -2,7 +2,7 @@ import * as Card from '$lib/components/ui/card'; import * as Table from '$lib/components/ui/table'; 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 SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte'; import CoinIcon from '$lib/components/self/CoinIcon.svelte'; @@ -29,26 +29,6 @@ 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)}`; - } diff --git a/website/src/routes/api/leaderboard/+server.ts b/website/src/routes/api/leaderboard/+server.ts new file mode 100644 index 0000000..4401654 --- /dev/null +++ b/website/src/routes/api/leaderboard/+server.ts @@ -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`COALESCE(SUM(CASE WHEN ${transaction.type} = 'SELL' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`, + totalBought: sql`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`CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC)`, + quantity: sql`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`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`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: [] + }); + } +} diff --git a/website/src/routes/leaderboard/+page.svelte b/website/src/routes/leaderboard/+page.svelte new file mode 100644 index 0000000..6ba9d15 --- /dev/null +++ b/website/src/routes/leaderboard/+page.svelte @@ -0,0 +1,349 @@ + + + + Leaderboard - Rugplay + + +
+
+
+
+

Leaderboard

+

Top performers and market activity

+
+ +
+
+ + {#if loading} +
+
+
Loading leaderboard...
+
+
+ {:else if !leaderboardData} +
+
+
Failed to load leaderboard
+ +
+
+ {:else} +
+ + + + + + Top Rugpullers (24h) + + + Users who made the most profit from selling coins today + + + + {#if leaderboardData.topRugpullers.length === 0} +
+

No major profits recorded today

+
+ {:else} + + + + Rank + User + Profit + + + + {#each leaderboardData.topRugpullers as user, index} + {@const rankInfo = getRankIcon(index)} + goto(`/user/${user.userId}`)} + > + +
+ + #{index + 1} +
+
+ +
+ + + {user.name?.charAt(0) || '?'} + +
+

{user.name}

+

@{user.username}

+
+
+
+ + {formatValue(user.totalExtracted)} + +
+ {/each} +
+
+ {/if} +
+
+ + + + + + + Biggest Losses (24h) + + Users who experienced the largest losses today + + + {#if leaderboardData.biggestLosers.length === 0} +
+

+ Everyone's in profit today! 📈 (This won't last...) +

+
+ {:else} + + + + Rank + User + Loss + + + + {#each leaderboardData.biggestLosers as user, index} + {@const rankInfo = getRankIcon(index)} + goto(`/user/${user.userId}`)} + > + +
+ + #{index + 1} +
+
+ +
+ + + {user.name?.charAt(0) || '?'} + +
+

{user.name}

+

@{user.username}

+
+
+
+ + -{formatValue(user.totalLoss)} + +
+ {/each} +
+
+ {/if} +
+
+ + + + + + + Top Cash Holders + + Users with the highest liquid cash balances + + + {#if leaderboardData.cashKings.length === 0} +
+

Everyone's invested! 💸

+
+ {:else} + + + + Rank + User + Cash + + + + {#each leaderboardData.cashKings as user, index} + {@const rankInfo = getRankIcon(index)} + goto(`/user/${user.userId}`)} + > + +
+ + #{index + 1} +
+
+ +
+ + + {user.name?.charAt(0) || '?'} + +
+

{user.name}

+

@{user.username}

+
+
+
+ + {formatValue(user.baseCurrencyBalance)} + +
+ {/each} +
+
+ {/if} +
+
+ + + + + + + Highest Portfolio Values + + Users with the largest total portfolio valuations (including illiquid) + + + {#if leaderboardData.paperMillionaires.length === 0} +
+

No large portfolios yet! 📉

+
+ {:else} + + + + Rank + User + Portfolio + Liquidity + + + + {#each leaderboardData.paperMillionaires as user, index} + {@const rankInfo = getRankIcon(index)} + {@const liquidityInfo = getLiquidityWarning(user.liquidityRatio)} + goto(`/user/${user.userId}`)} + > + +
+ + #{index + 1} +
+
+ +
+ + + {user.name?.charAt(0) || '?'} + +
+

{user.name}

+

@{user.username}

+
+
+
+ + {formatValue(user.totalPortfolioValue)} + + + + {liquidityInfo.text} + + +
+ {/each} +
+
+ {/if} +
+
+
+ {/if} +
diff --git a/website/src/routes/portfolio/+page.svelte b/website/src/routes/portfolio/+page.svelte index 5f0e3b8..017a652 100644 --- a/website/src/routes/portfolio/+page.svelte +++ b/website/src/routes/portfolio/+page.svelte @@ -4,7 +4,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Button } from '$lib/components/ui/button'; 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 { toast } from 'svelte-sonner'; 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 hasHoldings = $derived(portfolioData && portfolioData.coinHoldings.length > 0); let hasTransactions = $derived(transactions.length > 0);