Merge pull request #131 from SimplyBrandon/leaderboard-search

feat: search for users by username on the leaderboard page.
This commit is contained in:
Face 2025-07-15 18:24:51 +03:00 committed by GitHub
commit 5dd008b8cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 386 additions and 99 deletions

View file

@ -7,7 +7,7 @@
import { Calendar, Wallet } from 'lucide-svelte'; import { Calendar, Wallet } from 'lucide-svelte';
import type { UserProfileData } from '$lib/types/user-profile'; 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<UserProfileData | null>(null); let userData = $state<UserProfileData | null>(null);
let loading = $state(true); let loading = $state(true);
@ -93,7 +93,7 @@
</div> </div>
<p class="text-muted-foreground text-sm">@{profile.username}</p> <p class="text-muted-foreground text-sm">@{profile.username}</p>
{#if profile.bio} {#if profile.bio && showBio}
<p class="text-sm">{profile.bio}</p> <p class="text-sm">{profile.bio}</p>
{/if} {/if}

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
</script>
<div class="grid gap-4 md:gap-6 xl:grid-cols-2">
<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 Array(9) as _}
<Card.Root class="gap-1">
<Card.Content>
<div class="flex items-start gap-4">
<Skeleton class="h-12 w-12 rounded-full shrink-0" />
<div class="flex flex-col flex-grow gap-2">
<Skeleton class="h-4 w-32 md:h-4 md:w-40" />
<Skeleton class="h-4 w-20 md:h-4 md:w-24" />
<div class="flex flex-col gap-1 mt-2">
<div class="flex items-center justify-between">
<Skeleton class="h-3 w-16 md:h-3 md:w-20" />
<Skeleton class="h-4 w-16 md:h-4 md:w-20" />
</div>
<div class="flex items-center justify-between">
<Skeleton class="h-3 w-12 md:h-3 md:w-16" />
<Skeleton class="h-4 w-16 md:h-4 md:w-20" />
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<Skeleton class="h-4 w-4" />
<Skeleton class="h-3 w-24 md:h-3 md:w-32" />
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
</div>
</div>

View file

@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { user, transaction, userPortfolio, coin } from '$lib/server/db/schema'; 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 { try {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); 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<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`,
totalPortfolioValue: sql<number>`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();
}

View file

