feat: profile page + 404 page + refactor code

This commit is contained in:
Face 2025-05-25 18:44:06 +03:00
parent 3f137e5c3c
commit d692e86fe0
17 changed files with 1282 additions and 313 deletions

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
const status = page.status;
const message = getDefaultMessage(status);
function getDefaultMessage(status: number) {
switch (status) {
case 404:
return "This page doesn't exist. Just like the original Vyntr!";
case 403:
return "You don't have permission to access this page. Your credentials are likely ####.";
case 429:
return "Too many requests! You're hitting our servers. They have feelings too :(";
case 500:
return "Our magic machine just imploded. Don't worry though, we're on it!";
default:
return 'Something went wrong. We have no idea what happened, but you can blame us for it on X!';
}
}
</script>
<svelte:head>
<title>{status} | Rugplay</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="flex min-h-[70vh] items-center justify-center gap-12">
<div class="flex max-w-lg flex-col items-center justify-center text-center">
<h1 class="text-primary mb-4 font-bold" style="font-size: 3rem; line-height: 1;">
{status} WRONG TURN?
</h1>
<p class="text-muted-foreground mb-8 text-lg">
{message}
</p>
<div class="flex flex-col">
<Button variant="link" href="https://discord.gg/cKWNV2uZUP" target="_blank">@Discord</Button>
<Button variant="link" href="https://x.com/facedevstuff" target="_blank">@X</Button>
</div>
</div>
<img
src="/404.gif"
alt="404 Error Illustration"
class="hidden h-64 w-64 object-contain lg:block"
/>
</div>

View file

@ -1,13 +1,14 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import { getTimeBasedGreeting, formatPrice, formatMarketCap } from '$lib/utils';
import { USER_DATA } from '$lib/stores/user-data';
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 { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
let shouldSignIn = $state(false);
let coins = $state<any[]>([]);
@ -29,6 +30,51 @@
loading = false;
}
});
const marketColumns = [
{
key: 'name',
label: 'Name',
class: 'font-medium',
render: (value: any, row: any) => {
return {
component: 'link',
href: `/coin/${row.symbol}`,
content: {
icon: row.icon,
symbol: row.symbol,
name: row.name
}
};
}
},
{
key: 'price',
label: 'Price',
render: (value: any) => `$${formatPrice(value)}`
},
{
key: 'change24h',
label: '24h Change',
render: (value: any) => ({
component: 'badge',
variant: value >= 0 ? 'success' : 'destructive',
text: `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`
})
},
{
key: 'marketCap',
label: 'Market Cap',
class: 'hidden md:table-cell',
render: (value: any) => formatMarketCap(value)
},
{
key: 'volume24h',
label: 'Volume (24h)',
class: 'hidden md:table-cell',
render: (value: any) => formatMarketCap(value)
}
];
</script>
<SignInConfirmDialog bind:open={shouldSignIn} />
@ -102,44 +148,11 @@
<h2 class="mb-4 text-2xl font-bold">Market Overview</h2>
<Card.Root>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Price</Table.Head>
<Table.Head>24h Change</Table.Head>
<Table.Head class="hidden md:table-cell">Market Cap</Table.Head>
<Table.Head class="hidden md:table-cell">Volume (24h)</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each coins as coin}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={`/coin/${coin.symbol}`}
class="flex items-center gap-2 hover:underline"
>
<CoinIcon icon={coin.icon} symbol={coin.symbol} name={coin.name} size={4} />
{coin.name} <span class="text-muted-foreground">(*{coin.symbol})</span>
</a>
</Table.Cell>
<Table.Cell>${formatPrice(coin.price)}</Table.Cell>
<Table.Cell>
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'}>
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
</Badge>
</Table.Cell>
<Table.Cell class="hidden md:table-cell"
>{formatMarketCap(coin.marketCap)}</Table.Cell
>
<Table.Cell class="hidden md:table-cell"
>{formatMarketCap(coin.volume24h)}</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<DataTable
columns={marketColumns}
data={coins}
onRowClick={(coin) => goto(`/coin/${coin.symbol}`)}
/>
</Card.Content>
</Card.Root>
</div>

