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 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in a new issue