feat: add Top Holders
This commit is contained in:
parent
ff60529b3f
commit
36175c990d
13 changed files with 522 additions and 62 deletions
230
website/src/lib/components/self/TopHolders.svelte
Normal file
230
website/src/lib/components/self/TopHolders.svelte
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { Users } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getPublicUrl, formatQuantity, formatValue } from '$lib/utils';
|
||||
import UserProfilePreview from './UserProfilePreview.svelte';
|
||||
import DataTable from './DataTable.svelte';
|
||||
import HoldersSkeleton from './skeletons/HoldersSkeleton.svelte';
|
||||
|
||||
interface Holder {
|
||||
rank: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
quantity: number;
|
||||
percentage: number;
|
||||
liquidationValue: number;
|
||||
}
|
||||
|
||||
interface HoldersData {
|
||||
coinSymbol: string;
|
||||
totalHolders: number;
|
||||
circulatingSupply: number;
|
||||
poolInfo: {
|
||||
coinAmount: number;
|
||||
baseCurrencyAmount: number;
|
||||
currentPrice: number;
|
||||
};
|
||||
holders: Holder[];
|
||||
}
|
||||
|
||||
let { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||
|
||||
let loading = $state(true);
|
||||
let holdersData = $state<HoldersData | null>(null);
|
||||
let modalOpen = $state(false);
|
||||
|
||||
async function fetchHolders() {
|
||||
try {
|
||||
const response = await fetch(`/api/coin/${coinSymbol}/holders?limit=50`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch holders');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
holdersData = result;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch holders:', e);
|
||||
toast.error('Failed to load top holders');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (coinSymbol) {
|
||||
loading = true;
|
||||
holdersData = null;
|
||||
fetchHolders();
|
||||
}
|
||||
});
|
||||
|
||||
let holdersColumns = $derived([
|
||||
{
|
||||
key: 'rank',
|
||||
label: 'Rank',
|
||||
class: 'w-[10%] min-w-[60px]',
|
||||
render: (value: any) => `#${value}`
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
class: 'w-[35%] min-w-[150px]',
|
||||
render: (value: any, row: any) => ({
|
||||
component: 'user',
|
||||
image: row.image,
|
||||
name: row.name || 'Anonymous',
|
||||
username: row.username
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
label: 'Quantity',
|
||||
class: 'w-[20%] min-w-[100px] font-mono',
|
||||
sortable: false,
|
||||
render: (value: any) => formatQuantity(value)
|
||||
},
|
||||
{
|
||||
key: 'percentage',
|
||||
label: '%',
|
||||
class: 'w-[12%] min-w-[70px]',
|
||||
sortable: false,
|
||||
render: (value: any) => ({
|
||||
component: 'badge',
|
||||
variant: 'secondary',
|
||||
text: `${value.toFixed(1)}%`
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'liquidationValue',
|
||||
label: 'Value',
|
||||
class: 'w-[23%] min-w-[90px] font-mono font-medium',
|
||||
sortable: true,
|
||||
defaultSort: 'desc' as const,
|
||||
render: (value: any) => formatValue(value)
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="gap-2 {holdersData && holdersData.holders.length > 3
|
||||
? 'hover:bg-card/90 cursor-pointer transition-colors'
|
||||
: ''}"
|
||||
onclick={() => holdersData && holdersData.holders.length > 3 && (modalOpen = true)}
|
||||
>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">Top Holders</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="relative">
|
||||
{#if loading}
|
||||
<HoldersSkeleton />
|
||||
{:else if !holdersData || holdersData.holders.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<Users class="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p class="text-muted-foreground text-sm">No holders found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each holdersData.holders.slice(0, 3) as holder}
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-3 border-none bg-transparent p-0 text-left"
|
||||
>
|
||||
<Avatar.Root class="h-8 w-8 flex-shrink-0">
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(holder.image)}
|
||||
alt={holder.name || holder.username}
|
||||
/>
|
||||
<Avatar.Fallback class="text-xs">
|
||||
{(holder.name || holder.username).charAt(0)}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{holder.name || 'Anonymous'}
|
||||
</p>
|
||||
<p class="text-muted-foreground truncate text-xs">
|
||||
@{holder.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-1.5 text-right">
|
||||
<div class="hidden sm:block">
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{holder.percentage.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-0.5">
|
||||
<p class="text-muted-foreground font-mono text-xs">
|
||||
{formatQuantity(holder.quantity)}
|
||||
</p>
|
||||
<p class="text-muted-foreground/80 font-mono text-xs font-medium">
|
||||
{formatValue(holder.liquidationValue)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if holdersData && holdersData.holders.length > 3}
|
||||
<div
|
||||
class="from-card/80 pointer-events-none absolute inset-0 bg-gradient-to-t via-transparent to-transparent"
|
||||
></div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Dialog.Root bind:open={modalOpen}>
|
||||
<Dialog.Content
|
||||
class="flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] flex-col sm:max-w-[800px] md:max-w-2xl"
|
||||
>
|
||||
<Dialog.Header class="flex-shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Users class="h-5 w-5" />
|
||||
Top Holders (*{holdersData?.coinSymbol})
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>This list is limited to the top 50 holders.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
{#if holdersData && holdersData.holders.length > 0}
|
||||
<ScrollArea class="h-[600px] rounded-md border">
|
||||
<div class="bg-card p-2">
|
||||
<DataTable
|
||||
columns={holdersColumns}
|
||||
data={holdersData.holders}
|
||||
onRowClick={(holder) => goto(`/user/${holder.username}`)}
|
||||
enableUserPreview={true}
|
||||
emptyTitle="No holders found"
|
||||
emptyDescription="This coin doesn't have any holders yet."
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center py-12 text-center">
|
||||
<div>
|
||||
<Users class="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 class="mb-2 text-lg font-semibold">No holders found</h3>
|
||||
<p class="text-muted-foreground">This coin doesn't have any holders yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -1,32 +1,30 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import HoldersSkeleton from './HoldersSkeleton.svelte';
|
||||
</script>
|
||||
|
||||
<header class="mb-8">
|
||||
<div class="mb-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="mb-4 flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-center gap-3 sm:gap-4">
|
||||
<Skeleton class="h-12 w-12 rounded-lg sm:h-16 sm:w-16" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<Skeleton class="mb-2 h-6 w-40 sm:h-10 sm:w-48" />
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<Skeleton class="h-5 w-12 sm:h-6 sm:w-16" />
|
||||
<Skeleton class="h-5 w-16 sm:h-6 sm:w-20" />
|
||||
<Skeleton class="h-5 w-14" />
|
||||
<Skeleton class="h-5 w-12 sm:h-6 sm:w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 sm:items-end sm:text-right">
|
||||
<Skeleton class="h-6 w-28 sm:h-8 sm:w-32" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-5 w-12 sm:h-6 sm:w-16" />
|
||||
<Skeleton class="h-5 w-12 sm:h-6 sm:w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Info Skeleton -->
|
||||
<div class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
||||
<div class="text-muted-foreground mt-6 flex flex-wrap items-center gap-2 text-sm">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
<Skeleton class="h-4 w-4 rounded-full" />
|
||||
<Skeleton class="h-4 w-40" />
|
||||
|
|
@ -38,7 +36,7 @@
|
|||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Chart (2/3 width) -->
|
||||
<div class="lg:col-span-2">
|
||||
<Card.Root>
|
||||
<Card.Root class="flex h-full flex-col">
|
||||
<Card.Header class="pb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
|
|
@ -50,23 +48,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Skeleton class="h-[500px] w-full" />
|
||||
<Card.Content class="flex-1 pt-0">
|
||||
<Skeleton class="h-full min-h-[500px] w-full" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Trading Actions + Liquidity Pool (1/3 width) -->
|
||||
<!-- Right side - Trading Actions + Liquidity Pool + Top Holders (1/3 width) -->
|
||||
<div class="space-y-6 lg:col-span-1">
|
||||
<!-- Trading Actions Skeleton -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Root class="gap-3">
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
<Skeleton class="h-6 w-24" />
|
||||
</Card.Title>
|
||||
<Skeleton class="h-4 w-32" />
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
<Skeleton class="h-11 w-full" />
|
||||
<Skeleton class="h-11 w-full" />
|
||||
|
|
@ -76,16 +73,11 @@
|
|||
|
||||
<!-- Liquidity Pool Skeleton -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Title>
|
||||
<Skeleton class="h-6 w-28" />
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<div class="space-y-4">
|
||||
<Card.Content>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<Skeleton class="mb-3 h-5 w-32" />
|
||||
<div class="space-y-2">
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<Skeleton class="h-4 w-16" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
|
|
@ -98,7 +90,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<Skeleton class="mb-3 h-5 w-20" />
|
||||
<div class="space-y-2">
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
|
|
@ -112,20 +104,30 @@
|
|||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Top Holders Section Skeleton -->
|
||||
<Card.Root class="gap-2">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">Top Holders</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="relative">
|
||||
<HoldersSkeleton />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Grid Skeleton -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _}
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Root class="gap-1">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<Skeleton class="mb-1 h-6 w-24" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</Card.Content>
|
||||
|
|
@ -135,13 +137,13 @@
|
|||
|
||||
<!-- Comments Section Skeleton -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Skeleton class="h-5 w-5" />
|
||||
<Skeleton class="h-6 w-20" />
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<Card.Content>
|
||||
<div class="space-y-4">
|
||||
{#each Array(3) as _}
|
||||
<div class="flex gap-3 border-b pb-4 last:border-b-0">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each Array(3) as _}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Skeleton class="h-8 w-8 flex-shrink-0 rounded-full" />
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-1.5">
|
||||
<div class="hidden sm:block">
|
||||
<Skeleton class="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-0.5">
|
||||
<Skeleton class="h-3 w-16" />
|
||||
<Skeleton class="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
Reference in a new issue