View file

@ -35,8 +35,6 @@ export async function GET({ params, request }) {
userName: user.name,
userUsername: user.username,
userImage: user.image,
userBio: user.bio,
userCreatedAt: user.createdAt,
isLikedByUser: session?.user ?
sql<boolean>`EXISTS(SELECT 1 FROM ${commentLike} WHERE ${commentLike.userId} = ${session.user.id} AND ${commentLike.commentId} = ${comment.id})` :
sql<boolean>`FALSE`
@ -109,8 +107,6 @@ export async function POST({ request, params }) {
userName: user.name,
userUsername: user.username,
userImage: user.image,
userBio: user.bio,
userCreatedAt: user.createdAt,
isLikedByUser: sql<boolean>`FALSE`
})
.from(comment)

View file

@ -0,0 +1,139 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user, coin, transaction, userPortfolio } from '$lib/server/db/schema';
import { eq, desc, sql, count, and, gte } from 'drizzle-orm';
export async function GET({ params }) {
const { userId } = params;
if (!userId) {
throw error(400, 'User ID or username is required');
}
try {
const isNumeric = /^\d+$/.test(userId);
const userProfile = await db.query.user.findFirst({
where: isNumeric ? eq(user.id, parseInt(userId)) : eq(user.username, userId),
columns: {
id: true,
name: true,
username: true,
bio: true,
image: true,
createdAt: true,
baseCurrencyBalance: true,
isAdmin: true,
}
});
if (!userProfile) {
throw error(404, 'User not found');
}
const actualUserId = userProfile.id;
// get created coins
const createdCoins = await db
.select({
id: coin.id,
name: coin.name,
symbol: coin.symbol,
icon: coin.icon,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
volume24h: coin.volume24h,
change24h: coin.change24h,
createdAt: coin.createdAt,
})
.from(coin)
.where(eq(coin.creatorId, actualUserId))
.orderBy(desc(coin.createdAt))
.limit(10);
// get portfolio value and holdings count
const portfolioStats = await db
.select({
holdingsCount: count(),
totalValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`
})
.from(userPortfolio)
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
.where(eq(userPortfolio.userId, actualUserId));
// get recent transactions
const recentTransactions = await db
.select({
id: transaction.id,
type: transaction.type,
coinSymbol: coin.symbol,
coinName: coin.name,
coinIcon: coin.icon,
quantity: transaction.quantity,
pricePerCoin: transaction.pricePerCoin,
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
timestamp: transaction.timestamp,
})
.from(transaction)
.innerJoin(coin, eq(transaction.coinId, coin.id))
.where(eq(transaction.userId, actualUserId))
.orderBy(desc(transaction.timestamp))
.limit(10);
// calc total portfolio value
const baseCurrencyBalance = parseFloat(userProfile.baseCurrencyBalance);
const holdingsValue = portfolioStats[0]?.totalValue || 0;
const totalPortfolioValue = baseCurrencyBalance + holdingsValue;
// get all transaction statistics
const transactionStats = await db
.select({
totalTransactions: count(),
totalBuyVolume: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'BUY' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`,
totalSellVolume: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'SELL' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`
})
.from(transaction)
.where(eq(transaction.userId, actualUserId));
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const transactionStats24h = await db
.select({
transactions24h: count(),
buyVolume24h: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'BUY' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`,
sellVolume24h: sql<number>`COALESCE(SUM(CASE WHEN ${transaction.type} = 'SELL' THEN CAST(${transaction.totalBaseCurrencyAmount} AS NUMERIC) ELSE 0 END), 0)`
})
.from(transaction)
.where(
and(
eq(transaction.userId, actualUserId),
gte(transaction.timestamp, twentyFourHoursAgo)
)
);
return json({
profile: {
...userProfile,
baseCurrencyBalance,
totalPortfolioValue,
},
stats: {
totalPortfolioValue,
baseCurrencyBalance,
holdingsValue,
holdingsCount: portfolioStats[0]?.holdingsCount || 0,
coinsCreated: createdCoins.length,
totalTransactions: transactionStats[0]?.totalTransactions || 0,
totalBuyVolume: transactionStats[0]?.totalBuyVolume || 0,
totalSellVolume: transactionStats[0]?.totalSellVolume || 0,
transactions24h: transactionStats24h[0]?.transactions24h || 0,
buyVolume24h: transactionStats24h[0]?.buyVolume24h || 0,
sellVolume24h: transactionStats24h[0]?.sellVolume24h || 0,
},
createdCoins,
recentTransactions
});
} catch (e) {
console.error('Failed to fetch user profile:', e);
throw error(500, 'Failed to fetch user profile');
}
}

