diff --git a/website/src/lib/components/self/UserProfilePreview.svelte b/website/src/lib/components/self/UserProfilePreview.svelte index 8fc1ff4..9274db0 100644 --- a/website/src/lib/components/self/UserProfilePreview.svelte +++ b/website/src/lib/components/self/UserProfilePreview.svelte @@ -7,7 +7,7 @@ import { Calendar, Wallet } from 'lucide-svelte'; import type { UserProfileData } from '$lib/types/user-profile'; - let { userId }: { userId: number } = $props(); + let { userId, showBio = true }: { userId: number, showBio?: boolean } = $props(); let userData = $state(null); let loading = $state(true); @@ -93,7 +93,7 @@

@{profile.username}

- {#if profile.bio} + {#if profile.bio && showBio}

{profile.bio}

{/if} diff --git a/website/src/lib/components/self/skeletons/LeaderboardSearchSkeleton.svelte b/website/src/lib/components/self/skeletons/LeaderboardSearchSkeleton.svelte new file mode 100644 index 0000000..43d480a --- /dev/null +++ b/website/src/lib/components/self/skeletons/LeaderboardSearchSkeleton.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ {#each Array(9) as _} + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ {/each} +
+
+
diff --git a/website/src/routes/api/leaderboard/+server.ts b/website/src/routes/api/leaderboard/+server.ts index 4401654..67a2c83 100644 --- a/website/src/routes/api/leaderboard/+server.ts +++ b/website/src/routes/api/leaderboard/+server.ts @@ -1,9 +1,9 @@ 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'; +import { eq, desc, gte, and, sql, inArray, ilike, count } from 'drizzle-orm'; -export async function GET() { +async function getLeaderboardData() { try { const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); @@ -183,3 +183,53 @@ export async function GET() { }); } } + +async function getSearchedUsers(query: string, limit = 9, offset = 0) { + try { + const results = await db.select({ + id: user.id, + name: user.name, + username: user.username, + image: user.image, + bio: user.bio, + baseCurrencyBalance: user.baseCurrencyBalance, + coinValue: sql`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`, + totalPortfolioValue: sql`CAST(${user.baseCurrencyBalance} AS NUMERIC) + COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`, + createdAt: user.createdAt + }).from(user) + .leftJoin(userPortfolio, eq(userPortfolio.userId, user.id)) + .leftJoin(coin, eq(coin.id, userPortfolio.coinId)) + .groupBy(user.id, user.name, user.username, user.image, user.bio, user.baseCurrencyBalance) + .where(ilike(user.username, `%${query}%`)) + .orderBy(desc(user.username)) + .limit(limit) + .offset(offset); + + const total = await db.select({ count: count() }).from(user).where(ilike(user.username, `%${query}%`)).limit(1); + + return { + results, + total: total[0].count + } + } catch (error) { + return { + results: [], + total: 0 + } + } +} + +export async function GET({ url }) { + const query = url.searchParams.get('search'); + + if(query?.trim() !== '' && query !== null) { + const limit = parseInt(url.searchParams.get('limit') || '9'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + + let users = await getSearchedUsers(query, limit, offset); + + return json(users); + } + + return await getLeaderboardData(); +} diff --git a/website/src/routes/leaderboard/+page.svelte b/website/src/routes/leaderboard/+page.svelte index 979e434..f07b47d 100644 --- a/website/src/routes/leaderboard/+page.svelte +++ b/website/src/routes/leaderboard/+page.svelte @@ -7,20 +7,67 @@ import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; import { goto } from '$app/navigation'; - import { TrendingDown, Crown, Skull, Target, RefreshCw, Trophy } from 'lucide-svelte'; - import { formatValue } from '$lib/utils'; + import { + TrendingDown, + Crown, + Skull, + Target, + RefreshCw, + Trophy, + Search, + SearchX, + X, + Wallet, + Calendar + } from 'lucide-svelte'; + import { formatValue, getPublicUrl } from '$lib/utils'; + import Input from '$lib/components/ui/input/input.svelte'; + import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte'; + import LeaderboardSearchSkeleton from '$lib/components/self/skeletons/LeaderboardSearchSkeleton.svelte'; + import * as Avatar from '$lib/components/ui/avatar'; + import ProfileBadges from '$lib/components/self/ProfileBadges.svelte'; + let searchOffset = $state(0); + let searchQuery = $state(''); + let searchQueryValue = $state(''); + let searchQueryTimeout = $state(null); let leaderboardData = $state(null); let loading = $state(true); onMount(async () => { + const urlParams = new URLSearchParams(window.location.search); + const search = urlParams.get('search'); + + searchQuery = search || ''; + searchQueryValue = search || ''; + await fetchLeaderboardData(); }); - async function fetchLeaderboardData() { + function handleSearchKeydown(event: KeyboardEvent) { + if (!/^[a-zA-Z0-9]$/.test(event.key) && event.key !== 'Enter') { + return; + } + if (searchQueryTimeout) { + clearTimeout(searchQueryTimeout); + } + + if (event.key === 'Enter') { + searchQueryValue = searchQuery; + fetchLeaderboardData(); + return; + } + + searchQueryTimeout = setTimeout(() => { + searchQueryValue = searchQuery; + fetchLeaderboardData(); + }, 500); + } + + async function fetchLeaderboardData(offset = 0) { loading = true; try { - const response = await fetch('/api/leaderboard'); + const response = await fetch(`/api/leaderboard?search=${searchQueryValue}&offset=${offset}`); if (response.ok) { leaderboardData = await response.json(); } else { @@ -54,6 +101,19 @@ return { text: 'Mostly liquid', color: 'text-success' }; } + const searchColumns = [ + { + key: 'user', + label: 'User', + render: (value: any, row: any) => ({ + component: 'user', + image: row.image, + name: row.name, + username: row.username + }) + } + ]; + const rugpullersColumns = [ { key: 'rank', @@ -209,111 +269,250 @@

Leaderboard

Top performers and market activity

- +
+
+ + +
+ {#if searchQueryValue} + + {/if} + +
- {#if loading} - + {#if searchQueryValue} + + {:else} + + {/if} {:else if !leaderboardData}
Failed to load leaderboard
- +
{:else}
- - - - - - Top Rugpullers (24h) - - - Users who made the most profit from selling coins today - - - - goto(`/user/${user.userUsername || user.username}`)} - emptyMessage="No major profits recorded today" - enableUserPreview={true} - /> - - + {#if searchQueryValue} + {#if leaderboardData.results.length > 0} +
+
+ {#each leaderboardData.results as user} + goto(`/user/${user.username}`)} + > + +
+ + + {user.name?.charAt(0) || '?'} + +
+
+

+ {user.name} +

+ +
+

@{user.username}

+
+
+ + + Portfolio + + + {formatValue(user.totalPortfolioValue)} + +
- - - - - - Biggest Losses (24h) - - Users who experienced the largest losses today - - - goto(`/user/${user.userUsername || user.username}`)} - emptyMessage="No major losses recorded today" - enableUserPreview={true} - /> - - +
+ + + Cash + + + {formatValue(user.baseCurrencyBalance)} + +
+
+
+ +

+ Joined {new Date(user.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long' + })} +

+
+
+
+
+
+ {/each} +
+
+
+

+ Showing {1 + searchOffset} - {Math.min(leaderboardData.results.length, 9) + + searchOffset} of {leaderboardData.total} results +

+
+ {#each Array(Math.ceil(leaderboardData.total / 9)) as _, index} + + {/each} +
+
+ {:else} +
+

No users found

+

+ No users match your search "{searchQueryValue}" +

+ +
+ {/if} + {:else} + + + + + + Top Rugpullers (24h) + + + Users who made the most profit from selling coins today + + + + goto(`/user/${user.userUsername || user.username}`)} + emptyMessage="No major profits recorded today" + enableUserPreview={true} + /> + + - - - - - - Highest Portfolio Values - - Users with the largest total portfolio valuations (including illiquid) - - - goto(`/user/${user.userUsername || user.username}`)} - emptyMessage="No large portfolios yet! 📉" - enableUserPreview={true} - /> - - + + + + + + Biggest Losses (24h) + + Users who experienced the largest losses today + + + goto(`/user/${user.userUsername || user.username}`)} + emptyMessage="No major losses recorded today" + enableUserPreview={true} + /> + + + + + + + + + Top Cash Holders + + Users with the highest liquid cash balances + + + goto(`/user/${user.userUsername || user.username}`)} + emptyMessage="Everyone's invested! 💸" + enableUserPreview={true} + /> + + + + + + + + + Highest Portfolio Values + + Users with the largest total portfolio valuations (including illiquid) + + + goto(`/user/${user.userUsername || user.username}`)} + emptyMessage="No large portfolios yet! 📉" + enableUserPreview={true} + /> + + + {/if}
{/if}