feat: add Top Holders
This commit is contained in:
parent
ff60529b3f
commit
36175c990d
13 changed files with 522 additions and 62 deletions
|
|
@ -26,6 +26,7 @@
|
||||||
"svelte-lightweight-charts": "^2.2.0",
|
"svelte-lightweight-charts": "^2.2.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.482.0",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
|
@ -33,7 +34,7 @@
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^2.1.0",
|
"bits-ui": "^2.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.22.0",
|
"drizzle-kit": "^0.22.0",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
|
|
@ -515,7 +516,7 @@
|
||||||
|
|
||||||
"better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="],
|
"better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.2.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-Jo3PIWpMMAeT4rs5f3X3S5Qdu1MWyjz3YV5DhTJRtLI3UZn8A5YkZyHbIaPsAkKIxjLMNAqAa2FAMfDJ8DdXjw=="],
|
"bits-ui": ["bits-ui@2.5.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src\\app.css",
|
"css": "src\\app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
|
|
@ -12,5 +12,5 @@
|
||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://next.shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.482.0",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^2.1.0",
|
"bits-ui": "^2.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.22.0",
|
"drizzle-kit": "^0.22.0",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
|
|
|
||||||
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">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import HoldersSkeleton from './HoldersSkeleton.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="mb-8">
|
<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">
|
<div class="flex items-center gap-3 sm:gap-4">
|
||||||
<Skeleton class="h-12 w-12 rounded-lg sm:h-16 sm:w-16" />
|
<Skeleton class="h-12 w-12 rounded-lg sm:h-16 sm:w-16" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<Skeleton class="mb-2 h-6 w-40 sm:h-10 sm:w-48" />
|
<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">
|
<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-12 sm:h-6 sm:w-24" />
|
||||||
<Skeleton class="h-5 w-16 sm:h-6 sm:w-20" />
|
|
||||||
<Skeleton class="h-5 w-14" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-2 sm:items-end sm:text-right">
|
<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" />
|
<Skeleton class="h-6 w-28 sm:h-8 sm:w-32" />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Skeleton class="h-4 w-4" />
|
<Skeleton class="h-5 w-12 sm:h-6 sm:w-24" />
|
||||||
<Skeleton class="h-5 w-12 sm:h-6 sm:w-16" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Creator Info Skeleton -->
|
<!-- 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-20" />
|
||||||
<Skeleton class="h-4 w-4 rounded-full" />
|
<Skeleton class="h-4 w-4 rounded-full" />
|
||||||
<Skeleton class="h-4 w-40" />
|
<Skeleton class="h-4 w-40" />
|
||||||
|
|
@ -38,7 +36,7 @@
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Chart (2/3 width) -->
|
<!-- Chart (2/3 width) -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<Card.Root>
|
<Card.Root class="flex h-full flex-col">
|
||||||
<Card.Header class="pb-4">
|
<Card.Header class="pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
|
@ -50,23 +48,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="flex-1 pt-0">
|
||||||
<Skeleton class="h-[500px] w-full" />
|
<Skeleton class="h-full min-h-[500px] w-full" />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</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">
|
<div class="space-y-6 lg:col-span-1">
|
||||||
<!-- Trading Actions Skeleton -->
|
<!-- Trading Actions Skeleton -->
|
||||||
<Card.Root>
|
<Card.Root class="gap-3">
|
||||||
<Card.Header class="pb-4">
|
<Card.Header>
|
||||||
<Card.Title>
|
<Card.Title>
|
||||||
<Skeleton class="h-6 w-24" />
|
<Skeleton class="h-6 w-24" />
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<Skeleton class="h-4 w-32" />
|
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Skeleton class="h-11 w-full" />
|
<Skeleton class="h-11 w-full" />
|
||||||
<Skeleton class="h-11 w-full" />
|
<Skeleton class="h-11 w-full" />
|
||||||
|
|
@ -76,16 +73,11 @@
|
||||||
|
|
||||||
<!-- Liquidity Pool Skeleton -->
|
<!-- Liquidity Pool Skeleton -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Content>
|
||||||
<Card.Title>
|
<div class="space-y-5">
|
||||||
<Skeleton class="h-6 w-28" />
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="pt-0">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Skeleton class="mb-3 h-5 w-32" />
|
<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">
|
<div class="flex justify-between">
|
||||||
<Skeleton class="h-4 w-16" />
|
<Skeleton class="h-4 w-16" />
|
||||||
<Skeleton class="h-4 w-20" />
|
<Skeleton class="h-4 w-20" />
|
||||||
|
|
@ -98,7 +90,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Skeleton class="mb-3 h-5 w-20" />
|
<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">
|
<div class="flex justify-between">
|
||||||
<Skeleton class="h-4 w-24" />
|
<Skeleton class="h-4 w-24" />
|
||||||
<Skeleton class="h-4 w-20" />
|
<Skeleton class="h-4 w-20" />
|
||||||
|
|
@ -112,20 +104,30 @@
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Grid Skeleton -->
|
<!-- Statistics Grid Skeleton -->
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each Array(4) as _}
|
{#each Array(4) as _}
|
||||||
<Card.Root>
|
<Card.Root class="gap-1">
|
||||||
<Card.Header class="pb-2">
|
<Card.Header>
|
||||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
<Skeleton class="h-4 w-4" />
|
<Skeleton class="h-4 w-4" />
|
||||||
<Skeleton class="h-4 w-20" />
|
<Skeleton class="h-4 w-20" />
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<Skeleton class="mb-1 h-6 w-24" />
|
<Skeleton class="mb-1 h-6 w-24" />
|
||||||
<Skeleton class="h-3 w-16" />
|
<Skeleton class="h-3 w-16" />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|
@ -135,13 +137,13 @@
|
||||||
|
|
||||||
<!-- Comments Section Skeleton -->
|
<!-- Comments Section Skeleton -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Header>
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
<Skeleton class="h-5 w-5" />
|
<Skeleton class="h-5 w-5" />
|
||||||
<Skeleton class="h-6 w-20" />
|
<Skeleton class="h-6 w-20" />
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each Array(3) as _}
|
{#each Array(3) as _}
|
||||||
<div class="flex gap-3 border-b pb-4 last:border-b-0">
|
<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>
|
||||||
10
website/src/lib/components/ui/scroll-area/index.ts
Normal file
10
website/src/lib/components/ui/scroll-area/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Scrollbar from "./scroll-area-scrollbar.svelte";
|
||||||
|
import Root from "./scroll-area.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Scrollbar,
|
||||||
|
//,
|
||||||
|
Root as ScrollArea,
|
||||||
|
Scrollbar as ScrollAreaScrollbar,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
"flex touch-none select-none p-px transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
class="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
40
website/src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
40
website/src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { Scrollbar } from "./index.js";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
scrollbarXClasses = "",
|
||||||
|
scrollbarYClasses = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||||
|
orientation?: "vertical" | "horizontal" | "both" | undefined;
|
||||||
|
scrollbarXClasses?: string | undefined;
|
||||||
|
scrollbarYClasses?: string | undefined;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area"
|
||||||
|
class={cn("relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-4"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
{#if orientation === "vertical" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||||
|
{/if}
|
||||||
|
{#if orientation === "horizontal" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||||
|
{/if}
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
|
@ -52,7 +52,7 @@ const commentSubscriptions = new Map<string, (message: any) => void>();
|
||||||
// Price update callbacks
|
// Price update callbacks
|
||||||
const priceUpdateSubscriptions = new Map<string, (priceUpdate: PriceUpdate) => void>();
|
const priceUpdateSubscriptions = new Map<string, (priceUpdate: PriceUpdate) => void>();
|
||||||
|
|
||||||
async function loadInitialTrades(): Promise<void> {
|
export async function loadInitialTrades(mode: 'preview' | 'expanded' = 'preview'): Promise<void> {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
if (!hasLoadedInitialTrades) {
|
if (!hasLoadedInitialTrades) {
|
||||||
|
|
@ -60,20 +60,26 @@ async function loadInitialTrades(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [largeTradesResponse, allTradesResponse] = await Promise.all([
|
const params = new URLSearchParams();
|
||||||
fetch('/api/trades/recent?limit=5&minValue=1000'),
|
|
||||||
fetch('/api/trades/recent?limit=100')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (largeTradesResponse.ok) {
|
if (mode === 'preview') {
|
||||||
const { trades } = await largeTradesResponse.json();
|
params.set('limit', '5');
|
||||||
liveTradesStore.set(trades);
|
params.set('minValue', '1000');
|
||||||
|
} else {
|
||||||
|
params.set('limit', '100');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allTradesResponse.ok) {
|
const response = await fetch(`/api/trades/recent?${params.toString()}`);
|
||||||
const { trades } = await allTradesResponse.json();
|
|
||||||
|
if (response.ok) {
|
||||||
|
const { trades } = await response.json();
|
||||||
|
|
||||||
|
if (mode === 'preview') {
|
||||||
|
liveTradesStore.set(trades);
|
||||||
|
} else {
|
||||||
allTradesStore.set(trades);
|
allTradesStore.set(trades);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hasLoadedInitialTrades = true;
|
hasLoadedInitialTrades = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
107
website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
Normal file
107
website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { coin, userPortfolio, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { validateSearchParams } from '$lib/utils/validation';
|
||||||
|
|
||||||
|
function calculateLiquidationValue(tokensToSell: number, poolCoinAmount: number, poolBaseCurrencyAmount: number): number {
|
||||||
|
if (tokensToSell <= 0 || poolCoinAmount <= 0 || poolBaseCurrencyAmount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSellable = poolCoinAmount * 0.995;
|
||||||
|
const actualTokensToSell = Math.min(tokensToSell, maxSellable);
|
||||||
|
const k = poolCoinAmount * poolBaseCurrencyAmount;
|
||||||
|
const newPoolCoin = poolCoinAmount + actualTokensToSell;
|
||||||
|
const newPoolBaseCurrency = k / newPoolCoin;
|
||||||
|
const baseCurrencyReceived = poolBaseCurrencyAmount - newPoolBaseCurrency;
|
||||||
|
|
||||||
|
return Math.max(0, baseCurrencyReceived);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET({ params, url }) {
|
||||||
|
const coinSymbol = params.coinSymbol?.toUpperCase();
|
||||||
|
const validator = validateSearchParams(url.searchParams);
|
||||||
|
const limit = validator.getPositiveInt('limit', 50);
|
||||||
|
|
||||||
|
if (!coinSymbol) {
|
||||||
|
throw error(400, 'Coin symbol is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit > 200) {
|
||||||
|
throw error(400, 'Limit cannot exceed 200');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [coinData] = await db
|
||||||
|
.select({
|
||||||
|
id: coin.id,
|
||||||
|
symbol: coin.symbol,
|
||||||
|
circulatingSupply: coin.circulatingSupply,
|
||||||
|
poolCoinAmount: coin.poolCoinAmount,
|
||||||
|
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||||
|
currentPrice: coin.currentPrice
|
||||||
|
})
|
||||||
|
.from(coin)
|
||||||
|
.where(eq(coin.symbol, coinSymbol))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!coinData) {
|
||||||
|
throw error(404, 'Coin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const holders = await db
|
||||||
|
.select({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
quantity: userPortfolio.quantity
|
||||||
|
})
|
||||||
|
.from(userPortfolio)
|
||||||
|
.innerJoin(user, eq(userPortfolio.userId, user.id))
|
||||||
|
.where(eq(userPortfolio.coinId, coinData.id))
|
||||||
|
.orderBy(desc(userPortfolio.quantity))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const totalCirculating = Number(coinData.circulatingSupply);
|
||||||
|
const poolCoinAmount = Number(coinData.poolCoinAmount);
|
||||||
|
const poolBaseCurrencyAmount = Number(coinData.poolBaseCurrencyAmount);
|
||||||
|
|
||||||
|
const processedHolders = holders.map((holder, index) => {
|
||||||
|
const quantity = Number(holder.quantity);
|
||||||
|
const percentage = totalCirculating > 0 ? (quantity / totalCirculating) * 100 : 0;
|
||||||
|
const liquidationValue = calculateLiquidationValue(quantity, poolCoinAmount, poolBaseCurrencyAmount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rank: index + 1,
|
||||||
|
userId: holder.userId,
|
||||||
|
username: holder.username,
|
||||||
|
name: holder.name,
|
||||||
|
image: holder.image,
|
||||||
|
quantity,
|
||||||
|
percentage,
|
||||||
|
liquidationValue
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
coinSymbol: coinData.symbol,
|
||||||
|
totalHolders: holders.length,
|
||||||
|
circulatingSupply: totalCirculating,
|
||||||
|
poolInfo: {
|
||||||
|
coinAmount: poolCoinAmount,
|
||||||
|
baseCurrencyAmount: poolBaseCurrencyAmount,
|
||||||
|
currentPrice: Number(coinData.currentPrice)
|
||||||
|
},
|
||||||
|
holders: processedHolders
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e && typeof e === 'object' && 'status' in e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
console.error('Unexpected error in holders API:', e);
|
||||||
|
throw error(500, 'Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import CommentSection from '$lib/components/self/CommentSection.svelte';
|
import CommentSection from '$lib/components/self/CommentSection.svelte';
|
||||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||||
import CoinSkeleton from '$lib/components/self/skeletons/CoinSkeleton.svelte';
|
import CoinSkeleton from '$lib/components/self/skeletons/CoinSkeleton.svelte';
|
||||||
|
import TopHolders from '$lib/components/self/TopHolders.svelte';
|
||||||
import { TrendingUp, TrendingDown, DollarSign, Coins, ChartColumn } from 'lucide-svelte';
|
import { TrendingUp, TrendingDown, DollarSign, Coins, ChartColumn } from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
createChart,
|
createChart,
|
||||||
|
|
@ -469,7 +470,7 @@
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Chart (2/3 width) -->
|
<!-- Chart (2/3 width) -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<Card.Root>
|
<Card.Root class="flex h-full flex-col">
|
||||||
<Card.Header class="pb-4">
|
<Card.Header class="pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
|
@ -499,23 +500,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="flex-1 pt-0">
|
||||||
{#if chartData.length === 0}
|
{#if chartData.length === 0}
|
||||||
<div class="flex h-[500px] items-center justify-center">
|
<div class="flex h-full min-h-[500px] items-center justify-center">
|
||||||
<p class="text-muted-foreground">No trading data available yet</p>
|
<p class="text-muted-foreground">No trading data available yet</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-[500px] w-full" bind:this={chartContainer}></div>
|
<div class="h-full min-h-[500px] w-full" bind:this={chartContainer}></div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</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">
|
<div class="space-y-6 lg:col-span-1">
|
||||||
<!-- Trading Actions -->
|
<!-- Trading Actions -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Header>
|
||||||
<Card.Title>Trade {coin.symbol}</Card.Title>
|
<Card.Title>Trade {coin.symbol}</Card.Title>
|
||||||
{#if userHolding > 0}
|
{#if userHolding > 0}
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
|
|
@ -524,7 +525,7 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
{#if $USER_DATA}
|
{#if $USER_DATA}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -558,10 +559,7 @@
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
<!-- Liquidity Pool -->
|
<!-- Liquidity Pool -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Content>
|
||||||
<Card.Title class="flex items-center gap-2">Liquidity Pool</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content class="pt-0">
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-3 font-medium">Pool Composition</h4>
|
<h4 class="mb-3 font-medium">Pool Composition</h4>
|
||||||
|
|
@ -598,6 +596,8 @@
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
<!-- Top Holders -->
|
||||||
|
<TopHolders coinSymbol={coin.symbol} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -611,7 +611,7 @@
|
||||||
Market Cap
|
Market Cap
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<p class="text-xl font-bold">
|
<p class="text-xl font-bold">
|
||||||
{formatMarketCap(coin.marketCap)}
|
{formatMarketCap(coin.marketCap)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -626,7 +626,7 @@
|
||||||
24h Volume
|
24h Volume
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<p class="text-xl font-bold">
|
<p class="text-xl font-bold">
|
||||||
{formatMarketCap(coin.volume24h)}
|
{formatMarketCap(coin.volume24h)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -641,7 +641,7 @@
|
||||||
Circulating Supply
|
Circulating Supply
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<p class="text-xl font-bold">
|
<p class="text-xl font-bold">
|
||||||
{formatSupply(coin.circulatingSupply)}<span
|
{formatSupply(coin.circulatingSupply)}<span
|
||||||
class="text-muted-foreground ml-1 text-xs"
|
class="text-muted-foreground ml-1 text-xs"
|
||||||
|
|
@ -657,7 +657,7 @@
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title class="text-sm font-medium">24h Change</Card.Title>
|
<Card.Title class="text-sm font-medium">24h Change</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if coin.change24h >= 0}
|
{#if coin.change24h >= 0}
|
||||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
import { Activity, TrendingUp, TrendingDown, Clock } from 'lucide-svelte';
|
import { Activity, TrendingUp, TrendingDown, Clock } from 'lucide-svelte';
|
||||||
import { allTradesStore, isLoadingTrades } from '$lib/stores/websocket';
|
import { allTradesStore, isLoadingTrades, loadInitialTrades } from '$lib/stores/websocket';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { formatQuantity, formatRelativeTime, formatValue, getPublicUrl } from '$lib/utils';
|
import { formatQuantity, formatRelativeTime, formatValue, getPublicUrl } from '$lib/utils';
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||||
import LiveTradeSkeleton from '$lib/components/self/skeletons/LiveTradeSkeleton.svelte';
|
import LiveTradeSkeleton from '$lib/components/self/skeletons/LiveTradeSkeleton.svelte';
|
||||||
import SEO from '$lib/components/self/SEO.svelte';
|
import SEO from '$lib/components/self/SEO.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
function handleUserClick(username: string) {
|
function handleUserClick(username: string) {
|
||||||
goto(`/user/${username}`);
|
goto(`/user/${username}`);
|
||||||
|
|
@ -19,6 +20,10 @@
|
||||||
function handleCoinClick(coinSymbol: string) {
|
function handleCoinClick(coinSymbol: string) {
|
||||||
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadInitialTrades("expanded");
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SEO
|
<SEO
|
||||||
|
|
|
||||||
Reference in a new issue