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;
}
};
}