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 HoverCard from '$lib/components/ui/hover-card';
|
||||
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 { toast } from 'svelte-sonner';
|
||||
import { goto } from '$app/navigation';
|
||||
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
import UserProfilePreview from '$lib/components/self/UserProfilePreview.svelte';
|
||||
import WebSocket, { type WebSocketHandle } from '$lib/components/self/WebSocket.svelte';
|
||||
|
||||
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||
|
|
@ -221,7 +222,7 @@
|
|||
{#each comments as comment (comment.id)}
|
||||
<div class="border-border border-b pb-4 last:border-b-0">
|
||||
<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.Image src={getPublicUrl(comment.userImage)} alt={comment.userName} />
|
||||
<Avatar.Fallback>{comment.userName?.charAt(0) || '?'}</Avatar.Fallback>
|
||||
|
|
@ -232,39 +233,18 @@
|
|||
<HoverCard.Root>
|
||||
<HoverCard.Trigger
|
||||
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}
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80" side="top" sideOffset={3}>
|
||||
<div class="flex justify-between space-x-4">
|
||||
<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>
|
||||
<UserProfilePreview userId={comment.userId} />
|
||||
</HoverCard.Content>
|
||||
</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">
|
||||
@{comment.userUsername}
|
||||
</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;
|
||||
userCreatedAt: string;
|
||||
userIsAdmin: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (typeof price !== 'number' || isNaN(price)) return '$0.00';
|
||||
if (price < 0.01) {
|
||||
return price.toFixed(6);
|
||||
} else if (price < 1) {
|
||||
|
|
@ -57,14 +58,17 @@ export function formatPrice(price: number): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
export function formatValue(value: number | string): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (typeof numValue !== 'number' || isNaN(numValue)) return '$0.00';
|
||||
if (numValue >= 1e9) return `$${(numValue / 1e9).toFixed(2)}B`;
|
||||
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 {
|
||||
if (typeof value !== 'number' || isNaN(value)) return '0';
|
||||
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
|
||||
|
|
|
|||
Reference in a new issue