feat: treemap, skeletons

This commit is contained in:
Face 2025-05-27 14:12:29 +03:00
parent 848eda70e4
commit 330ea7ad79
18 changed files with 1033 additions and 45 deletions

View file

@ -6,6 +6,7 @@
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import DataTable from '$lib/components/self/DataTable.svelte';
import HomeSkeleton from '$lib/components/self/skeletons/HomeSkeleton.svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -102,11 +103,7 @@
</header>
{#if loading}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">Loading market data...</div>
</div>
</div>
<HomeSkeleton />
{:else if coins.length === 0}
<div class="flex h-96 items-center justify-center">
<div class="text-center">

View file

@ -13,7 +13,7 @@
import { Alert, AlertDescription } from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Plus, Ticket, Users, Calendar, CheckCircle, XCircle, Loader2 } from 'lucide-svelte';
import { Plus, Ticket, Users, Calendar, XCircle, Loader2, CheckIcon } from 'lucide-svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { formatDate, getExpirationDate } from '$lib/utils';
import type { PromoCode } from '$lib/types/promo-code';
@ -216,7 +216,7 @@
class={createSuccess ? 'text-success' : ''}
>
{#if createSuccess}
<CheckCircle class="h-4 w-4 text-green-600" />
<CheckIcon class="h-4 w-4 text-green-600" />
{:else}
<XCircle class="h-4 w-4" />
{/if}

View file

@ -180,6 +180,13 @@ export async function POST({ params, request }) {
updatedAt: new Date()
})
.where(eq(coin.id, coinData.id));
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({
currentPrice: newPrice,
marketCap: Number(coinData.circulatingSupply) * newPrice,
change24h: metrics.change24h,
volume24h: metrics.volume24h
}));
});
// REDIS
@ -313,6 +320,13 @@ export async function POST({ params, request }) {
updatedAt: new Date()
})
.where(eq(coin.id, coinData.id));
await redis.publish(`prices:${normalizedSymbol}`, JSON.stringify({
currentPrice: newPrice,
marketCap: Number(coinData.circulatingSupply) * newPrice,
change24h: metrics.change24h,
volume24h: metrics.volume24h
}));
});
// REDIS

View file

