feat: live trades (/live & sidebar) + sidebar skeleton
This commit is contained in:
parent
37d76b243b
commit
0ddb431536
12 changed files with 785 additions and 175 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
|
|
@ -18,7 +19,10 @@
|
||||||
BellIcon,
|
BellIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
Trophy
|
Trophy,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
@ -28,8 +32,9 @@
|
||||||
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
|
||||||
import DailyRewards from './DailyRewards.svelte';
|
import DailyRewards from './DailyRewards.svelte';
|
||||||
import { signOut } from '$lib/auth-client';
|
import { signOut } from '$lib/auth-client';
|
||||||
import { getPublicUrl } from '$lib/utils';
|
import { formatValue, getPublicUrl } from '$lib/utils';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { liveTradesStore, isLoadingTrades, type LiveTrade } from '$lib/stores/websocket';
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
|
|
@ -46,7 +51,6 @@
|
||||||
const { setOpenMobile, isMobile } = useSidebar();
|
const { setOpenMobile, isMobile } = useSidebar();
|
||||||
let shouldSignIn = $state(false);
|
let shouldSignIn = $state(false);
|
||||||
|
|
||||||
// Fetch portfolio data when user is authenticated
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($USER_DATA) {
|
if ($USER_DATA) {
|
||||||
fetchPortfolioData();
|
fetchPortfolioData();
|
||||||
|
|
@ -70,6 +74,16 @@
|
||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLiveTradesClick() {
|
||||||
|
goto('/live');
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTradeClick(coinSymbol: string) {
|
||||||
|
goto(`/coin/${coinSymbol.toLowerCase()}`);
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
@ -142,44 +156,148 @@
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
</Sidebar.Menu>
|
</Sidebar.Menu>
|
||||||
</Sidebar.GroupContent> </Sidebar.Group>
|
</Sidebar.GroupContent>
|
||||||
|
</Sidebar.Group>
|
||||||
|
|
||||||
<!-- Daily Rewards -->
|
<!-- Daily Rewards -->
|
||||||
{#if $USER_DATA}
|
{#if $USER_DATA}
|
||||||
<Sidebar.Group>
|
<Sidebar.Group>
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<div class="px-2 py-1">
|
<div class="px-2 py-1">
|
||||||
|
{#if !$PORTFOLIO_DATA}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Skeleton class="h-8 w-full rounded" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<DailyRewards />
|
<DailyRewards />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
{/if}
|
{/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 -->
|
<!-- Portfolio Summary -->
|
||||||
{#if $USER_DATA && $PORTFOLIO_DATA}
|
{#if $USER_DATA}
|
||||||
<Sidebar.Group>
|
<Sidebar.Group>
|
||||||
<Sidebar.GroupLabel>Portfolio</Sidebar.GroupLabel>
|
<Sidebar.GroupLabel>Portfolio</Sidebar.GroupLabel>
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<div class="px-2 py-1 space-y-2">
|
<div class="space-y-2 px-2 py-1">
|
||||||
|
{#if !$PORTFOLIO_DATA}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Wallet class="h-4 w-4 text-muted-foreground" />
|
<Skeleton class="h-4 w-4 rounded" />
|
||||||
|
<Skeleton class="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
<Skeleton class="h-5 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{: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>
|
<span class="text-sm font-medium">Total Value</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" class="font-mono">
|
<Badge variant="secondary" class="font-mono">
|
||||||
${formatCurrency($PORTFOLIO_DATA.totalValue)}
|
${formatCurrency($PORTFOLIO_DATA.totalValue)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 text-xs text-muted-foreground">
|
<div class="text-muted-foreground space-y-1 text-xs">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Cash:</span>
|
<span>Cash:</span>
|
||||||
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}</span>
|
<span class="font-mono"
|
||||||
|
>${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span>Coins:</span>
|
<span>Coins:</span>
|
||||||
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.totalCoinValue)}</span>
|
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.totalCoinValue)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.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 }>();
|
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||||
import type { Comment } from '$lib/types/comment';
|
import type { Comment } from '$lib/types/comment';
|
||||||
|
|
@ -21,7 +21,15 @@
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let shouldSignIn = $state(false);
|
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 }) {
|
function handleWebSocketMessage(message: { type: string; data?: any }) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
|
@ -48,13 +56,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWebSocketOpen() {
|
|
||||||
wsManager?.send({
|
|
||||||
type: 'set_coin',
|
|
||||||
coinSymbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/coin/${coinSymbol}/comments`);
|
const response = await fetch(`/api/coin/${coinSymbol}/comments`);
|
||||||
|
|
@ -151,13 +152,6 @@
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
||||||
<WebSocket
|
|
||||||
bind:this={wsManager}
|
|
||||||
onMessage={handleWebSocketMessage}
|
|
||||||
onOpen={handleWebSocketOpen}
|
|
||||||
disableReconnect={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -141,17 +141,16 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $USER_DATA && rewardStatus}
|
|
||||||
<Button
|
<Button
|
||||||
onclick={claimReward}
|
onclick={claimReward}
|
||||||
disabled={claimState === 'loading' || !rewardStatus.canClaim}
|
disabled={claimState === 'loading' || !rewardStatus?.canClaim}
|
||||||
class="w-full transition-all duration-300"
|
class="w-full transition-all duration-300"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={claimState === 'success' ? 'secondary' : rewardStatus.canClaim ? 'default' : 'outline'}
|
variant={claimState === 'success' ? 'secondary' : rewardStatus?.canClaim ? 'default' : 'outline'}
|
||||||
>
|
>
|
||||||
{#if claimState === 'loading'}
|
{#if !rewardStatus || claimState === 'loading'}
|
||||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
|
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
|
||||||
<span>Claiming...</span>
|
<span>{!rewardStatus ? 'Loading...' : 'Claiming...'}</span>
|
||||||
{:else if claimState === 'success'}
|
{:else if claimState === 'success'}
|
||||||
<CheckCircle class="h-4 w-4" />
|
<CheckCircle class="h-4 w-4" />
|
||||||
<span>Claimed!</span>
|
<span>Claimed!</span>
|
||||||
|
|
@ -163,4 +162,3 @@
|
||||||
<span>Next in {formatTimeRemaining(rewardStatus.timeRemaining)}</span>
|
<span>Next in {formatTimeRemaining(rewardStatus.timeRemaining)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
212
website/src/lib/stores/websocket.ts
Normal file
212
website/src/lib/stores/websocket.ts
Normal 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
|
||||||
|
};
|
||||||
43
website/src/lib/utils/validation.ts
Normal file
43
website/src/lib/utils/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { websocketController } from '$lib/stores/websocket';
|
||||||
|
|
||||||
let { data, children } = $props<{
|
let { data, children } = $props<{
|
||||||
data: { userSession?: any };
|
data: { userSession?: any };
|
||||||
|
|
@ -26,6 +27,8 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
websocketController.connect();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`%c .--
|
`%c .--
|
||||||
.=--:
|
.=--:
|
||||||
|
|
@ -69,6 +72,10 @@
|
||||||
window.history.replaceState({}, '', url);
|
window.history.replaceState({}, '', url);
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
websocketController.disconnect();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function getPageTitle(routeId: string | null): string {
|
function getPageTitle(routeId: string | null): string {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { error, json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
|
import { coin, userPortfolio, user, transaction, priceHistory } from '$lib/server/db/schema';
|
||||||
import { eq, and, gte } from 'drizzle-orm';
|
import { eq, and, gte } from 'drizzle-orm';
|
||||||
|
import { redis } from '$lib/server/redis';
|
||||||
|
|
||||||
async function calculate24hMetrics(coinId: number, currentPrice: number) {
|
async function calculate24hMetrics(coinId: number, currentPrice: number) {
|
||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
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');
|
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) {
|
if (!userData) {
|
||||||
throw error(404, 'User not found');
|
throw error(404, 'User not found');
|
||||||
|
|
@ -177,6 +182,34 @@ export async function POST({ params, request }) {
|
||||||
.where(eq(coin.id, coinData.id));
|
.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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
type: 'BUY',
|
type: 'BUY',
|
||||||
|
|
@ -282,6 +315,34 @@ export async function POST({ params, request }) {
|
||||||
.where(eq(coin.id, coinData.id));
|
.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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
type: 'SELL',
|
type: 'SELL',
|
||||||
|
|
|
||||||
57
website/src/routes/api/trades/recent/+server.ts
Normal file
57
website/src/routes/api/trades/recent/+server.ts
Normal 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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||||
import { getPublicUrl } from '$lib/utils.js';
|
import { getPublicUrl } from '$lib/utils.js';
|
||||||
|
import { websocketController } from '$lib/stores/websocket';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const coinSymbol = data.coinSymbol;
|
const coinSymbol = data.coinSymbol;
|
||||||
|
|
@ -38,6 +39,8 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadCoinData();
|
await loadCoinData();
|
||||||
await loadUserHolding();
|
await loadUserHolding();
|
||||||
|
|
||||||
|
websocketController.setCoin(coinSymbol.toUpperCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCoinData() {
|
async function loadCoinData() {
|
||||||
|
|
|
||||||
167
website/src/routes/live/+page.svelte
Normal file
167
website/src/routes/live/+page.svelte
Normal 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>
|
||||||
|
|
@ -15,12 +15,23 @@ if (!process.env.REDIS_URL) {
|
||||||
const redis = new Redis(process.env.REDIS_URL);
|
const redis = new Redis(process.env.REDIS_URL);
|
||||||
|
|
||||||
redis.on('error', (err) => console.error('Redis Client Error', err));
|
redis.on('error', (err) => console.error('Redis Client Error', err));
|
||||||
redis.on('connect', () => console.log('Connected to Redis'));
|
|
||||||
|
|
||||||
redis.psubscribe('comments:*');
|
redis.on('connect', () => {
|
||||||
|
redis.psubscribe('comments:*', 'prices:*', (err, count) => {
|
||||||
|
if (err) console.error("Failed to psubscribe to patterns", err);
|
||||||
|
else console.log(`Successfully psubscribed to patterns. Active psubscriptions: ${count}`);
|
||||||
|
});
|
||||||
|
|
||||||
redis.on('pmessage', (_pattern, channel, msg) => {
|
redis.subscribe('trades:all', 'trades:large', (err, count) => {
|
||||||
|
if (err) console.error("Failed to subscribe to 'trades:all' and 'trades:large'", err);
|
||||||
|
else console.log(`Successfully subscribed to 'trades:all', 'trades:large'. Active subscriptions: ${count}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on('pmessage', (pattern, channel, msg) => {
|
||||||
|
console.log(`[Redis pmessage RECEIVED] Pattern: "${pattern}", Channel: "${channel}", Message: "${msg}"`);
|
||||||
try {
|
try {
|
||||||
|
if (channel.startsWith('comments:')) {
|
||||||
const coinSymbol = channel.substring('comments:'.length);
|
const coinSymbol = channel.substring('comments:'.length);
|
||||||
const sockets = coinSockets.get(coinSymbol);
|
const sockets = coinSockets.get(coinSymbol);
|
||||||
if (sockets) {
|
if (sockets) {
|
||||||
|
|
@ -30,8 +41,47 @@ redis.on('pmessage', (_pattern, channel, msg) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (channel.startsWith('prices:')) {
|
||||||
|
const coinSymbol = channel.substring('prices:'.length);
|
||||||
|
const sockets = coinSockets.get(coinSymbol);
|
||||||
|
console.log(`Received price update for ${coinSymbol}:`, msg);
|
||||||
|
if (sockets) {
|
||||||
|
const priceData = JSON.parse(msg);
|
||||||
|
const priceMessage = JSON.stringify({
|
||||||
|
type: 'price_update',
|
||||||
|
coinSymbol,
|
||||||
|
...priceData
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ws of sockets) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(priceMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing Redis message:', error);
|
console.error('Error processing Redis pmessage:', error, `Pattern: ${pattern}, Channel: ${channel}, Raw message: ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on('message', (channel, msg) => {
|
||||||
|
try {
|
||||||
|
if (channel === 'trades:all' || channel === 'trades:large') {
|
||||||
|
const tradeData = JSON.parse(msg);
|
||||||
|
|
||||||
|
const tradeMessage = JSON.stringify(tradeData);
|
||||||
|
|
||||||
|
for (const [, sockets] of coinSockets.entries()) {
|
||||||
|
for (const ws of sockets) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(tradeMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing Redis message:', error, `Channel: ${channel}, Raw message: ${msg}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -117,6 +167,8 @@ const server = Bun.serve<WebSocketData, undefined>({
|
||||||
|
|
||||||
if (data.type === 'set_coin' && data.coinSymbol) {
|
if (data.type === 'set_coin' && data.coinSymbol) {
|
||||||
handleSetCoin(ws, data.coinSymbol);
|
handleSetCoin(ws, data.coinSymbol);
|
||||||
|
} else if (data.type === 'pong') {
|
||||||
|
ws.data.lastActivity = Date.now();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Message parsing error:', error);
|
console.error('Message parsing error:', error);
|
||||||
|
|
|
||||||
Reference in a new issue