Merge pull request #131 from SimplyBrandon/leaderboard-search
feat: search for users by username on the leaderboard page.
This commit is contained in:
commit
5dd008b8cc
4 changed files with 386 additions and 99 deletions
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Reference in a new issue