feat: live trades (/live & sidebar) + sidebar skeleton

This commit is contained in:
Face 2025-05-26 15:06:45 +03:00
parent 37d76b243b
commit 0ddb431536
12 changed files with 785 additions and 175 deletions

View file

@ -3,6 +3,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import {
Moon,
Sun,
@ -18,7 +19,10 @@
BellIcon,
LogOutIcon,
Wallet,
Trophy
Trophy,
Activity,
TrendingUp,
TrendingDown
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@ -28,8 +32,9 @@
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
import DailyRewards from './DailyRewards.svelte';
import { signOut } from '$lib/auth-client';
import { getPublicUrl } from '$lib/utils';
import { formatValue, getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
import { liveTradesStore, isLoadingTrades, type LiveTrade } from '$lib/stores/websocket';
const data = {
navMain: [
@ -46,7 +51,6 @@
const { setOpenMobile, isMobile } = useSidebar();
let shouldSignIn = $state(false);
// Fetch portfolio data when user is authenticated
$effect(() => {
if ($USER_DATA) {
fetchPortfolioData();
@ -70,6 +74,16 @@
maximumFractionDigits: 2
});
}
function handleLiveTradesClick() {
goto('/live');
setOpenMobile(false);
}
function handleTradeClick(coinSymbol: string) {
goto(`/coin/${coinSymbol.toLowerCase()}`);
setOpenMobile(false);
}
</script>
<SignInConfirmDialog bind:open={shouldSignIn} />
@ -142,44 +156,148 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent> </Sidebar.Group>
</Sidebar.GroupContent>
</Sidebar.Group>
<!-- Daily Rewards -->
{#if $USER_DATA}
<Sidebar.Group>
<Sidebar.GroupContent>
<div class="px-2 py-1">
<DailyRewards />
{#if !$PORTFOLIO_DATA}
<div class="space-y-2">
<Skeleton class="h-8 w-full rounded" />
</div>
{:else}
<DailyRewards />
{/if}
</div>
</Sidebar.GroupContent>
</Sidebar.Group>
{/if}
<!-- Live Trades -->
<Sidebar.Group>
<Sidebar.GroupLabel class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Activity class="h-4 w-4" />
<span>Live Trades</span>
</div>
<button
onclick={handleLiveTradesClick}
class="text-muted-foreground hover:text-foreground cursor-pointer text-xs transition-colors"
>
View All
</button>
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<div class="space-y-1 px-2 py-1">
{#if $isLoadingTrades}
{#each Array(5) as _, i}
<div class="flex items-center gap-2 py-1 text-xs">
<div class="flex items-center gap-1">
<Skeleton class="h-3 w-3 rounded-full" />
<Skeleton class="h-4 w-8" />
</div>
<div class="flex-1">
<div class="flex items-center gap-1">
<Skeleton class="h-3 w-12" />
<Skeleton class="h-3 w-28" />
</div>
</div>
</div>
{/each}
{:else if $liveTradesStore.length === 0}
<div class="text-muted-foreground py-2 text-center text-xs">No big trades yet...</div>
{:else}
{#each $liveTradesStore.slice(0, 5) as trade, index (`${trade.timestamp}-${trade.username}-${trade.coinSymbol}-${index}`)}
<button
onclick={() => handleTradeClick(trade.coinSymbol)}
class="hover:bg-muted/50 flex w-full cursor-pointer items-center gap-2 rounded px-1 py-1 text-left text-xs transition-colors"
>
<div class="flex items-center gap-1">
{#if trade.type === 'BUY'}
<TrendingUp class="h-3 w-3 text-green-500" />
<Badge
variant="outline"
class="h-4 border-green-500 px-1 py-0 text-[10px] text-green-500"
>
BUY
</Badge>
{:else}
<TrendingDown class="h-3 w-3 text-red-500" />
<Badge
variant="outline"
class="h-4 border-red-500 px-1 py-0 text-[10px] text-red-500"
>
SELL
</Badge>
{/if}
</div>
<div class="flex-1 truncate">
<div class="flex items-center gap-1">
<span class="text-foreground font-medium">
{formatValue(trade.totalValue)}
</span>
<span class="text-muted-foreground">*{trade.coinSymbol}</span>
<span class="text-muted-foreground">by</span>
<span class="text-muted-foreground">@{trade.username}</span>
</div>
</div>
</button>
{/each}
{/if}
</div>
</Sidebar.GroupContent>
</Sidebar.Group>
<!-- Portfolio Summary -->
{#if $USER_DATA && $PORTFOLIO_DATA}
{#if $USER_DATA}
<Sidebar.Group>
<Sidebar.GroupLabel>Portfolio</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<div class="px-2 py-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Wallet class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">Total Value</span>
<div class="space-y-2 px-2 py-1">
{#if !$PORTFOLIO_DATA}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4 rounded" />
<Skeleton class="h-4 w-16" />
</div>
<Skeleton class="h-5 w-16 rounded" />
</div>
<Badge variant="secondary" class="font-mono">
${formatCurrency($PORTFOLIO_DATA.totalValue)}
</Badge>
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<div class="flex justify-between">
<span>Cash:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}</span>
<div class="space-y-1">
<div class="flex justify-between">
<Skeleton class="h-3 w-8" />
<Skeleton class="h-3 w-12" />
</div>
<div class="flex justify-between">
<Skeleton class="h-3 w-10" />
<Skeleton class="h-3 w-12" />
</div>
</div>
<div class="flex justify-between">
<span>Coins:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.totalCoinValue)}</span>
{:else}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Wallet class="text-muted-foreground h-4 w-4" />
<span class="text-sm font-medium">Total Value</span>
</div>
<Badge variant="secondary" class="font-mono">
${formatCurrency($PORTFOLIO_DATA.totalValue)}
</Badge>
</div>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>Cash:</span>
<span class="font-mono"
>${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}</span
>
</div>
<div class="flex justify-between">
<span>Coins:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.totalCoinValue)}</span>
</div>
</div>
{/if}
</div>
</Sidebar.GroupContent>
</Sidebar.Group>

View file

@ -12,7 +12,7 @@
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import WebSocket, { type WebSocketHandle } from '$lib/components/self/WebSocket.svelte';
import { websocketController } from '$lib/stores/websocket';
const { coinSymbol } = $props<{ coinSymbol: string }>();
import type { Comment } from '$lib/types/comment';
@ -21,7 +21,15 @@
let isSubmitting = $state(false);
let isLoading = $state(true);
let shouldSignIn = $state(false);
let wsManager = $state<WebSocketHandle | undefined>();
$effect(() => {
websocketController.setCoin(coinSymbol);
websocketController.subscribeToComments(coinSymbol, handleWebSocketMessage);
return () => {
websocketController.unsubscribeFromComments(coinSymbol);
};
});
function handleWebSocketMessage(message: { type: string; data?: any }) {
switch (message.type) {
@ -48,13 +56,6 @@
}
}
function handleWebSocketOpen() {
wsManager?.send({
type: 'set_coin',
coinSymbol
});
}
async function loadComments() {
try {
const response = await fetch(`/api/coin/${coinSymbol}/comments`);
@ -151,13 +152,6 @@
<SignInConfirmDialog bind:open={shouldSignIn} />
<WebSocket
bind:this={wsManager}
onMessage={handleWebSocketMessage}
onOpen={handleWebSocketOpen}
disableReconnect={true}
/>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">

View file

@ -141,26 +141,24 @@
}
</script>
{#if $USER_DATA && rewardStatus}
<Button
onclick={claimReward}
disabled={claimState === 'loading' || !rewardStatus.canClaim}
class="w-full transition-all duration-300"
size="sm"
variant={claimState === 'success' ? 'secondary' : rewardStatus.canClaim ? 'default' : 'outline'}
>
{#if claimState === 'loading'}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
<span>Claiming...</span>
{:else if claimState === 'success'}
<CheckCircle class="h-4 w-4" />
<span>Claimed!</span>
{:else if rewardStatus.canClaim}
<Gift class="h-4 w-4" />
<span>Claim ${formatCurrency(rewardStatus.rewardAmount)}</span>
{:else}
<Clock class="h-4 w-4" />
<span>Next in {formatTimeRemaining(rewardStatus.timeRemaining)}</span>
{/if}
</Button>
{/if}
<Button
onclick={claimReward}
disabled={claimState === 'loading' || !rewardStatus?.canClaim}
class="w-full transition-all duration-300"
size="sm"
variant={claimState === 'success' ? 'secondary' : rewardStatus?.canClaim ? 'default' : 'outline'}
>
{#if !rewardStatus || claimState === 'loading'}
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
<span>{!rewardStatus ? 'Loading...' : 'Claiming...'}</span>
{:else if claimState === 'success'}
<CheckCircle class="h-4 w-4" />
<span>Claimed!</span>
{:else if rewardStatus.canClaim}
<Gift class="h-4 w-4" />
<span>Claim ${formatCurrency(rewardStatus.rewardAmount)}</span>
{:else}
<Clock class="h-4 w-4" />
<span>Next in {formatTimeRemaining(rewardStatus.timeRemaining)}</span>
{/if}
</Button>

View file

@ -1,102 +0,0 @@
<script lang="ts">
import { PUBLIC_WEBSOCKET_URL } from '$env/static/public';
import { onMount } from 'svelte';
export interface WebSocketHandle {
send: (data: any) => void;
ws: WebSocket | null;
}
type WebSocketMessage = {
type: string;
data?: any;
coinSymbol?: string;
};
let {
onMessage,
onOpen = undefined,
onClose = undefined,
disableReconnect = false
} = $props<{
onMessage: (message: WebSocketMessage) => void;
onOpen?: () => void;
onClose?: (event: CloseEvent) => void;
disableReconnect?: boolean;
}>();
let ws = $state<WebSocket | null>(null);
let reconnectAttempts = $state(0);
const MAX_RECONNECT_ATTEMPTS = 5;
const BASE_DELAY = 1000;
async function initializeWebSocket() {
ws = new WebSocket(PUBLIC_WEBSOCKET_URL);
ws.addEventListener('open', () => {
reconnectAttempts = 0;
onOpen?.();
});
ws.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
if (message.type === 'ping') {
ws?.send(JSON.stringify({ type: 'pong' }));
} else {
onMessage(message);
}
} catch (e) {
console.error('WebSocket message parse error:', e);
}
});
ws.addEventListener('close', async (event) => {
if (onClose) {
onClose(event);
return;
}
if (disableReconnect || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
return;
}
const delay = BASE_DELAY * Math.pow(2, reconnectAttempts);
reconnectAttempts++;
await new Promise((resolve) => setTimeout(resolve, delay));
handleReconnect();
});
ws.addEventListener('error', (event) => {
console.error('WebSocket error:', event);
});
}
async function handleReconnect() {
try {
await initializeWebSocket();
} catch (error) {
console.error('Reconnect failed:', error);
}
}
onMount(() => {
initializeWebSocket().catch((error) => {
console.error(`Connection failed: ${error.message}`);
});
return () => {
ws?.close();
ws = null;
};
});
export { ws as ws };
export const send: WebSocketHandle['send'] = (data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
};
</script>

View file

@ -0,0 +1,212 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { PUBLIC_WEBSOCKET_URL } from '$env/static/public';
export interface LiveTrade {
type: 'BUY' | 'SELL';
username: string;
amount: number;
coinSymbol: string;
coinName?: string;
coinIcon?: string;
totalValue: number;
price: number;
timestamp: number;
userId: string;
userImage?: string;
}
// Constants
const WEBSOCKET_URL = PUBLIC_WEBSOCKET_URL;
const RECONNECT_DELAY = 5000;
const MAX_LIVE_TRADES = 5;
const MAX_ALL_TRADES = 100;
// WebSocket state
let socket: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let activeCoin: string = '@global';
// Stores
export const liveTradesStore = writable<LiveTrade[]>([]);
export const allTradesStore = writable<LiveTrade[]>([]);
export const isConnectedStore = writable<boolean>(false);
export const isLoadingTrades = writable<boolean>(false);
// Comment callbacks
const commentSubscriptions = new Map<string, (message: any) => void>();
async function loadInitialTrades(): Promise<void> {
if (!browser) return;
isLoadingTrades.set(true);
try {
const [largeTradesResponse, allTradesResponse] = await Promise.all([
fetch('/api/trades/recent?limit=5&minValue=1000'),
fetch('/api/trades/recent?limit=100')
]);
if (largeTradesResponse.ok) {
const { trades } = await largeTradesResponse.json();
liveTradesStore.set(trades);
}
if (allTradesResponse.ok) {
const { trades } = await allTradesResponse.json();
allTradesStore.set(trades);
}
} catch (error) {
console.error('Failed to load initial trades:', error);
} finally {
isLoadingTrades.set(false);
}
}
function clearReconnectTimer(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect(): void {
clearReconnectTimer();
reconnectTimer = setTimeout(connect, RECONNECT_DELAY);
}
function isSocketConnected(): boolean {
return socket?.readyState === WebSocket.OPEN;
}
function isSocketConnecting(): boolean {
return socket?.readyState === WebSocket.CONNECTING;
}
function sendMessage(message: object): void {
if (isSocketConnected()) {
socket!.send(JSON.stringify(message));
}
}
function subscribeToChannels(): void {
sendMessage({ type: 'subscribe', channel: 'trades:all' });
sendMessage({ type: 'subscribe', channel: 'trades:large' });
sendMessage({ type: 'set_coin', coinSymbol: activeCoin });
}
function handleTradeMessage(message: any): void {
const trade: LiveTrade = {
...message.data,
timestamp: Number(message.data.timestamp)
};
if (message.type === 'live-trade') {
liveTradesStore.update(trades => [trade, ...trades.slice(0, MAX_LIVE_TRADES - 1)]);
} else if (message.type === 'all-trades') {
allTradesStore.update(trades => [trade, ...trades.slice(0, MAX_ALL_TRADES - 1)]);
}
}
function handleCommentMessage(message: any): void {
const callback = commentSubscriptions.get(activeCoin);
if (callback) {
callback(message);
}
}
function handleWebSocketMessage(event: MessageEvent): void {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'live-trade':
case 'all-trades':
handleTradeMessage(message);
break;
case 'ping':
sendMessage({ type: 'pong' });
break;
case 'new_comment':
case 'comment_liked':
handleCommentMessage(message);
break;
default:
console.log('Unhandled message type:', message.type, message);
}
} catch (error) {
console.error('Failed to process WebSocket message:', error);
}
}
function connect(): void {
if (!browser) return;
// Don't connect if already connected or connecting
if (isSocketConnected() || isSocketConnecting()) {
return;
}
clearReconnectTimer();
socket = new WebSocket(WEBSOCKET_URL);
loadInitialTrades();
socket.onopen = () => {
console.log('WebSocket connected');
isConnectedStore.set(true);
clearReconnectTimer();
subscribeToChannels();
};
socket.onmessage = handleWebSocketMessage;
socket.onclose = (event) => {
console.log(`WebSocket disconnected. Code: ${event.code}`);
isConnectedStore.set(false);
socket = null;
scheduleReconnect();
};
socket.onerror = (error) => {
console.error('WebSocket error:', error);
isConnectedStore.set(false);
};
}
function setCoin(coinSymbol: string): void {
activeCoin = coinSymbol;
sendMessage({ type: 'set_coin', coinSymbol });
}
function disconnect(): void {
clearReconnectTimer();
if (socket) {
socket.close();
socket = null;
}
isConnectedStore.set(false);
}
function subscribeToComments(coinSymbol: string, callback: (message: any) => void): void {
commentSubscriptions.set(coinSymbol, callback);
}
function unsubscribeFromComments(coinSymbol: string): void {
commentSubscriptions.delete(coinSymbol);
}
export const websocketController = {
connect,
disconnect,
setCoin,
subscribeToComments,
unsubscribeFromComments,
loadInitialTrades
};

View file

@ -0,0 +1,43 @@
/**
* Validates and parses URL search parameters with proper fallbacks
*/
export function validateSearchParams(searchParams: URLSearchParams) {
return {
/**
* Gets a positive integer parameter with a default fallback
* @param key - The parameter key
* @param defaultValue - Default value if invalid or missing
* @returns Valid positive integer or default
*/
getPositiveInt: (key: string, defaultValue: number): number => {
const param = searchParams.get(key);
const parsed = param ? parseInt(param, 10) : defaultValue;
return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue;
},
/**
* Gets a non-negative float parameter with a default fallback
* @param key - The parameter key
* @param defaultValue - Default value if invalid or missing
* @returns Valid non-negative float or default
*/
getNonNegativeFloat: (key: string, defaultValue: number): number => {
const param = searchParams.get(key);
const parsed = param ? parseFloat(param) : defaultValue;
return !isNaN(parsed) && parsed >= 0 ? parsed : defaultValue;
},
/**
* Gets a string parameter with optional validation
* @param key - The parameter key
* @param defaultValue - Default value if missing
* @param validator - Optional validation function
* @returns Valid string or default
*/
getString: (key: string, defaultValue: string, validator?: (value: string) => boolean): string => {
const param = searchParams.get(key);
if (!param) return defaultValue;
return validator ? (validator(param) ? param : defaultValue) : param;
}
};
}

View file

@ -11,6 +11,7 @@
import { invalidateAll } from '$app/navigation';
import { ModeWatcher } from 'mode-watcher';
import { page } from '$app/state';
import { websocketController } from '$lib/stores/websocket';
let { data, children } = $props<{
data: { userSession?: any };
@ -26,6 +27,8 @@
});
onMount(() => {
websocketController.connect();
console.log(
`%c .--
.=--:
@ -69,6 +72,10 @@
window.history.replaceState({}, '', url);
invalidateAll();
}
return () => {
websocketController.disconnect();
};
});
function getPageTitle(routeId: string | null): string {

View file

@ -3,6 +3,7 @@ import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
import { eq, and, gte } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
async function calculate24hMetrics(coinId: number, currentPrice: number) {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
@ -74,7 +75,11 @@ export async function POST({ params, request }) {
throw error(400, 'This coin is delisted and cannot be traded');
}
const [userData] = await db.select({ baseCurrencyBalance: user.baseCurrencyBalance }).from(user).where(eq(user.id, userId)).limit(1);
const [userData] = await db.select({
baseCurrencyBalance: user.baseCurrencyBalance,
username: user.username,
image: user.image
}).from(user).where(eq(user.id, userId)).limit(1);
if (!userData) {
throw error(404, 'User not found');
@ -177,6 +182,34 @@ export async function POST({ params, request }) {
.where(eq(coin.id, coinData.id));
});
// REDIS
const tradeData = {
type: 'BUY',
username: userData.username,
userImage: userData.image || '',
amount: coinsBought,
coinSymbol: normalizedSymbol,
coinName: coinData.name,
coinIcon: coinData.icon || '',
totalValue: totalCost,
price: newPrice,
timestamp: Date.now(),
userId: userId.toString()
};
await redis.publish('trades:all', JSON.stringify({
type: 'all-trades',
data: tradeData
}));
if (totalCost >= 1000) {
await redis.publish('trades:large', JSON.stringify({
type: 'live-trade',
data: tradeData
}));
}
// End REDIS
return json({
success: true,
type: 'BUY',
@ -282,6 +315,34 @@ export async function POST({ params, request }) {
.where(eq(coin.id, coinData.id));
});
// REDIS
const tradeData = {
type: 'SELL',
username: userData.username,
userImage: userData.image || '',
amount: amount,
coinSymbol: normalizedSymbol,
coinName: coinData.name,
coinIcon: coinData.icon || '',
totalValue: totalCost,
price: newPrice,
timestamp: Date.now(),
userId: userId.toString()
};
await redis.publish('trades:all', JSON.stringify({
type: 'all-trades',
data: tradeData
}));
if (totalCost >= 1000) {
await redis.publish('trades:large', JSON.stringify({
type: 'live-trade',
data: tradeData
}));
}
// End REDIS
return json({
success: true,
type: 'SELL',

View file

@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { transaction, user, coin } from '$lib/server/db/schema';
import { desc, gte, eq } from 'drizzle-orm';
import { validateSearchParams } from '$lib/utils/validation';
export async function GET({ url }) {
const params = validateSearchParams(url.searchParams);
const limit = params.getPositiveInt('limit', 100);
const minValue = params.getNonNegativeFloat('minValue', 0);
try {
const trades = await db
.select({
type: transaction.type,
username: user.username,
userImage: user.image,
amount: transaction.quantity,
coinSymbol: coin.symbol,
coinName: coin.name,
coinIcon: coin.icon,
totalValue: transaction.totalBaseCurrencyAmount,
price: transaction.pricePerCoin,
timestamp: transaction.timestamp,
userId: transaction.userId
})
.from(transaction)
.innerJoin(user, eq(user.id, transaction.userId))
.innerJoin(coin, eq(coin.id, transaction.coinId))
.where(
minValue > 0
? gte(transaction.totalBaseCurrencyAmount, minValue.toString())
: undefined
)
.orderBy(desc(transaction.timestamp))
.limit(limit);
const formattedTrades = trades.map(trade => ({
type: trade.type as 'BUY' | 'SELL',
username: trade.username,
userImage: trade.userImage,
amount: Number(trade.amount),
coinSymbol: trade.coinSymbol,
coinName: trade.coinName,
coinIcon: trade.coinIcon,
totalValue: Number(trade.totalValue),
price: Number(trade.price),
timestamp: trade.timestamp.getTime(),
userId: trade.userId.toString()
}));
return json({ trades: formattedTrades });
} catch (error) {
console.error('Error fetching recent trades:', error);
return json({ trades: [] });
}
}

View file

@ -22,6 +22,7 @@
import { USER_DATA } from '$lib/stores/user-data';
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
import { getPublicUrl } from '$lib/utils.js';
import { websocketController } from '$lib/stores/websocket';
const { data } = $props();
const coinSymbol = data.coinSymbol;
@ -38,6 +39,8 @@
onMount(async () => {
await loadCoinData();
await loadUserHolding();
websocketController.setCoin(coinSymbol.toUpperCase());
});
async function loadCoinData() {

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import * as Avatar from '$lib/components/ui/avatar';
import * as HoverCard from '$lib/components/ui/hover-card';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Activity, TrendingUp, TrendingDown, Clock } from 'lucide-svelte';
import { allTradesStore, isLoadingTrades } 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';
function handleUserClick(username: string) {
goto(`/user/${username}`);
}
function handleCoinClick(coinSymbol: string) {
goto(`/coin/${coinSymbol.toLowerCase()}`);
}
</script>
<svelte:head>
<title>Live Trades - Rugplay</title>
<meta name="description" content="Real-time cryptocurrency trading activity on Rugplay" />
</svelte:head>
<div class="container mx-auto max-w-7xl p-6">
<header class="mb-8">
<div>
<h1 class="text-3xl font-bold">Live Trades</h1>
<p class="text-muted-foreground">Real-time trading activity for all trades</p>
</div>
</header>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Activity class="h-5 w-5" />
Stream
{#if $allTradesStore.length > 0}
<Badge variant="secondary" class="ml-auto">
{$allTradesStore.length} trade{$allTradesStore.length !== 1 ? 's' : ''}
</Badge>
{/if}
</CardTitle>
</CardHeader>
<CardContent>
{#if $isLoadingTrades}
<div class="space-y-3">
{#each Array(8) as _, i}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 rounded-full" />
<Skeleton class="h-6 w-12" />
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<Skeleton class="h-6 w-6 rounded-full" />
<Skeleton class="h-4 w-24" />
<Skeleton class="h-4 w-16" />
<Skeleton class="h-5 w-5 rounded-full" />
<Skeleton class="h-4 w-20" />
</div>
<Skeleton class="h-3 w-32" />
</div>
</div>
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4" />
<Skeleton class="h-4 w-16" />
</div>
</div>
{/each}
</div>
{:else if $allTradesStore.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<Activity class="text-muted-foreground/50 mb-4 h-16 w-16" />
<h3 class="mb-2 text-lg font-semibold">Waiting for trades...</h3>
<p class="text-muted-foreground">All trades will appear here in real-time.</p>
</div>
{:else}
<div class="space-y-3">
{#each $allTradesStore as trade (trade.timestamp)}
<div
class="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-4 transition-colors"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
{#if trade.type === 'BUY'}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10"
>
<TrendingUp class="h-4 w-4 text-green-500" />
</div>
<Badge variant="outline" class="border-green-500 text-green-500">BUY</Badge>
{:else}
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-red-500/10"
>
<TrendingDown class="h-4 w-4 text-red-500" />
</div>
<Badge variant="outline" class="border-red-500 text-red-500">SELL</Badge>
{/if}
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<button
onclick={() => handleCoinClick(trade.coinSymbol)}
class="flex cursor-pointer items-center gap-2 transition-opacity hover:underline hover:opacity-80"
>
<CoinIcon
icon={trade.coinIcon}
symbol={trade.coinSymbol}
name={trade.coinName || trade.coinSymbol}
size={6}
/>
<span class="font-mono font-medium">
{formatQuantity(trade.amount)} *{trade.coinSymbol}
</span>
</button>
<span class="text-muted-foreground">
{trade.type === 'BUY' ? 'bought by' : 'sold by'}
</span>
<HoverCard.Root>
<HoverCard.Trigger
class="cursor-pointer font-medium underline-offset-4 hover:underline"
onclick={() => handleUserClick(trade.username)}
>
<div class="flex items-center gap-1">
<Avatar.Root class="h-5 w-5">
<Avatar.Image
src={getPublicUrl(trade.userImage ?? null)}
alt={trade.username}
/>
<Avatar.Fallback class="text-xs"
>{trade.username.charAt(0).toUpperCase()}</Avatar.Fallback
>
</Avatar.Root>
<span>@{trade.username}</span>
</div>
</HoverCard.Trigger>
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
<UserProfilePreview userId={parseInt(trade.userId)} />
</HoverCard.Content>
</HoverCard.Root>
</div>
<div class="text-muted-foreground text-sm">
Trade value: {formatValue(trade.totalValue)}
</div>
</div>
</div>
<div class="text-muted-foreground flex items-center gap-2 text-sm">
<Clock class="h-4 w-4" />
<span class="font-mono">{formatRelativeTime(new Date(trade.timestamp))}</span>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
</div>