feat: search for users by name on the leaderboard page.

This commit is contained in:
= 2025-07-02 11:41:20 +01:00
parent 75abbb3d51
commit 1d7b47a0b6
2 changed files with 215 additions and 92 deletions

View file

@ -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 } from 'drizzle-orm';
export async function GET() {
async function getLeaderboardData() {
try {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
@ -183,3 +183,40 @@ export async function GET() {
});
}
}
async function getSearchedUsers(query: string) {
try {
return await db.select({
userId: user.id,
username: user.username,
name: user.name,
image: user.image,
coinsValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`,
balanceValue: user.baseCurrencyBalance,
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(user)
.leftJoin(userPortfolio, eq(userPortfolio.userId, user.id))
.leftJoin(coin, eq(coin.id, userPortfolio.coinId))
.leftJoin(transaction, eq(transaction.userId, user.id))
.where(ilike(user.username, `%${query}%`))
.groupBy(user.id, user.username, user.name, user.image)
.orderBy(desc(sql`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`))
.limit(2);
} catch (error) {
return [];
}
}
export async function GET({ url }) {
const query = url.searchParams.get('search');
if(query?.trim() !== '' && query !== null) {
let users = await getSearchedUsers(query);
return json({
results: users
});
}
return await getLeaderboardData();
}

View file

@ -7,20 +7,51 @@
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 { TrendingDown, Crown, Skull, Target, RefreshCw, Trophy, Search, SearchX, X } from 'lucide-svelte';
import { formatValue } from '$lib/utils';
import Input from '$lib/components/ui/input/input.svelte';
import * as HoverCard from '$lib/components/ui/hover-card';
import * as Avatar from '$lib/components/ui/avatar';
import { getPublicUrl } from '$lib/utils';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
let searchQuery = $state('');
let searchQueryValue = $state('');
let searchQueryTimeout = $state<NodeJS.Timeout | null>(null);
let leaderboardData = $state<any>(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();
});
function handleSearchKeydown(event: KeyboardEvent) {
if (searchQueryTimeout) {
clearTimeout(searchQueryTimeout);
}
searchQueryTimeout = setTimeout(() => {
searchQueryValue = searchQuery;
fetchLeaderboardData();
}, 500);
if (event.key === 'Enter') {
clearTimeout(searchQueryTimeout);
searchQueryValue = searchQuery;
fetchLeaderboardData();
}
}
async function fetchLeaderboardData() {
loading = true;
try {
const response = await fetch('/api/leaderboard');
const response = await fetch(`/api/leaderboard?search=${searchQueryValue}`);
if (response.ok) {
leaderboardData = await response.json();
} else {
@ -54,6 +85,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,10 +253,24 @@
<h1 class="text-2xl font-bold md:text-3xl">Leaderboard</h1>
<p class="text-muted-foreground text-sm md:text-base">Top performers and market activity</p>
</div>
<Button variant="outline" onclick={fetchLeaderboardData} disabled={loading} class="w-fit">
<RefreshCw class="h-4 w-4" />
Refresh
</Button>
<div class="flex items-center gap-4">
<div class="flex items-center relative">
<Search size={16} class="absolute left-3 pointer-events-none"></Search>
<Input type="text" placeholder="Search" class="pl-10" bind:value={searchQuery} onkeydown={handleSearchKeydown} />
</div>
{#if searchQueryValue}
<Button variant="outline" onclick={() => {
searchQuery = '';
searchQueryValue = '';
fetchLeaderboardData();
}} disabled={loading} class="w-fit">
<X class="h-4 w-4" />
</Button>
{/if}
<Button variant="outline" onclick={fetchLeaderboardData} disabled={loading} class="w-fit">
<RefreshCw class="h-4 w-4" />
</Button>
</div>
</div>
</header>
@ -227,93 +285,121 @@
</div>
{:else}
<div class="grid gap-4 md:gap-6 xl:grid-cols-2">
<!-- Top Profit Makers -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-red-600 md:text-xl">
<Skull class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Top Rugpullers (24h)</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm">
Users who made the most profit from selling coins today
</Card.Description>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={rugpullersColumns}
data={leaderboardData.topRugpullers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major profits recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
{#if searchQueryValue}
{#if leaderboardData.results.length > 0}
<div class="flex flex-col xl:col-span-2">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each leaderboardData.results as user}
<div class="flex flex-col bg-muted rounded-md border border-border hover:bg-muted/50 transition ease duration-200">
<a href={`/user/${user.username}`}>
<UserProfilePreview userId={user.userId}/>
</a>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex flex-col xl:col-span-2 items-center justify-center h-60">
<h2 class="mb-4 text-xl">No users found</h2>
<p class="text-muted-foreground text-sm md:text-base mb-4">No users match your search "{searchQueryValue}"</p>
<Button variant="outline" onclick={() => {
searchQuery = '';
searchQueryValue = '';
fetchLeaderboardData();
}}>
<h2 class="text-sm">Clear search</h2>
</Button>
</div>
{/if}
{:else}
<!-- Top Profit Makers -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-red-600 md:text-xl">
<Skull class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Top Rugpullers (24h)</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm">
Users who made the most profit from selling coins today
</Card.Description>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={rugpullersColumns}
data={leaderboardData.topRugpullers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major profits recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Biggest Losses -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-orange-600 md:text-xl">
<TrendingDown class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Biggest Losses (24h)</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users who experienced the largest losses today</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={losersColumns}
data={leaderboardData.biggestLosers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major losses recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Biggest Losses -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-orange-600 md:text-xl">
<TrendingDown class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Biggest Losses (24h)</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users who experienced the largest losses today</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={losersColumns}
data={leaderboardData.biggestLosers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major losses recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Top Cash Holders -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-green-600 md:text-xl">
<Crown class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Top Cash Holders</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users with the highest liquid cash balances</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={cashKingsColumns}
data={leaderboardData.cashKings}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="Everyone's invested! 💸"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Top Cash Holders -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-green-600 md:text-xl">
<Crown class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Top Cash Holders</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users with the highest liquid cash balances</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={cashKingsColumns}
data={leaderboardData.cashKings}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="Everyone's invested! 💸"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Top Portfolio Values -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-cyan-600 md:text-xl">
<Trophy class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Highest Portfolio Values</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users with the largest total portfolio valuations (including illiquid)</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={millionairesColumns}
data={leaderboardData.paperMillionaires}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No large portfolios yet! 📉"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
<!-- Top Portfolio Values -->
<Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-cyan-600 md:text-xl">
<Trophy class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Highest Portfolio Values</span>
</Card.Title>
<Card.Description class="text-xs md:text-sm"
>Users with the largest total portfolio valuations (including illiquid)</Card.Description
>
</Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable
columns={millionairesColumns}
data={leaderboardData.paperMillionaires}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No large portfolios yet! 📉"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
{/if}
</div>
{/if}
</div>