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>