@ -7,20 +7,67 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { TrendingDown, Crown, Skull, Target, RefreshCw, Trophy } from 'lucide-svelte'; import {
import { formatValue } from '$lib/utils'; 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<NodeJS.Timeout | null>(null);
let leaderboardData = $state<any>(null); let leaderboardData = $state<any>(null);
let loading = $state(true); let loading = $state(true);
onMount(async () => { onMount(async () => {
const urlParams = new URLSearchParams(window.location.search);
const search = urlParams.get('search');
searchQuery = search || '';
searchQueryValue = search || '';
await fetchLeaderboardData(); 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; loading = true;
try { try {
const response = await fetch('/api/leaderboard'); const response = await fetch(`/api/leaderboard?search=${searchQueryValue}&offset=${offset}`);
if (response.ok) { if (response.ok) {
leaderboardData = await response.json(); leaderboardData = await response.json();
} else { } else {
@ -54,6 +101,19 @@
return { text: 'Mostly liquid', color: 'text-success' }; 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 = [ const rugpullersColumns = [
{ {
key: 'rank', key: 'rank',
@ -209,111 +269,250 @@
<h1 class="text-2xl font-bold md:text-3xl">Leaderboard</h1> <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> <p class="text-muted-foreground text-sm md:text-base">Top performers and market activity</p>
</div> </div>
<Button variant="outline" onclick={fetchLeaderboardData} disabled={loading} class="w-fit"> <div class="flex items-center gap-4">
<RefreshCw class="h-4 w-4" /> <div class="relative flex flex-grow items-center">
Refresh <Search size={16} class="pointer-events-none absolute left-3"></Search>
</Button> <Input
type="text"
placeholder="Search by username..."
class="flex-grow 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(searchOffset)}
disabled={loading}
class="w-fit"
>
<RefreshCw class="h-4 w-4" />
</Button>
</div>
</div> </div>
</header> </header>
{#if loading} {#if loading}
<LeaderboardSkeleton /> {#if searchQueryValue}
<LeaderboardSearchSkeleton />
{:else}
<LeaderboardSkeleton />
{/if}
{:else if !leaderboardData} {:else if !leaderboardData}
<div class="flex h-96 items-center justify-center"> <div class="flex h-96 items-center justify-center">
<div class="text-center"> <div class="text-center">
<div class="text-muted-foreground mb-4 text-lg md:text-xl">Failed to load leaderboard</div> <div class="text-muted-foreground mb-4 text-lg md:text-xl">Failed to load leaderboard</div>
<Button onclick={fetchLeaderboardData}>Try Again</Button> <Button onclick={() => fetchLeaderboardData()}>Try Again</Button>
</div> </div>
</div> </div>
{:else} {:else}
<div class="grid gap-4 md:gap-6 xl:grid-cols-2"> <div class="grid gap-4 md:gap-6 xl:grid-cols-2">
<!-- Top Profit Makers --> {#if searchQueryValue}
<Card.Root class="overflow-hidden"> {#if leaderboardData.results.length > 0}
<Card.Header class="pb-3 md:pb-4"> <div class="flex flex-col xl:col-span-2">
<Card.Title class="flex items-center gap-2 text-lg text-red-600 md:text-xl"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skull class="h-5 w-5 md:h-6 md:w-6" /> {#each leaderboardData.results as user}
<span class="truncate">Top Rugpullers (24h)</span> <Card.Root
</Card.Title> class="hover:bg-muted/50 cursor-pointer gap-1 transition duration-200"
<Card.Description class="text-xs md:text-sm"> onclick={() => goto(`/user/${user.username}`)}
Users who made the most profit from selling coins today >
</Card.Description> <Card.Content>
</Card.Header> <div class="flex items-start gap-4">
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0"> <Avatar.Root class="h-12 w-12 shrink-0">
<DataTable <Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
columns={rugpullersColumns} <Avatar.Fallback class="text-sm"
data={leaderboardData.topRugpullers} >{user.name?.charAt(0) || '?'}</Avatar.Fallback
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)} >
emptyMessage="No major profits recorded today" </Avatar.Root>
enableUserPreview={true} <div class="flex flex-grow flex-col">
/> <div class="flex items-center gap-2">
</Card.Content> <h4 class="max-w-[150px] truncate text-sm font-semibold sm:max-w-[200px]">
</Card.Root> {user.name}
</h4>
<ProfileBadges {user} showId={true} size="sm" />
</div>
<p class="text-muted-foreground text-sm">@{user.username}</p>
<div class="mt-2 flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="text-muted-foreground flex items-center gap-2 text-xs">
<Wallet class="h-3 w-3" />
Portfolio
</span>
<span class="font-mono text-sm font-medium">
{formatValue(user.totalPortfolioValue)}
</span>
</div>
<!-- Biggest Losses --> <div class="flex items-center justify-between">
<Card.Root class="overflow-hidden"> <span class="text-muted-foreground flex items-center gap-2 text-xs">
<Card.Header class="pb-3 md:pb-4"> <Wallet class="h-3 w-3" />
<Card.Title class="flex items-center gap-2 text-lg text-orange-600 md:text-xl"> Cash
<TrendingDown class="h-5 w-5 md:h-6 md:w-6" /> </span>
<span class="truncate">Biggest Losses (24h)</span> <span class="text-success font-mono text-sm font-medium">
</Card.Title> {formatValue(user.baseCurrencyBalance)}
<Card.Description class="text-xs md:text-sm" </span>
>Users who experienced the largest losses today</Card.Description </div>
> </div>
</Card.Header> <div class="mt-2 flex items-center gap-2">
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0"> <Calendar class="text-muted-foreground h-4 w-4" />
<DataTable <p class="text-muted-foreground text-xs">
columns={losersColumns} Joined {new Date(user.createdAt).toLocaleDateString('en-US', {
data={leaderboardData.biggestLosers} year: 'numeric',
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)} month: 'long'
emptyMessage="No major losses recorded today" })}
enableUserPreview={true} </p>
/> </div>
</Card.Content> </div>
</Card.Root> </div>
</Card.Content>
</Card.Root>
{/each}
</div>
</div>
<div class="flex flex-col items-center justify-between gap-2 lg:flex-row xl:col-span-2">
<h2 class="text-muted-foreground text-sm">
Showing {1 + searchOffset} - {Math.min(leaderboardData.results.length, 9) +
searchOffset} of {leaderboardData.total} results
</h2>
<div
class="flex w-full flex-grow items-center justify-center gap-2 overflow-x-auto lg:w-auto lg:justify-end"
>
{#each Array(Math.ceil(leaderboardData.total / 9)) as _, index}
<Button
variant={searchOffset === index * 9 ? 'outline' : 'ghost'}
onclick={() => {
if (searchOffset === index * 9) return;
if (index * 9 > leaderboardData.total) return;
<!-- Top Cash Holders --> searchOffset = index * 9;
<Card.Root class="overflow-hidden"> fetchLeaderboardData(searchOffset);
<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" /> <h2 class="text-sm">{index + 1}</h2>
<span class="truncate">Top Cash Holders</span> </Button>
</Card.Title> {/each}
<Card.Description class="text-xs md:text-sm" </div>
>Users with the highest liquid cash balances</Card.Description </div>
> {:else}
</Card.Header> <div class="flex h-60 flex-col items-center justify-center xl:col-span-2">
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0"> <h2 class="mb-4 text-xl">No users found</h2>
<DataTable <p class="text-muted-foreground mb-4 text-sm md:text-base">
columns={cashKingsColumns} No users match your search "{searchQueryValue}"
data={leaderboardData.cashKings} </p>
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)} <Button
emptyMessage="Everyone's invested! 💸" variant="outline"
enableUserPreview={true} onclick={() => {
/> searchQuery = '';
</Card.Content> searchQueryValue = '';
</Card.Root> 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>
<!-- Top Portfolio Values --> <!-- Biggest Losses -->
<Card.Root class="overflow-hidden"> <Card.Root class="overflow-hidden">
<Card.Header class="pb-3 md:pb-4"> <Card.Header class="pb-3 md:pb-4">
<Card.Title class="flex items-center gap-2 text-lg text-cyan-600 md:text-xl"> <Card.Title class="flex items-center gap-2 text-lg text-orange-600 md:text-xl">
<Trophy class="h-5 w-5 md:h-6 md:w-6" /> <TrendingDown class="h-5 w-5 md:h-6 md:w-6" />
<span class="truncate">Highest Portfolio Values</span> <span class="truncate">Biggest Losses (24h)</span>
</Card.Title> </Card.Title>
<Card.Description class="text-xs md:text-sm" <Card.Description class="text-xs md:text-sm"
>Users with the largest total portfolio valuations (including illiquid)</Card.Description >Users who experienced the largest losses today</Card.Description
> >
</Card.Header> </Card.Header>
<Card.Content class="p-3 pt-0 md:p-6 md:pt-0"> <Card.Content class="p-3 pt-0 md:p-6 md:pt-0">
<DataTable <DataTable
columns={millionairesColumns} columns={losersColumns}
data={leaderboardData.paperMillionaires} data={leaderboardData.biggestLosers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)} onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No large portfolios yet! 📉" emptyMessage="No major losses recorded today"
enableUserPreview={true} enableUserPreview={true}
/> />
</Card.Content> </Card.Content>
</Card.Root> </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>
{/if}
</div> </div>
{/if} {/if}
</div> </div>