diff --git a/website/src/lib/components/self/skeletons/HoldersSkeleton.svelte b/website/src/lib/components/self/skeletons/HoldersSkeleton.svelte
new file mode 100644
index 0000000..8e4db73
--- /dev/null
+++ b/website/src/lib/components/self/skeletons/HoldersSkeleton.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {#each Array(3) as _}
+
+ {/each}
+
diff --git a/website/src/lib/components/ui/scroll-area/index.ts b/website/src/lib/components/ui/scroll-area/index.ts
new file mode 100644
index 0000000..e86a25b
--- /dev/null
+++ b/website/src/lib/components/ui/scroll-area/index.ts
@@ -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,
+};
diff --git a/website/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte b/website/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte
new file mode 100644
index 0000000..4127444
--- /dev/null
+++ b/website/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/website/src/lib/components/ui/scroll-area/scroll-area.svelte b/website/src/lib/components/ui/scroll-area/scroll-area.svelte
new file mode 100644
index 0000000..38a1847
--- /dev/null
+++ b/website/src/lib/components/ui/scroll-area/scroll-area.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+ {@render children?.()}
+
+ {#if orientation === "vertical" || orientation === "both"}
+
+ {/if}
+ {#if orientation === "horizontal" || orientation === "both"}
+
+ {/if}
+
+
diff --git a/website/src/lib/stores/websocket.ts b/website/src/lib/stores/websocket.ts
index 31071a4..4a2b8e8 100644
--- a/website/src/lib/stores/websocket.ts
+++ b/website/src/lib/stores/websocket.ts
@@ -52,7 +52,7 @@ const commentSubscriptions = new Map
void>();
// Price update callbacks
const priceUpdateSubscriptions = new Map void>();
-async function loadInitialTrades(): Promise {
+export async function loadInitialTrades(mode: 'preview' | 'expanded' = 'preview'): Promise {
if (!browser) return;
if (!hasLoadedInitialTrades) {
@@ -60,21 +60,27 @@ async function loadInitialTrades(): Promise {
}
try {
- const [largeTradesResponse, allTradesResponse] = await Promise.all([
- fetch('/api/trades/recent?limit=5&minValue=1000'),
- fetch('/api/trades/recent?limit=100')
- ]);
+ const params = new URLSearchParams();
- if (largeTradesResponse.ok) {
- const { trades } = await largeTradesResponse.json();
- liveTradesStore.set(trades);
+ if (mode === 'preview') {
+ params.set('limit', '5');
+ params.set('minValue', '1000');
+ } else {
+ params.set('limit', '100');
}
- if (allTradesResponse.ok) {
- const { trades } = await allTradesResponse.json();
- allTradesStore.set(trades);
+ const response = await fetch(`/api/trades/recent?${params.toString()}`);
+
+ if (response.ok) {
+ const { trades } = await response.json();
+
+ if (mode === 'preview') {
+ liveTradesStore.set(trades);
+ } else {
+ allTradesStore.set(trades);
+ }
}
-
+
hasLoadedInitialTrades = true;
} catch (error) {
console.error('Failed to load initial trades:', error);
diff --git a/website/src/routes/api/coin/[coinSymbol]/holders/+server.ts b/website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
new file mode 100644
index 0000000..49eb9cb
--- /dev/null
+++ b/website/src/routes/api/coin/[coinSymbol]/holders/+server.ts
@@ -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');
+ }
+}
diff --git a/website/src/routes/coin/[coinSymbol]/+page.svelte b/website/src/routes/coin/[coinSymbol]/+page.svelte
index cd12688..3eb3d85 100644
--- a/website/src/routes/coin/[coinSymbol]/+page.svelte
+++ b/website/src/routes/coin/[coinSymbol]/+page.svelte
@@ -9,6 +9,7 @@
import CommentSection from '$lib/components/self/CommentSection.svelte';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.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 {
createChart,
@@ -469,7 +470,7 @@
-
+
@@ -499,23 +500,23 @@
-
+
{#if chartData.length === 0}
-
+
No trading data available yet
{:else}
-
+
{/if}
-
+
-
+
Trade {coin.symbol}
{#if userHolding > 0}
@@ -524,7 +525,7 @@
{/if}
-
+
{#if $USER_DATA}
@@ -611,7 +611,7 @@
Market Cap
-
+
{formatMarketCap(coin.marketCap)}
@@ -626,7 +626,7 @@
24h Volume
-
+
{formatMarketCap(coin.volume24h)}
@@ -641,7 +641,7 @@
Circulating Supply
-
+
{formatSupply(coin.circulatingSupply)}
24h Change
-
+
{#if coin.change24h >= 0}
diff --git a/website/src/routes/live/+page.svelte b/website/src/routes/live/+page.svelte
index dfc8319..af10ead 100644
--- a/website/src/routes/live/+page.svelte
+++ b/website/src/routes/live/+page.svelte
@@ -4,13 +4,14 @@
import * as Avatar from '$lib/components/ui/avatar';
import * as HoverCard from '$lib/components/ui/hover-card';
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 { formatQuantity, formatRelativeTime, formatValue, getPublicUrl } from '$lib/utils';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import LiveTradeSkeleton from '$lib/components/self/skeletons/LiveTradeSkeleton.svelte';
import SEO from '$lib/components/self/SEO.svelte';
+ import { onMount } from 'svelte';
function handleUserClick(username: string) {
goto(`/user/${username}`);
@@ -19,6 +20,10 @@
function handleCoinClick(coinSymbol: string) {
goto(`/coin/${coinSymbol.toLowerCase()}`);
}
+
+ onMount(() => {
+ loadInitialTrades("expanded");
+ })