@ -7,6 +7,7 @@
import TradeModal from '$lib/components/self/TradeModal.svelte';
import CommentSection from '$lib/components/self/CommentSection.svelte';
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
import CoinSkeleton from '$lib/components/self/skeletons/CoinSkeleton.svelte';
import { TrendingUp, TrendingDown, DollarSign, Coins, ChartColumn } from 'lucide-svelte';
import {
createChart,
@ -259,26 +260,17 @@
onSuccess={handleTradeSuccess}
/>
{/if}
{#if loading}
<div class="container mx-auto max-w-7xl p-6">
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">Loading coin data...</div>
</div>
</div>
</div>
{:else if !coin}
<div class="container mx-auto max-w-7xl p-6">
<div class="container mx-auto max-w-7xl p-6">
{#if loading}
<CoinSkeleton />
{:else if !coin}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="text-muted-foreground mb-4 text-xl">Coin not found</div>
<Button onclick={() => goto('/')}>Go Home</Button>
</div>
</div>
</div>
{:else}
<div class="container mx-auto max-w-7xl p-6">
{:else}
<!-- Header Section -->
<header class="mb-8">
<div class="mb-4 flex items-start justify-between">
@ -535,5 +527,5 @@
<!-- Comments Section -->
<CommentSection {coinSymbol} />
</div>
</div>
{/if}
{/if}
</div>

View file

@ -4,6 +4,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import DataTable from '$lib/components/self/DataTable.svelte';
import LeaderboardSkeleton from '$lib/components/self/skeletons/LeaderboardSkeleton.svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -215,11 +216,7 @@
</header>
{#if loading}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">Loading leaderboard...</div>
</div>
</div>
<LeaderboardSkeleton />
{:else if !leaderboardData}
<div class="flex h-96 items-center justify-center">
<div class="text-center">

View file

@ -8,6 +8,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Label } from '$lib/components/ui/label';
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import MarketSkeleton from '$lib/components/self/skeletons/MarketSkeleton.svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -410,12 +411,7 @@
<!-- Market Grid -->
{#if loading}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="mb-4 text-xl">Loading market data...</div>
<div class="text-muted-foreground">Fetching the latest coin prices and chaos levels</div>
</div>
</div>
<MarketSkeleton />
{:else if coins.length === 0}
<div class="flex h-96 items-center justify-center">
<div class="text-center">

View file

@ -0,0 +1,350 @@
<script lang="ts">
import { onMount } from 'svelte';
import { chart } from 'svelte-apexcharts';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Activity, ChartColumn, Maximize, Minimize } from 'lucide-svelte';
import { formatValue } from '$lib/utils';
import { allTradesStore } from '$lib/stores/websocket';
import { Button } from '$lib/components/ui/button';
interface CoinData {
symbol: string;
name: string;
currentPrice: number;
marketCap: number;
priceChange24h: number;
volume24h: number;
}
let coins: CoinData[] = $state([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let lastUpdated = $state<Date>(new Date());
let isLiveUpdatesEnabled = $state(true);
let isFullscreen = $state(false);
let fullscreenContainer: HTMLDivElement;
let treemapOptions = $derived({
series: [
{
data: coins.map((coin) => {
const change = coin.priceChange24h;
if (Math.abs(change) < 0.5) {
return { x: coin.symbol, y: coin.marketCap, fillColor: 'rgba(107,114,128,0.3)' };
}
const intensity = Math.min(Math.abs(change) / 100, 1);
const alpha = 0.3 + intensity * 0.7;
const base = change >= 0 ? '16,163,74' : '220,38,38';
return { x: coin.symbol, y: coin.marketCap, fillColor: `rgba(${base},${alpha})` };
})
}
],
chart: {
height: isFullscreen ? window.innerHeight - 300 : 600,
type: 'treemap',
toolbar: {
show: false
},
background: 'transparent',
animations: {
enabled: true,
easing: 'easeinout',
speed: 200
}
},
dataLabels: {
enabled: true,
style: {
fontSize: '12px',
fontWeight: 'bold',
colors: ['#ffffff']
},
formatter: function (text: string, op: any) {
const coin = coins.find((c) => c.symbol === text);
if (!coin) return [text];
const changeSign = coin.priceChange24h >= 0 ? '+' : '';
return [text, `${changeSign}${coin.priceChange24h.toFixed(2)}%`];
},
offsetY: -4
},
plotOptions: {
treemap: {
distributed: true,
enableShades: false
}
},
legend: {
show: false
},
tooltip: {
enabled: true,
custom: function ({ seriesIndex, dataPointIndex }: any) {
const coin = coins[dataPointIndex];
if (!coin) return '';
const changeColor = coin.priceChange24h >= 0 ? '#22c55e' : '#ef4444';
const changeSign = coin.priceChange24h >= 0 ? '+' : '';
return `
<div class="p-3 bg-card border rounded-md shadow-lg">
<div class="font-semibold text-lg mb-2">*${coin.symbol}</div>
<div class="text-sm text-muted-foreground mb-1">${coin.name}</div>
<div class="space-y-1 text-xs">
<div>Price: <span class="font-mono">${formatValue(coin.currentPrice)}</span></div>
<div>Market Cap: <span class="font-mono">${formatValue(coin.marketCap)}</span></div>
<div>24h Volume: <span class="font-mono">${formatValue(coin.volume24h)}</span></div>
<div>24h Change: <span class="font-mono" style="color: ${changeColor}">${changeSign}${coin.priceChange24h.toFixed(2)}%</span></div>
</div>
</div>
`;
}
},
theme: {
mode: 'light'
}
});
$effect(() => {
if ($allTradesStore.length > 0 && isLiveUpdatesEnabled) {
const timeoutId = setTimeout(() => {
fetchCoins();
}, 2000);
return () => clearTimeout(timeoutId);
}
});
$effect(() => {
function handleFullscreenChange() {
isFullscreen = !!document.fullscreenElement;
}
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
};
});
async function toggleFullscreen() {
if (!document.fullscreenElement) {
try {
await fullscreenContainer.requestFullscreen();
} catch (err) {
console.error('Error attempting to enable fullscreen:', err);
}
} else {
try {
await document.exitFullscreen();
} catch (err) {
console.error('Error attempting to exit fullscreen:', err);
}
}
}
async function fetchCoins() {
try {
if (coins.length === 0) {
isLoading = true;
}
error = null;
const response = await fetch('/api/market?limit=100');
if (!response.ok) {
throw new Error('Failed to fetch coins data');
}
const data = await response.json();
coins =
data.coins.map((coin: any) => ({
symbol: coin.symbol,
name: coin.name,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
priceChange24h: coin.change24h,
volume24h: coin.volume24h
})) || [];
lastUpdated = new Date();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
console.error('Error fetching coins:', err);
} finally {
isLoading = false;
}
}
onMount(() => {
fetchCoins();
});
</script>
<svelte:head>
<title>Treemap - Rugplay</title>
<meta name="description" content="Cryptocurrency market treemap visualization" />
</svelte:head>
<div
bind:this={fullscreenContainer}
class="treemap-container {isFullscreen ? 'fullscreen-mode' : ''}"
>
<div class="container mx-auto px-4 py-8 {isFullscreen ? 'fullscreen-content' : ''}">
<div class="mb-6">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<ChartColumn class="h-6 w-6" />
<h1 class="text-2xl font-bold">Market Treemap</h1>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => (isLiveUpdatesEnabled = !isLiveUpdatesEnabled)}
class={isLiveUpdatesEnabled
? 'border-green-500 text-green-500'
: 'border-red-500 text-red-500'}
>
{#if isLiveUpdatesEnabled}
<Activity class="h-4 w-4" />
Live
{:else}
<Activity class="h-4 w-4" />
Paused
{/if}
</Button>
<Button variant="outline" size="sm" onclick={toggleFullscreen}>
{#if isFullscreen}
<Minimize class="h-4 w-4" />
Exit Fullscreen
{:else}
<Maximize class="h-4 w-4" />
Fullscreen
{/if}
</Button>
</div>
</div>
<p class="text-muted-foreground">
Visual representation of the cryptocurrency market. Size indicates market cap, color shows
24h price change.
</p>
{#if coins.length > 0}
<p class="text-muted-foreground mt-1 text-sm">
Last updated: {lastUpdated.toLocaleTimeString()}
</p>
{/if}
</div>
{#if isLoading && coins.length === 0}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Skeleton class="h-5 w-5" />
<Skeleton class="h-6 w-48" />
</Card.Title>
<Card.Description>
<Skeleton class="h-4 w-64" />
</Card.Description>
</Card.Header>
<Card.Content>
<Skeleton class="h-[600px] w-full" />
</Card.Content>
</Card.Root>
{:else if error}
<Card.Root>
<Card.Content class="p-8 text-center">
<div class="text-muted-foreground mb-4">
<ChartColumn class="mx-auto mb-2 h-12 w-12 opacity-50" />
<p class="text-lg font-medium">Failed to load treemap</p>
<p class="text-sm">{error}</p>
</div>
<Button onclick={fetchCoins}>Try Again</Button>
</Card.Content>
</Card.Root>
{:else if coins.length === 0}
<Card.Root>
<Card.Content class="p-8 text-center">
<div class="text-muted-foreground">
<ChartColumn class="mx-auto mb-2 h-12 w-12 opacity-50" />
<p class="text-lg font-medium">No coins available</p>
<p class="text-sm">Create some coins to see the treemap visualization.</p>
</div>
</Card.Content>
</Card.Root>
{:else}
<Card.Root>
<Card.Content class="p-6">
<div class="text-muted-foreground mb-4 flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded bg-green-500"></div>
<span>Positive 24h change</span>
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded bg-red-500"></div>
<span>Negative 24h change</span>
</div>
<Badge variant="outline" class="ml-auto">
{coins.length} coins
</Badge>
</div>
<div use:chart={treemapOptions}></div>
</Card.Content>
</Card.Root>
{/if}
</div>
</div>
<style>
.treemap-container.fullscreen-mode {
background: hsl(var(--background));
padding: 1rem;
height: 100vh;
overflow: hidden;
}
.treemap-container.fullscreen-mode .container {
max-width: none;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.treemap-container.fullscreen-mode .fullscreen-content {
height: 100%;
display: flex;
flex-direction: column;
padding: 1rem 0;
}
.treemap-container.fullscreen-mode .mb-6 {
margin-bottom: 1rem;
flex-shrink: 0;
}
.treemap-container.fullscreen-mode :global(.card) {
flex: 1;
display: flex;
flex-direction: column;
}
.treemap-container.fullscreen-mode :global(.card .card-content) {
flex: 1;
display: flex;
flex-direction: column;
}
:global(.fullscreen-mode) {
z-index: 9999;
}
</style>

View file

@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button';
import DataTable from '$lib/components/self/DataTable.svelte';
import ProfileBadges from '$lib/components/self/ProfileBadges.svelte';
import ProfileSkeleton from '$lib/components/self/skeletons/ProfileSkeleton.svelte';
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
@ -15,8 +16,7 @@
TrendingDown,
Coins,
Receipt,
Activity,
RefreshCw
Activity
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { UserProfileData } from '$lib/types/user-profile';
@ -208,12 +208,7 @@
<div class="container mx-auto max-w-6xl p-6">
{#if loading}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<RefreshCw class="text-muted-foreground mx-auto mb-4 h-8 w-8 animate-spin" />
<div class="text-xl">Loading profile...</div>
</div>
</div>
<ProfileSkeleton />
{:else if !profileData}
<div class="flex h-96 items-center justify-center">
<div class="text-center">