feat: profile page + 404 page + refactor code

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

View file

@ -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>

View 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}

View 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>

View 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>

View 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>

View file

@ -12,6 +12,7 @@ export interface Comment {
userBio: string | null;
userCreatedAt: string;
userIsAdmin: boolean;
}
export interface CommentLike {

View 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[];
}

View file

@ -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`;