diff --git a/website/bun.lock b/website/bun.lock index 2f34d4e..23d1825 100644 --- a/website/bun.lock +++ b/website/bun.lock @@ -26,6 +26,7 @@ "svelte-lightweight-charts": "^2.2.0", }, "devDependencies": { + "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.482.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", @@ -33,7 +34,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.15.21", "autoprefixer": "^10.4.20", - "bits-ui": "^2.1.0", + "bits-ui": "^2.5.0", "clsx": "^2.1.1", "drizzle-kit": "^0.22.0", "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=="], - "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=="], diff --git a/website/components.json b/website/components.json index 8ae582a..e1c74ec 100644 --- a/website/components.json +++ b/website/components.json @@ -1,5 +1,5 @@ { - "$schema": "https://next.shadcn-svelte.com/schema.json", + "$schema": "https://shadcn-svelte.com/schema.json", "tailwind": { "css": "src\\app.css", "baseColor": "slate" @@ -12,5 +12,5 @@ "lib": "$lib" }, "typescript": true, - "registry": "https://next.shadcn-svelte.com/registry" + "registry": "https://shadcn-svelte.com/registry" } diff --git a/website/package.json b/website/package.json index 5826f43..7bb862b 100644 --- a/website/package.json +++ b/website/package.json @@ -16,6 +16,7 @@ "db:studio": "drizzle-kit studio" }, "devDependencies": { + "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.482.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", @@ -23,7 +24,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.15.21", "autoprefixer": "^10.4.20", - "bits-ui": "^2.1.0", + "bits-ui": "^2.5.0", "clsx": "^2.1.1", "drizzle-kit": "^0.22.0", "prettier": "^3.3.2", diff --git a/website/src/lib/components/self/TopHolders.svelte b/website/src/lib/components/self/TopHolders.svelte new file mode 100644 index 0000000..7031302 --- /dev/null +++ b/website/src/lib/components/self/TopHolders.svelte @@ -0,0 +1,230 @@ + + + holdersData && holdersData.holders.length > 3 && (modalOpen = true)} +> + + Top Holders + + + {#if loading} + + {:else if !holdersData || holdersData.holders.length === 0} +
+ +

No holders found

+
+ {:else} +
+ {#each holdersData.holders.slice(0, 3) as holder} +
+
+ + + + {(holder.name || holder.username).charAt(0)} + + +
+

+ {holder.name || 'Anonymous'} +

+

+ @{holder.username} +

+
+
+ +
+ +
+

+ {formatQuantity(holder.quantity)} +

+

+ {formatValue(holder.liquidationValue)} +

+
+
+
+ {/each} +
+ {/if} + + {#if holdersData && holdersData.holders.length > 3} +
+ {/if} +
+
+ + + + + + + Top Holders (*{holdersData?.coinSymbol}) + + This list is limited to the top 50 holders. + + +
+ {#if holdersData && holdersData.holders.length > 0} + +
+ goto(`/user/${holder.username}`)} + enableUserPreview={true} + emptyTitle="No holders found" + emptyDescription="This coin doesn't have any holders yet." + /> +
+
+ {:else} +
+
+ +

No holders found

+

This coin doesn't have any holders yet.

+
+
+ {/if} +
+
+
diff --git a/website/src/lib/components/self/skeletons/CoinSkeleton.svelte b/website/src/lib/components/self/skeletons/CoinSkeleton.svelte index c26ad4d..c229d7e 100644 --- a/website/src/lib/components/self/skeletons/CoinSkeleton.svelte +++ b/website/src/lib/components/self/skeletons/CoinSkeleton.svelte @@ -1,32 +1,30 @@
-
+
- - - +
- - +
-
+
@@ -38,7 +36,7 @@
- +
@@ -50,23 +48,22 @@
- - + +
- +
- - + + - - +
@@ -76,16 +73,11 @@ - - - - - - -
+ +
-
+
@@ -98,7 +90,7 @@
-
+
@@ -112,20 +104,30 @@
+ + + + + Top Holders + + + + +
{#each Array(4) as _} - - + + - + @@ -135,13 +137,13 @@ - + - +
{#each Array(3) as _}
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"); + })