View file

@ -6,6 +6,7 @@
import * as HoverCard from '$lib/components/ui/hover-card';
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 {
TrendingUp,
TrendingDown,
@ -28,13 +29,13 @@
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
import { getPublicUrl } from '$lib/utils.js';
const { data } = $props();
const coinSymbol = data.coinSymbol;
let coin = $state<any>(null);
let loading = $state(true);
let creatorImageUrl = $state<string | null>(null);
let chartData = $state<any[]>([]);
let volumeData = $state<any[]>([]);
let userHolding = $state(0);
@ -61,15 +62,6 @@
chartData = result.candlestickData || [];
volumeData = result.volumeData || [];
if (coin.creatorId) {
try {
const imageResponse = await fetch(`/api/user/${coin.creatorId}/image`);
const imageResult = await imageResponse.json();
creatorImageUrl = imageResult.url;
} catch (e) {
console.error('Failed to load creator image:', e);
}
}
} catch (e) {
console.error('Failed to fetch coin data:', e);
toast.error('Failed to load coin data');
@ -339,37 +331,16 @@
<HoverCard.Root>
<HoverCard.Trigger
class="flex cursor-pointer items-center gap-1 rounded-sm underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
onclick={() => goto(`/user/${coin.creatorId}`)}
onclick={() => goto(`/user/${coin.creatorUsername}`)}
>
<Avatar.Root class="h-4 w-4">
<Avatar.Image src={creatorImageUrl} alt={coin.creatorName} />
<Avatar.Image src={getPublicUrl(coin.creatorImage)} alt={coin.creatorName} />
<Avatar.Fallback>{coin.creatorName.charAt(0)}</Avatar.Fallback>
</Avatar.Root>
<span class="font-medium">{coin.creatorName} (@{coin.creatorUsername})</span>
</HoverCard.Trigger>
<HoverCard.Content class="w-80" side="bottom" sideOffset={3}>
<div class="flex justify-between space-x-4">
<Avatar.Root class="h-14 w-14">
<Avatar.Image src={creatorImageUrl} alt={coin.creatorName} />
<Avatar.Fallback>{coin.creatorName.charAt(0)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex-1 space-y-1">
<h4 class="text-sm font-semibold">{coin.creatorName}</h4>
<p class="text-muted-foreground text-sm">@{coin.creatorUsername}</p>
{#if coin.creatorBio}
<p class="text-sm">{coin.creatorBio}</p>
{/if}
<div class="flex items-center pt-2">
<CalendarDays class="mr-2 h-4 w-4 opacity-70" />
<span class="text-muted-foreground text-xs">
Joined {new Date(coin.creatorCreatedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})}
</span>
</div>
</div>
</div>
<UserProfilePreview userId={coin.creatorId} />
</HoverCard.Content>
</HoverCard.Root>
</div>

View file

@ -1,9 +1,9 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import * as Avatar from '$lib/components/ui/avatar';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import DataTable from '$lib/components/self/DataTable.svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -53,6 +53,147 @@
if (liquidityRatio < 0.5) return { text: '50%+ illiquid', color: 'text-yellow-600' };
return { text: 'Mostly liquid', color: 'text-success' };
}
const rugpullersColumns = [
{
key: 'rank',
label: 'Rank',
render: (value: any, row: any, index: number) => {
const rankInfo = getRankIcon(index);
return {
component: 'rank',
icon: rankInfo.icon,
color: rankInfo.color,
number: index + 1
};
}
},
{
key: 'user',
label: 'User',
render: (value: any, row: any) => ({
component: 'user',
image: row.image,
name: row.name,
username: row.username
})
},
{
key: 'totalExtracted',
label: 'Profit',
class: 'text-success font-mono text-sm font-bold',
render: (value: any) => formatValue(value)
}
];
const losersColumns = [
{
key: 'rank',
label: 'Rank',
render: (value: any, row: any, index: number) => {
const rankInfo = getRankIcon(index);
return {
component: 'rank',
icon: rankInfo.icon,
color: rankInfo.color,
number: index + 1
};
}
},
{
key: 'user',
label: 'User',
render: (value: any, row: any) => ({
component: 'user',
image: row.image,
name: row.name,
username: row.username
})
},
{
key: 'totalLoss',
label: 'Loss',
class: 'text-destructive font-mono text-sm font-bold',
render: (value: any) => `-${formatValue(value)}`
}
];
const cashKingsColumns = [
{
key: 'rank',
label: 'Rank',
render: (value: any, row: any, index: number) => {
const rankInfo = getRankIcon(index);
return {
component: 'rank',
icon: rankInfo.icon,
color: rankInfo.color,
number: index + 1
};
}
},
{
key: 'user',
label: 'User',
render: (value: any, row: any) => ({
component: 'user',
image: row.image,
name: row.name,
username: row.username
})
},
{
key: 'baseCurrencyBalance',
label: 'Cash',
class: 'text-success font-mono text-sm font-bold',
render: (value: any) => formatValue(value)
}
];
const millionairesColumns = [
{
key: 'rank',
label: 'Rank',
render: (value: any, row: any, index: number) => {
const rankInfo = getRankIcon(index);
return {
component: 'rank',
icon: rankInfo.icon,
color: rankInfo.color,
number: index + 1
};
}
},
{
key: 'user',
label: 'User',
render: (value: any, row: any) => ({
component: 'user',
image: row.image,
name: row.name,
username: row.username
})
},
{
key: 'totalPortfolioValue',
label: 'Portfolio',
class: 'text-success font-mono text-sm font-bold',
render: (value: any) => formatValue(value)
},
{
key: 'liquidityRatio',
label: 'Liquidity',
render: (value: any) => {
const info = getLiquidityWarning(value);
return {
component: 'badge',
variant: 'secondary',
class: `text-xs ${info.color}`,
text: info.text
};
}
}
];
</script>
<svelte:head>
@ -100,54 +241,13 @@
</Card.Description>
</Card.Header>
<Card.Content>
{#if leaderboardData.topRugpullers.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">No major profits recorded today</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Rank</Table.Head>
<Table.Head>User</Table.Head>
<Table.Head>Profit</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboardData.topRugpullers as user, index}
{@const rankInfo = getRankIcon(index)}
<Table.Row
class="hover:bg-muted/50 cursor-pointer transition-colors"
onclick={() => goto(`/user/${user.userId}`)}
>
<Table.Cell>
<div class="flex items-center gap-2">
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
<span class="font-mono text-sm">#{index + 1}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6">
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div>
<p class="text-sm font-medium">{user.name}</p>
<p class="text-muted-foreground text-xs">@{user.username}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-success font-mono text-sm font-bold">
{formatValue(user.totalExtracted)}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<DataTable
columns={rugpullersColumns}
data={leaderboardData.topRugpullers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major profits recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
@ -161,56 +261,13 @@
<Card.Description>Users who experienced the largest losses today</Card.Description>
</Card.Header>
<Card.Content>
{#if leaderboardData.biggestLosers.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">
No major losses recorded today
</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Rank</Table.Head>
<Table.Head>User</Table.Head>
<Table.Head>Loss</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboardData.biggestLosers as user, index}
{@const rankInfo = getRankIcon(index)}
<Table.Row
class="hover:bg-muted/50 cursor-pointer transition-colors"
onclick={() => goto(`/user/${user.userId}`)}
>
<Table.Cell>
<div class="flex items-center gap-2">
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
<span class="font-mono text-sm">#{index + 1}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6">
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div>
<p class="text-sm font-medium">{user.name}</p>
<p class="text-muted-foreground text-xs">@{user.username}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-destructive font-mono text-sm font-bold">
-{formatValue(user.totalLoss)}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<DataTable
columns={losersColumns}
data={leaderboardData.biggestLosers}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No major losses recorded today"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
@ -224,54 +281,13 @@
<Card.Description>Users with the highest liquid cash balances</Card.Description>
</Card.Header>
<Card.Content>
{#if leaderboardData.cashKings.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">Everyone's invested! 💸</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Rank</Table.Head>
<Table.Head>User</Table.Head>
<Table.Head>Cash</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboardData.cashKings as user, index}
{@const rankInfo = getRankIcon(index)}
<Table.Row
class="hover:bg-muted/50 cursor-pointer transition-colors"
onclick={() => goto(`/user/${user.userId}`)}
>
<Table.Cell>
<div class="flex items-center gap-2">
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
<span class="font-mono text-sm">#{index + 1}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6">
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div>
<p class="text-sm font-medium">{user.name}</p>
<p class="text-muted-foreground text-xs">@{user.username}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-success font-mono text-sm font-bold">
{formatValue(user.baseCurrencyBalance)}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<DataTable
columns={cashKingsColumns}
data={leaderboardData.cashKings}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="Everyone's invested! 💸"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
@ -287,61 +303,13 @@
>
</Card.Header>
<Card.Content>
{#if leaderboardData.paperMillionaires.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">No large portfolios yet! 📉</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Rank</Table.Head>
<Table.Head>User</Table.Head>
<Table.Head>Portfolio</Table.Head>
<Table.Head>Liquidity</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each leaderboardData.paperMillionaires as user, index}
{@const rankInfo = getRankIcon(index)}
{@const liquidityInfo = getLiquidityWarning(user.liquidityRatio)}
<Table.Row
class="hover:bg-muted/50 cursor-pointer transition-colors"
onclick={() => goto(`/user/${user.userId}`)}
>
<Table.Cell>
<div class="flex items-center gap-2">
<rankInfo.icon class="h-4 w-4 {rankInfo.color}" />
<span class="font-mono text-sm">#{index + 1}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-2">
<Avatar.Root class="h-6 w-6">
<Avatar.Image src={getPublicUrl(user.image)} alt={user.name} />
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs"
>{user.name?.charAt(0) || '?'}</Avatar.Fallback
>
</Avatar.Root>
<div>
<p class="text-sm font-medium">{user.name}</p>
<p class="text-muted-foreground text-xs">@{user.username}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-success font-mono text-sm font-bold">
{formatValue(user.totalPortfolioValue)}
</Table.Cell>
<Table.Cell>
<Badge variant="secondary" class="text-xs {liquidityInfo.color}">
{liquidityInfo.text}
</Badge>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<DataTable
columns={millionairesColumns}
data={leaderboardData.paperMillionaires}
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
emptyMessage="No large portfolios yet! 📉"
enableUserPreview={true}
/>
</Card.Content>
</Card.Root>
</div>

View file

@ -0,0 +1,450 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Avatar from '$lib/components/ui/avatar';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import DataTable from '$lib/components/self/DataTable.svelte';
import ProfileBadges from '$lib/components/self/ProfileBadges.svelte';
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import {
Calendar,
Wallet,
TrendingUp,
TrendingDown,
Coins,
Receipt,
Activity,
RefreshCw
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { UserProfileData } from '$lib/types/user-profile';
let { data } = $props();
const username = data.username;
let profileData = $state<UserProfileData | null>(null);
let loading = $state(true);
onMount(async () => {
await fetchProfileData();
});
async function fetchProfileData() {
try {
const response = await fetch(`/api/user/${username}`);
if (response.ok) {
profileData = await response.json();
} else {
toast.error('Failed to load profile data');
}
} catch (e) {
console.error('Failed to fetch profile data:', e);
toast.error('Failed to load profile data');
} finally {
loading = false;
}
}
let memberSince = $derived(
profileData?.profile
? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})
: ''
);
let hasCreatedCoins = $derived(
profileData?.createdCoins?.length ? profileData.createdCoins.length > 0 : false
);
let totalTradingVolume = $derived(
profileData?.stats
? Number(profileData.stats.totalBuyVolume) + Number(profileData.stats.totalSellVolume)
: 0
);
let buyPercentage = $derived(
profileData?.stats && totalTradingVolume > 0
? (Number(profileData.stats.totalBuyVolume) / totalTradingVolume) * 100
: 0
);
let sellPercentage = $derived(
profileData?.stats && totalTradingVolume > 0
? (Number(profileData.stats.totalSellVolume) / totalTradingVolume) * 100
: 0
);
let totalPortfolioValue = $derived(
profileData?.stats?.totalPortfolioValue ? Number(profileData.stats.totalPortfolioValue) : 0
);
let baseCurrencyBalance = $derived(
profileData?.stats?.baseCurrencyBalance ? Number(profileData.stats.baseCurrencyBalance) : 0
);
let holdingsValue = $derived(
profileData?.stats?.holdingsValue ? Number(profileData.stats.holdingsValue) : 0
);
let totalBuyVolume = $derived(
profileData?.stats?.totalBuyVolume ? Number(profileData.stats.totalBuyVolume) : 0
);
let totalSellVolume = $derived(
profileData?.stats?.totalSellVolume ? Number(profileData.stats.totalSellVolume) : 0
);
let buyVolume24h = $derived(
profileData?.stats?.buyVolume24h ? Number(profileData.stats.buyVolume24h) : 0
);
let sellVolume24h = $derived(
profileData?.stats?.sellVolume24h ? Number(profileData.stats.sellVolume24h) : 0
);
let totalTradingVolumeAllTime = $derived(totalBuyVolume + totalSellVolume);
let totalTradingVolume24h = $derived(buyVolume24h + sellVolume24h);
const createdCoinsColumns = [
{
key: 'coin',
label: 'Coin',
class: 'pl-6 font-medium',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.icon,
symbol: row.symbol,
name: row.name
})
},
{
key: 'currentPrice',
label: 'Price',
class: 'font-mono',
render: (value: any) => `$${formatPrice(parseFloat(value))}`
},
{
key: 'marketCap',
label: 'Market Cap',
class: 'hidden font-mono sm:table-cell',
render: (value: any) => formatValue(parseFloat(value))
},
{
key: 'change24h',
label: '24h Change',
class: 'hidden md:table-cell',
render: (value: any) => ({
component: 'badge',
variant: parseFloat(value) >= 0 ? 'success' : 'destructive',
text: `${parseFloat(value) >= 0 ? '+' : ''}${parseFloat(value).toFixed(2)}%`
})
},
{
key: 'createdAt',
label: 'Created',
class: 'text-muted-foreground hidden text-sm lg:table-cell',
render: (value: any) => formatDate(value)
}
];
const transactionsColumns = [
{
key: 'type',
label: 'Type',
class: 'pl-6',
render: (value: any) => ({
component: 'badge',
variant: value === 'BUY' ? 'success' : 'destructive',
text: value
})
},
{
key: 'coin',
label: 'Coin',
class: 'font-medium',
render: (value: any, row: any) => ({
component: 'coin',
icon: row.coinIcon,
symbol: row.coinSymbol,
name: row.coinName,
size: 6
})
},
{
key: 'quantity',
label: 'Quantity',
class: 'hidden font-mono sm:table-cell',
render: (value: any) => formatQuantity(parseFloat(value))
},
{
key: 'pricePerCoin',
label: 'Price',
class: 'font-mono',
render: (value: any) => `$${formatPrice(parseFloat(value))}`
},
{
key: 'totalBaseCurrencyAmount',
label: 'Total',
class: 'hidden font-mono font-medium md:table-cell',
render: (value: any) => formatValue(parseFloat(value))
},
{
key: 'timestamp',
label: 'Date',
class: 'text-muted-foreground hidden text-sm lg:table-cell',
render: (value: any) => formatDate(value)
}
];
</script>
<svelte:head>
<title
>{profileData?.profile?.name
? `${profileData.profile.name} (@${profileData.profile.username})`
: 'Loading...'} - Rugplay</title
>
<meta
name="description"
content="View {profileData?.profile?.name || 'user'}'s profile and trading activity on Rugplay"
/>
</svelte:head>
<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>
{:else if !profileData}
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="text-muted-foreground mb-4 text-xl">Failed to load profile</div>
<Button onclick={fetchProfileData}>Try Again</Button>
</div>
</div>
{:else}
<!-- Profile Header Card -->
<Card.Root class="mb-6 py-0">
<Card.Content class="p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<!-- Avatar -->
<div class="flex-shrink-0">
<Avatar.Root class="size-20 sm:size-24">
<Avatar.Image
src={getPublicUrl(profileData.profile.image)}
alt={profileData.profile.name}
/>
<Avatar.Fallback class="text-xl"
>{profileData.profile.name.charAt(0).toUpperCase()}</Avatar.Fallback
>
</Avatar.Root>
</div>
<!-- Profile Info -->
<div class="min-w-0 flex-1">
<div class="mb-3">
<div class="mb-1 flex flex-wrap items-center gap-2">
<h1 class="text-2xl font-bold sm:text-3xl">{profileData.profile.name}</h1>
<!-- Badges -->
<ProfileBadges user={profileData.profile} />
</div>
<p class="text-muted-foreground text-lg">@{profileData.profile.username}</p>
</div>
{#if profileData.profile.bio}
<p class="text-muted-foreground mb-3 max-w-2xl leading-relaxed">
{profileData.profile.bio}
</p>
{/if}
<div class="text-muted-foreground flex items-center gap-2 text-sm">
<Calendar class="h-4 w-4" />
<span>Joined {memberSince}</span>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Main Portfolio Stats -->
<div class="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Total Portfolio Value -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">Total Portfolio</div>
<Wallet class="text-muted-foreground h-4 w-4" />
</div>
<div class="mt-1 text-2xl font-bold">
{formatValue(totalPortfolioValue)}
</div>
<p class="text-muted-foreground text-xs">{profileData.stats.holdingsCount} holdings</p>
</Card.Content>
</Card.Root>
<!-- Liquid Value -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">Liquid Value</div>
</div>
<div class="text-success mt-1 text-2xl font-bold">
{formatValue(baseCurrencyBalance)}
</div>
<p class="text-muted-foreground text-xs">Available cash</p>
</Card.Content>
</Card.Root>
<!-- Illiquid Value -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">Illiquid Value</div>
</div>
<div class="text-success mt-1 text-2xl font-bold">
{formatValue(holdingsValue)}
</div>
<p class="text-muted-foreground text-xs">Coin holdings</p>
</Card.Content>
</Card.Root>
<!-- Buy/Sell Ratio -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">Buy/Sell Ratio</div>
<div class="flex gap-1">
<div class="bg-success h-2 w-2 rounded-full"></div>
<div class="h-2 w-2 rounded-full bg-red-500"></div>
</div>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="text-success text-xl font-bold">{buyPercentage.toFixed(1)}%</span>
<span class="text-muted-foreground text-xs">buy</span>
<span class="text-xl font-bold text-red-600">{sellPercentage.toFixed(1)}%</span>
<span class="text-muted-foreground text-xs">sell</span>
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Buy & Sell Activity Breakdown -->
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-4">
<!-- Buy Activity -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-foreground text-sm font-medium">Buy Activity</div>
<TrendingUp class="text-success h-4 w-4" />
</div>
<div class="mt-1">
<div class="text-success text-2xl font-bold">
{formatValue(totalBuyVolume)}
</div>
<div class="text-muted-foreground text-xs">Total amount spent</div>
</div>
<div class="border-muted mt-3 border-t pt-3">
<div class="text-success text-lg font-bold">
{formatValue(buyVolume24h)}
</div>
<div class="text-muted-foreground text-xs">24h buy volume</div>
</div>
</Card.Content>
</Card.Root>
<!-- Sell Activity -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-foreground text-sm font-medium">Sell Activity</div>
<TrendingDown class="h-4 w-4 text-red-600" />
</div>
<div class="mt-1">
<div class="text-2xl font-bold text-red-600">
{formatValue(totalSellVolume)}
</div>
<div class="text-muted-foreground text-xs">Total amount received</div>
</div>
<div class="border-muted mt-3 border-t pt-3">
<div class="text-lg font-bold text-red-600">
{formatValue(sellVolume24h)}
</div>
<div class="text-muted-foreground text-xs">24h sell volume</div>
</div>
</Card.Content>
</Card.Root>
<!-- Total Trading Volume -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">Total Trading Volume</div>
<Badge variant="outline" class="text-xs">All Time</Badge>
</div>
<div class="mt-1 text-2xl font-bold">
{formatValue(totalTradingVolumeAllTime)}
</div>
<div class="text-muted-foreground text-xs">
{profileData.stats.totalTransactions} total trades
</div>
</Card.Content>
</Card.Root>
<!-- 24h Trading Volume -->
<Card.Root class="py-0">
<Card.Content class="p-4">
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-sm font-medium">24h Trading Volume</div>
<Badge variant="outline" class="text-xs">24h</Badge>
</div>
<div class="mt-1 text-2xl font-bold">
{formatValue(totalTradingVolume24h)}
</div>
<div class="text-muted-foreground text-xs">
{profileData.stats.transactions24h || 0} trades today
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Created Coins -->
{#if hasCreatedCoins}
<Card.Root class="mb-6">
<Card.Header class="pb-3">
<Card.Title class="flex items-center gap-2">
<Coins class="h-5 w-5" />
Created Coins ({profileData.createdCoins.length})
</Card.Title>
<Card.Description>Coins launched by {profileData.profile.name}</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<DataTable
columns={createdCoinsColumns}
data={profileData.createdCoins}
onRowClick={(coin) => goto(`/coin/${coin.symbol}`)}
/>
</Card.Content>
</Card.Root>
{/if}
<!-- Recent Trading Activity -->
<Card.Root>
<Card.Header class="pb-3">
<Card.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
Recent Trading Activity
</Card.Title>
<Card.Description>Latest transactions by {profileData.profile.name}</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<DataTable
columns={transactionsColumns}
data={profileData?.recentTransactions || []}
emptyIcon={Receipt}
emptyTitle="No recent activity"
emptyDescription="This user hasn't made any trades yet."
/>
</Card.Content>
</Card.Root>
{/if}
</div>

View file

@ -0,0 +1,5 @@
export async function load({ params }) {
return {
username: params.username
};
}