feat: profile page + 404 page + refactor code
This commit is contained in:
parent
3f137e5c3c
commit
d692e86fe0
17 changed files with 1282 additions and 313 deletions
|
|
@ -5,12 +5,13 @@
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { MessageCircle, Send, Loader2, Heart, CalendarDays } from 'lucide-svelte';
|
import { MessageCircle, Send, Loader2, Heart } from 'lucide-svelte';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
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 WebSocket, { type WebSocketHandle } from '$lib/components/self/WebSocket.svelte';
|
||||||
|
|
||||||
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||||
|
|
@ -221,7 +222,7 @@
|
||||||
{#each comments as comment (comment.id)}
|
{#each comments as comment (comment.id)}
|
||||||
<div class="border-border border-b pb-4 last:border-b-0">
|
<div class="border-border border-b pb-4 last:border-b-0">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<button onclick={() => goto(`/user/${comment.userId}`)} class="cursor-pointer">
|
<button onclick={() => goto(`/user/${comment.userUsername}`)} class="cursor-pointer">
|
||||||
<Avatar.Root class="h-8 w-8">
|
<Avatar.Root class="h-8 w-8">
|
||||||
<Avatar.Image src={getPublicUrl(comment.userImage)} alt={comment.userName} />
|
<Avatar.Image src={getPublicUrl(comment.userImage)} alt={comment.userName} />
|
||||||
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
||||||
|
|
@ -232,39 +233,18 @@
|
||||||
<HoverCard.Root>
|
<HoverCard.Root>
|
||||||
<HoverCard.Trigger
|
<HoverCard.Trigger
|
||||||
class="cursor-pointer font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
|
class="cursor-pointer font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
|
||||||
onclick={() => goto(`/user/${comment.userId}`)}
|
onclick={() => goto(`/user/${comment.userUsername}`)}
|
||||||
>
|
>
|
||||||
{comment.userName}
|
{comment.userName}
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
|
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
|
||||||
<div class="flex justify-between space-x-4">
|
<UserProfilePreview userId={comment.userId} />
|
||||||
<Avatar.Root class="h-14 w-14">
|
|
||||||
<Avatar.Image
|
|
||||||
src={getPublicUrl(comment.userImage)}
|
|
||||||
alt={comment.userName}
|
|
||||||
/>
|
|
||||||
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<div class="flex-1 space-y-1">
|
|
||||||
<h4 class="text-sm font-semibold">{comment.userName}</h4>
|
|
||||||
<p class="text-muted-foreground text-sm">@{comment.userUsername}</p>
|
|
||||||
{#if comment.userBio}
|
|
||||||
<p class="text-sm">{comment.userBio}</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(comment.userCreatedAt).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long'
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCard.Content>
|
</HoverCard.Content>
|
||||||
</HoverCard.Root>
|
</HoverCard.Root>
|
||||||
<button onclick={() => goto(`/user/${comment.userId}`)} class="cursor-pointer">
|
<button
|
||||||
|
onclick={() => goto(`/user/${comment.userUsername}`)}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
<Badge variant="outline" class="text-xs">
|
<Badge variant="outline" class="text-xs">
|
||||||
@{comment.userUsername}
|
@{comment.userUsername}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
153
website/src/lib/components/self/DataTable.svelte
Normal file
153
website/src/lib/components/self/DataTable.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import CoinIcon from './CoinIcon.svelte';
|
||||||
|
import UserProfilePreview from './UserProfilePreview.svelte';
|
||||||
|
import { getPublicUrl } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
class?: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
render?: (value: any, row: any, index: number) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onRowClick,
|
||||||
|
emptyMessage = 'No data available',
|
||||||
|
emptyIcon,
|
||||||
|
emptyTitle = 'No data',
|
||||||
|
emptyDescription = '',
|
||||||
|
enableUserPreview = false
|
||||||
|
}: {
|
||||||
|
columns: Column[];
|
||||||
|
data: any[];
|
||||||
|
onRowClick?: (row: any) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
emptyIcon?: any;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
enableUserPreview?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.length === 0}
|
||||||
|
<div class="py-12 text-center">
|
||||||
|
{#if emptyIcon}
|
||||||
|
<div class="bg-muted mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
|
<svelte:component this={emptyIcon} class="text-muted-foreground h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<h3 class="mb-2 text-lg font-semibold">{emptyTitle}</h3>
|
||||||
|
<p class="text-muted-foreground">{emptyDescription || emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#each columns as column}
|
||||||
|
<Table.Head class={column.class}>{column.label}</Table.Head>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each data as row, index}
|
||||||
|
<Table.Row
|
||||||
|
class={onRowClick ? 'hover:bg-muted/50 cursor-pointer transition-colors' : ''}
|
||||||
|
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
>
|
||||||
|
{#each columns as column}
|
||||||
|
<Table.Cell class={column.class}>
|
||||||
|
{#if column.render}
|
||||||
|
{@const rendered = column.render(row[column.key], row, index)}
|
||||||
|
{#if typeof rendered === 'object' && rendered !== null}
|
||||||
|
{#if rendered.component === 'badge'}
|
||||||
|
<Badge variant={rendered.variant} class={rendered.class}>
|
||||||
|
{rendered.text}
|
||||||
|
</Badge>
|
||||||
|
{:else if rendered.component === 'user'}
|
||||||
|
{#if enableUserPreview}
|
||||||
|
<HoverCard.Root>
|
||||||
|
<HoverCard.Trigger>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image
|
||||||
|
src={getPublicUrl(rendered.image)}
|
||||||
|
alt={rendered.name}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
||||||
|
{rendered.name?.charAt(0) || '?'}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{rendered.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<HoverCard.Content class="w-80" side="right" sideOffset={10}>
|
||||||
|
<UserProfilePreview userId={row.userId || row.id} />
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar.Root class="h-6 w-6">
|
||||||
|
<Avatar.Image src={getPublicUrl(rendered.image)} alt={rendered.name} />
|
||||||
|
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
||||||
|
{rendered.name?.charAt(0) || '?'}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{rendered.name}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if rendered.component === 'rank'}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svelte:component this={rendered.icon} class="h-4 w-4 {rendered.color}" />
|
||||||
|
<span class="font-mono text-sm">#{rendered.number}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rendered.component === 'coin'}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<CoinIcon
|
||||||
|
icon={rendered.icon}
|
||||||
|
symbol={rendered.symbol}
|
||||||
|
name={rendered.name}
|
||||||
|
size={rendered.size || 8}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{rendered.name}</div>
|
||||||
|
<div class="text-muted-foreground text-sm">*{rendered.symbol}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if rendered.component === 'link'}
|
||||||
|
<a href={rendered.href} class="flex items-center gap-2 hover:underline">
|
||||||
|
<CoinIcon
|
||||||
|
icon={rendered.content.icon}
|
||||||
|
symbol={rendered.content.symbol}
|
||||||
|
name={rendered.content.name}
|
||||||
|
size={4}
|
||||||
|
/>
|
||||||
|
{rendered.content.name}
|
||||||
|
<span class="text-muted-foreground">(*{rendered.content.symbol})</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{rendered}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{row[column.key]}
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
26
website/src/lib/components/self/ProfileBadges.svelte
Normal file
26
website/src/lib/components/self/ProfileBadges.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { UserProfile } from '$lib/types/user-profile';
|
||||||
|
import SilentBadge from './SilentBadge.svelte';
|
||||||
|
import { Hash, Hammer } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
user,
|
||||||
|
showId = true,
|
||||||
|
size = 'default'
|
||||||
|
}: {
|
||||||
|
user: UserProfile;
|
||||||
|
showId?: boolean;
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#if showId}
|
||||||
|
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
|
||||||
|
{/if}
|
||||||
|
{#if user.isAdmin}
|
||||||
|
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
25
website/src/lib/components/self/SilentBadge.svelte
Normal file
25
website/src/lib/components/self/SilentBadge.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: any;
|
||||||
|
text: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon: Icon, text, class: className }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<div class="cursor-pointer rounded-full p-1 opacity-80 hover:opacity-100 {className}">
|
||||||
|
<Icon class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content
|
||||||
|
class="bg-secondary text-secondary-foreground ring-border ring-1"
|
||||||
|
arrowClasses="bg-secondary"
|
||||||
|
>
|
||||||
|
<p>{text}</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
134
website/src/lib/components/self/UserProfilePreview.svelte
Normal file
134
website/src/lib/components/self/UserProfilePreview.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import ProfileBadges from './ProfileBadges.svelte';
|
||||||
|
import { getPublicUrl, formatValue } from '$lib/utils';
|
||||||
|
import { Calendar, Wallet } from 'lucide-svelte';
|
||||||
|
import type { UserProfileData } from '$lib/types/user-profile';
|
||||||
|
|
||||||
|
let { userId }: { userId: number } = $props();
|
||||||
|
|
||||||
|
let userData = $state<UserProfileData | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state(false);
|
||||||
|
|
||||||
|
async function fetchUserData() {
|
||||||
|
loading = true;
|
||||||
|
error = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/${userId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
userData = await response.json();
|
||||||
|
} else {
|
||||||
|
error = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch user data:', e);
|
||||||
|
error = true;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (userId) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
fetchUserData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let profile = $derived(userData?.profile);
|
||||||
|
let stats = $derived(userData?.stats);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Skeleton class="size-14 shrink-0 rounded-full" />
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Skeleton class="h-5 w-[150px]" />
|
||||||
|
<Skeleton class="h-5 w-[80px]" />
|
||||||
|
</div>
|
||||||
|
<Skeleton class="h-4 w-[130px]" />
|
||||||
|
<Skeleton class="h-4 w-[200px]" />
|
||||||
|
<Skeleton class="h-4 w-[200px]" />
|
||||||
|
<Skeleton class="h-4 w-[200px]" />
|
||||||
|
<Skeleton class="h-4 w-[200px]" />
|
||||||
|
<div class="space-y-1 pt-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Skeleton class="h-4 w-[80px]" />
|
||||||
|
<Skeleton class="h-4 w-[100px]" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Skeleton class="h-4 w-[50px]" />
|
||||||
|
<Skeleton class="h-4 w-[90px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton class="h-4 w-[180px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<span class="text-muted-foreground text-sm">Failed to load profile</span>
|
||||||
|
</div>
|
||||||
|
{:else if profile}
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Avatar.Root class="h-14 w-14 shrink-0">
|
||||||
|
<Avatar.Image src={getPublicUrl(profile.image)} alt={profile.name} />
|
||||||
|
<Avatar.Fallback class="text-sm">{profile.name?.charAt(0) || '?'}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold">{profile.name}</h4>
|
||||||
|
<ProfileBadges user={profile} showId={true} size="sm" />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm">@{profile.username}</p>
|
||||||
|
|
||||||
|
{#if profile.bio}
|
||||||
|
<p class="text-sm">{profile.bio}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
<div class="space-y-1 pt-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Wallet class="h-3 w-3" />
|
||||||
|
Portfolio
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-sm font-medium">
|
||||||
|
{formatValue(stats.totalPortfolioValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Wallet class="h-3 w-3" />
|
||||||
|
Cash
|
||||||
|
</span>
|
||||||
|
<span class="text-success font-mono text-sm font-medium">
|
||||||
|
{formatValue(stats.baseCurrencyBalance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center pt-2">
|
||||||
|
<Calendar class="mr-2 h-4 w-4 opacity-70" />
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
Joined {new Date(profile.createdAt).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -12,6 +12,7 @@ export interface Comment {
|
||||||
|
|
||||||
userBio: string | null;
|
userBio: string | null;
|
||||||
userCreatedAt: string;
|
userCreatedAt: string;
|
||||||
|
userIsAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentLike {
|
export interface CommentLike {
|
||||||
|
|
|
||||||
56
website/src/lib/types/user-profile.ts
Normal file
56
website/src/lib/types/user-profile.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
bio: string | null;
|
||||||
|
image: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
baseCurrencyBalance: number;
|
||||||
|
isAdmin: boolean;
|
||||||
|
totalPortfolioValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
totalPortfolioValue: number;
|
||||||
|
baseCurrencyBalance: number;
|
||||||
|
holdingsValue: number;
|
||||||
|
holdingsCount: number;
|
||||||
|
coinsCreated: number;
|
||||||
|
totalTransactions: number;
|
||||||
|
totalBuyVolume: number;
|
||||||
|
totalSellVolume: number;
|
||||||
|
transactions24h: number;
|
||||||
|
buyVolume24h: number;
|
||||||
|
sellVolume24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedCoin {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
icon: string | null;
|
||||||
|
currentPrice: string;
|
||||||
|
marketCap: string;
|
||||||
|
volume24h: string;
|
||||||
|
change24h: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentTransaction {
|
||||||
|
id: number;
|
||||||
|
type: 'BUY' | 'SELL';
|
||||||
|
coinSymbol: string;
|
||||||
|
coinName: string;
|
||||||
|
coinIcon: string | null;
|
||||||
|
quantity: string;
|
||||||
|
pricePerCoin: string;
|
||||||
|
totalBaseCurrencyAmount: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileData {
|
||||||
|
profile: UserProfile;
|
||||||
|
stats: UserStats;
|
||||||
|
createdCoins: CreatedCoin[];
|
||||||
|
recentTransactions: RecentTransaction[];
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ export function debounce(func: (...args: any[]) => void, wait: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPrice(price: number): string {
|
export function formatPrice(price: number): string {
|
||||||
|
if (typeof price !== 'number' || isNaN(price)) return '$0.00';
|
||||||
if (price < 0.01) {
|
if (price < 0.01) {
|
||||||
return price.toFixed(6);
|
return price.toFixed(6);
|
||||||
} else if (price < 1) {
|
} else if (price < 1) {
|
||||||
|
|
@ -57,14 +58,17 @@ export function formatPrice(price: number): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(value: number): string {
|
export function formatValue(value: number | string): string {
|
||||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
if (typeof numValue !== 'number' || isNaN(numValue)) return '$0.00';
|
||||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
if (numValue >= 1e9) return `$${(numValue / 1e9).toFixed(2)}B`;
|
||||||
return `$${value.toFixed(2)}`;
|
if (numValue >= 1e6) return `$${(numValue / 1e6).toFixed(2)}M`;
|
||||||
|
if (numValue >= 1e3) return `$${(numValue / 1e3).toFixed(2)}K`;
|
||||||
|
return `$${numValue.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatQuantity(value: number): string {
|
export function formatQuantity(value: number): string {
|
||||||
|
if (typeof value !== 'number' || isNaN(value)) return '0';
|
||||||
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||||
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||||
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
||||||
|
|
|
||||||
48
website/src/routes/+error.svelte
Normal file
48
website/src/routes/+error.svelte
Normal 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>
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { getTimeBasedGreeting, formatPrice, formatMarketCap } from '$lib/utils';
|
import { getTimeBasedGreeting, formatPrice, formatMarketCap } from '$lib/utils';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
|
import DataTable from '$lib/components/self/DataTable.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
let shouldSignIn = $state(false);
|
let shouldSignIn = $state(false);
|
||||||
let coins = $state<any[]>([]);
|
let coins = $state<any[]>([]);
|
||||||
|
|
@ -29,6 +30,51 @@
|
||||||
loading = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
@ -102,44 +148,11 @@
|
||||||
<h2 class="mb-4 text-2xl font-bold">Market Overview</h2>
|
<h2 class="mb-4 text-2xl font-bold">Market Overview</h2>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Content class="p-0">
|
<Card.Content class="p-0">
|
||||||
<Table.Root>
|
<DataTable
|
||||||
<Table.Header>
|
columns={marketColumns}
|
||||||
<Table.Row>
|
data={coins}
|
||||||
<Table.Head>Name</Table.Head>
|
onRowClick={(coin) => goto(`/coin/${coin.symbol}`)}
|
||||||
<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>
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ export async function GET({ params, request }) {
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
userImage: user.image,
|
userImage: user.image,
|
||||||
userBio: user.bio,
|
|
||||||
userCreatedAt: user.createdAt,
|
|
||||||
isLikedByUser: session?.user ?
|
isLikedByUser: session?.user ?
|
||||||
sql<boolean>`EXISTS(SELECT 1 FROM ${commentLike} WHERE ${commentLike.userId} = ${session.user.id} AND ${commentLike.commentId} = ${comment.id})` :
|
sql<boolean>`EXISTS(SELECT 1 FROM ${commentLike} WHERE ${commentLike.userId} = ${session.user.id} AND ${commentLike.commentId} = ${comment.id})` :
|
||||||
sql<boolean>`FALSE`
|
sql<boolean>`FALSE`
|
||||||
|
|
@ -109,8 +107,6 @@ export async function POST({ request, params }) {
|
||||||
userName: user.name,
|
userName: user.name,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
userImage: user.image,
|
userImage: user.image,
|
||||||
userBio: user.bio,
|
|
||||||
userCreatedAt: user.createdAt,
|
|
||||||
isLikedByUser: sql<boolean>`FALSE`
|
isLikedByUser: sql<boolean>`FALSE`
|
||||||
})
|
})
|
||||||
.from(comment)
|
.from(comment)
|
||||||
|
|
|
||||||
139
website/src/routes/api/user/[userId]/+server.ts
Normal file
139
website/src/routes/api/user/[userId]/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
import TradeModal from '$lib/components/self/TradeModal.svelte';
|
import TradeModal from '$lib/components/self/TradeModal.svelte';
|
||||||
import CommentSection from '$lib/components/self/CommentSection.svelte';
|
import CommentSection from '$lib/components/self/CommentSection.svelte';
|
||||||
|
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
|
@ -28,13 +29,13 @@
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||||
|
import { getPublicUrl } from '$lib/utils.js';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const coinSymbol = data.coinSymbol;
|
const coinSymbol = data.coinSymbol;
|
||||||
|
|
||||||
let coin = $state<any>(null);
|
let coin = $state<any>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let creatorImageUrl = $state<string | null>(null);
|
|
||||||
let chartData = $state<any[]>([]);
|
let chartData = $state<any[]>([]);
|
||||||
let volumeData = $state<any[]>([]);
|
let volumeData = $state<any[]>([]);
|
||||||
let userHolding = $state(0);
|
let userHolding = $state(0);
|
||||||
|
|
@ -61,15 +62,6 @@
|
||||||
chartData = result.candlestickData || [];
|
chartData = result.candlestickData || [];
|
||||||
volumeData = result.volumeData || [];
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch coin data:', e);
|
console.error('Failed to fetch coin data:', e);
|
||||||
toast.error('Failed to load coin data');
|
toast.error('Failed to load coin data');
|
||||||
|
|
@ -339,37 +331,16 @@
|
||||||
<HoverCard.Root>
|
<HoverCard.Root>
|
||||||
<HoverCard.Trigger
|
<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"
|
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.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.Fallback>{coin.creatorName.charAt(0)}</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<span class="font-medium">{coin.creatorName} (@{coin.creatorUsername})</span>
|
<span class="font-medium">{coin.creatorName} (@{coin.creatorUsername})</span>
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<HoverCard.Content class="w-80" side="bottom" sideOffset={3}>
|
<HoverCard.Content class="w-80" side="bottom" sideOffset={3}>
|
||||||
<div class="flex justify-between space-x-4">
|
<UserProfilePreview userId={coin.creatorId} />
|
||||||
<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>
|
|
||||||
</HoverCard.Content>
|
</HoverCard.Content>
|
||||||
</HoverCard.Root>
|
</HoverCard.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
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 * as Avatar from '$lib/components/ui/avatar';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import DataTable from '$lib/components/self/DataTable.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
@ -53,6 +53,147 @@
|
||||||
if (liquidityRatio < 0.5) return { text: '50%+ illiquid', color: 'text-yellow-600' };
|
if (liquidityRatio < 0.5) return { text: '50%+ illiquid', color: 'text-yellow-600' };
|
||||||
return { text: 'Mostly liquid', color: 'text-success' };
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -100,54 +241,13 @@
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if leaderboardData.topRugpullers.length === 0}
|
<DataTable
|
||||||
<div class="py-8 text-center">
|
columns={rugpullersColumns}
|
||||||
<p class="text-muted-foreground">No major profits recorded today</p>
|
data={leaderboardData.topRugpullers}
|
||||||
</div>
|
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
|
||||||
{:else}
|
emptyMessage="No major profits recorded today"
|
||||||
<Table.Root>
|
enableUserPreview={true}
|
||||||
<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}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
@ -161,56 +261,13 @@
|
||||||
<Card.Description>Users who experienced the largest losses today</Card.Description>
|
<Card.Description>Users who experienced the largest losses today</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if leaderboardData.biggestLosers.length === 0}
|
<DataTable
|
||||||
<div class="py-8 text-center">
|
columns={losersColumns}
|
||||||
<p class="text-muted-foreground">
|
data={leaderboardData.biggestLosers}
|
||||||
No major losses recorded today
|
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
|
||||||
</p>
|
emptyMessage="No major losses recorded today"
|
||||||
</div>
|
enableUserPreview={true}
|
||||||
{: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}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
@ -224,54 +281,13 @@
|
||||||
<Card.Description>Users with the highest liquid cash balances</Card.Description>
|
<Card.Description>Users with the highest liquid cash balances</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if leaderboardData.cashKings.length === 0}
|
<DataTable
|
||||||
<div class="py-8 text-center">
|
columns={cashKingsColumns}
|
||||||
<p class="text-muted-foreground">Everyone's invested! 💸</p>
|
data={leaderboardData.cashKings}
|
||||||
</div>
|
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
|
||||||
{:else}
|
emptyMessage="Everyone's invested! 💸"
|
||||||
<Table.Root>
|
enableUserPreview={true}
|
||||||
<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}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
@ -287,61 +303,13 @@
|
||||||
>
|
>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if leaderboardData.paperMillionaires.length === 0}
|
<DataTable
|
||||||
<div class="py-8 text-center">
|
columns={millionairesColumns}
|
||||||
<p class="text-muted-foreground">No large portfolios yet! 📉</p>
|
data={leaderboardData.paperMillionaires}
|
||||||
</div>
|
onRowClick={(user) => goto(`/user/${user.userUsername || user.username}`)}
|
||||||
{:else}
|
emptyMessage="No large portfolios yet! 📉"
|
||||||
<Table.Root>
|
enableUserPreview={true}
|
||||||
<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}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
450
website/src/routes/user/[username]/+page.svelte
Normal file
450
website/src/routes/user/[username]/+page.svelte
Normal 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>
|
||||||
5
website/src/routes/user/[username]/+page.ts
Normal file
5
website/src/routes/user/[username]/+page.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export async function load({ params }) {
|
||||||
|
return {
|
||||||
|
username: params.username
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
website/static/404.gif
Normal file
BIN
website/static/404.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 590 KiB |
Reference in a new issue