feat: add username availability check API endpoint
feat: create user image retrieval API endpoint feat: enhance coin page with dynamic data fetching and improved UI feat: implement coin creation form with validation and submission logic feat: add user settings page with profile update functionality
This commit is contained in:
parent
9aa4ba157b
commit
16ad425bb5
48 changed files with 3030 additions and 326 deletions
|
|
@ -1,6 +1,4 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@
|
|||
import '../app.css';
|
||||
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { Toaster } from '$lib/components/ui/sonner';
|
||||
|
||||
import AppSidebar from '$lib/components/self/AppSidebar.svelte';
|
||||
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data, children } = $props<{
|
||||
data: { userSession?: any };
|
||||
|
|
@ -70,6 +73,7 @@
|
|||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<Toaster richColors={true} />
|
||||
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
|
|
@ -81,7 +85,13 @@
|
|||
<div class="flex w-full items-center gap-4 px-4 lg:px-6">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
|
||||
<h1 class="mr-6 text-base font-medium">test</h1>
|
||||
<h1 class="mr-6 text-base font-medium">
|
||||
{#if page.route.id === '/coin/create'}
|
||||
Coin: Create
|
||||
{:else}
|
||||
test
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { coins } from '$lib/data/coins';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { getTimeBasedGreeting } from '$lib/utils';
|
||||
import { getTimeBasedGreeting, getPublicUrl } from '$lib/utils';
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let shouldSignIn = $state(false);
|
||||
let coins = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/coins/top');
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
coins = result.coins;
|
||||
} else {
|
||||
toast.error('Failed to load coins');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch coins:', e);
|
||||
toast.error('Failed to load coins');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (price < 0.01) {
|
||||
return price.toFixed(6);
|
||||
} else if (price < 1) {
|
||||
return price.toFixed(4);
|
||||
} else {
|
||||
return price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatMarketCap(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)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
|
@ -34,83 +74,105 @@
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each coins as coin}
|
||||
<a href={`/coin/${coin.symbol}`} class="block">
|
||||
<Card.Root class="h-full transition-shadow hover:shadow-md">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center justify-between">
|
||||
<span>{coin.name} ({coin.symbol})</span>
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="ml-2">
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h}%
|
||||
</Badge>
|
||||
</Card.Title>
|
||||
<Card.Description
|
||||
>Market Cap: ${(coin.marketCap / 1000000000).toFixed(2)}B</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-3xl font-bold"
|
||||
>${coin.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: coin.price < 1 ? 3 : 2,
|
||||
maximumFractionDigits: coin.price < 1 ? 3 : 2
|
||||
})}</span
|
||||
>
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>24h Vol: ${(coin.volume24h / 1000000000).toFixed(2)}B</span
|
||||
>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-xl">Loading market data...</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if coins.length === 0}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground mb-4 text-xl">No coins available</div>
|
||||
<p class="text-muted-foreground text-sm">Be the first to create a coin!</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each coins.slice(0, 6) as coin}
|
||||
<a href={`/coin/${coin.symbol}`} class="block">
|
||||
<Card.Root class="h-full transition-shadow hover:shadow-md">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if coin.icon}
|
||||
<img
|
||||
src={getPublicUrl(coin.icon)}
|
||||
alt={coin.name}
|
||||
class="h-6 w-6 rounded-full"
|
||||
/>
|
||||
{/if}
|
||||
<span>{coin.name} (*{coin.symbol})</span>
|
||||
</div>
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="ml-2">
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
|
||||
</Badge>
|
||||
</Card.Title>
|
||||
<Card.Description>Market Cap: {formatMarketCap(coin.marketCap)}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-3xl font-bold">${formatPrice(coin.price)}</span>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
24h Vol: {formatMarketCap(coin.volume24h)}
|
||||
</span>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 text-2xl font-bold">Market Overview</h2>
|
||||
<Card.Root>
|
||||
<Card.Content class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Price</Table.Head>
|
||||
<Table.Head>24h Change</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Market Cap</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Volume (24h)</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each coins as coin}
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 text-2xl font-bold">Market Overview</h2>
|
||||
<Card.Root>
|
||||
<Card.Content class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a href={`/coin/${coin.symbol}`} class="hover:underline">
|
||||
{coin.name} <span class="text-muted-foreground">({coin.symbol})</span>
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
>${coin.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: coin.price < 1 ? 3 : 2,
|
||||
maximumFractionDigits: coin.price < 1 ? 3 : 2
|
||||
})}</Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'}>
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h}%
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="hidden md:table-cell"
|
||||
>${(coin.marketCap / 1000000000).toFixed(2)}B</Table.Cell
|
||||
>
|
||||
<Table.Cell class="hidden md:table-cell"
|
||||
>${(coin.volume24h / 1000000000).toFixed(2)}B</Table.Cell
|
||||
>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Price</Table.Head>
|
||||
<Table.Head>24h Change</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Market Cap</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Volume (24h)</Table.Head>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
{#if coin.icon}
|
||||
<img
|
||||
src={getPublicUrl(coin.icon)}
|
||||
alt={coin.name}
|
||||
class="h-4 w-4 rounded-full"
|
||||
/>
|
||||
{/if}
|
||||
{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.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
73
website/src/routes/api/coin/[coinSymbol]/+server.ts
Normal file
73
website/src/routes/api/coin/[coinSymbol]/+server.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin, user, priceHistory } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { coinSymbol } = params;
|
||||
|
||||
if (!coinSymbol) {
|
||||
throw error(400, 'Coin symbol is required');
|
||||
}
|
||||
|
||||
const normalizedSymbol = coinSymbol.toUpperCase();
|
||||
|
||||
const [coinData] = await db
|
||||
.select({
|
||||
id: coin.id,
|
||||
name: coin.name,
|
||||
symbol: coin.symbol,
|
||||
creatorId: coin.creatorId,
|
||||
creatorName: user.name,
|
||||
creatorUsername: user.username,
|
||||
creatorBio: user.bio,
|
||||
creatorImage: user.image,
|
||||
initialSupply: coin.initialSupply,
|
||||
circulatingSupply: coin.circulatingSupply,
|
||||
currentPrice: coin.currentPrice,
|
||||
marketCap: coin.marketCap,
|
||||
icon: coin.icon,
|
||||
volume24h: coin.volume24h,
|
||||
change24h: coin.change24h,
|
||||
poolCoinAmount: coin.poolCoinAmount,
|
||||
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||
createdAt: coin.createdAt,
|
||||
isListed: coin.isListed
|
||||
})
|
||||
.from(coin)
|
||||
.leftJoin(user, eq(coin.creatorId, user.id))
|
||||
.where(eq(coin.symbol, normalizedSymbol))
|
||||
.limit(1);
|
||||
|
||||
if (!coinData) {
|
||||
throw error(404, 'Coin not found');
|
||||
}
|
||||
|
||||
const priceHistoryData = await db
|
||||
.select({
|
||||
price: priceHistory.price,
|
||||
timestamp: priceHistory.timestamp
|
||||
})
|
||||
.from(priceHistory)
|
||||
.where(eq(priceHistory.coinId, coinData.id))
|
||||
.orderBy(desc(priceHistory.timestamp))
|
||||
.limit(720);
|
||||
|
||||
return json({
|
||||
coin: {
|
||||
...coinData,
|
||||
currentPrice: Number(coinData.currentPrice),
|
||||
marketCap: Number(coinData.marketCap),
|
||||
volume24h: Number(coinData.volume24h || 0),
|
||||
change24h: Number(coinData.change24h || 0),
|
||||
initialSupply: Number(coinData.initialSupply),
|
||||
circulatingSupply: Number(coinData.circulatingSupply),
|
||||
poolCoinAmount: Number(coinData.poolCoinAmount),
|
||||
poolBaseCurrencyAmount: Number(coinData.poolBaseCurrencyAmount)
|
||||
},
|
||||
priceHistory: priceHistoryData.map(p => ({
|
||||
price: Number(p.price),
|
||||
timestamp: p.timestamp
|
||||
}))
|
||||
});
|
||||
}
|
||||
143
website/src/routes/api/coin/create/+server.ts
Normal file
143
website/src/routes/api/coin/create/+server.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin, userPortfolio, user, priceHistory } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { uploadCoinIcon } from '$lib/server/s3';
|
||||
import { CREATION_FEE, FIXED_SUPPLY, STARTING_PRICE, INITIAL_LIQUIDITY, TOTAL_COST, MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
|
||||
function validateInputs(name: string, symbol: string, iconFile: File | null) {
|
||||
if (!name || name.length < 2 || name.length > 255) {
|
||||
throw error(400, 'Name must be between 2 and 255 characters');
|
||||
}
|
||||
|
||||
if (!symbol || symbol.length < 2 || symbol.length > 10) {
|
||||
throw error(400, 'Symbol must be between 2 and 10 characters');
|
||||
}
|
||||
|
||||
if (iconFile && iconFile.size > MAX_FILE_SIZE) {
|
||||
throw error(400, 'Icon file must be smaller than 1MB');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUserBalance(userId: number) {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
if (currentBalance < TOTAL_COST) {
|
||||
throw error(400, `Insufficient funds. You need $${TOTAL_COST.toFixed(2)} but only have $${currentBalance.toFixed(2)}.`);
|
||||
}
|
||||
|
||||
return currentBalance;
|
||||
}
|
||||
|
||||
async function validateSymbolUnique(symbol: string) {
|
||||
const existingCoin = await db.select().from(coin).where(eq(coin.symbol, symbol)).limit(1);
|
||||
if (existingCoin.length > 0) {
|
||||
throw error(400, 'A coin with this symbol already exists');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIconUpload(iconFile: File | null, symbol: string): Promise<string | null> {
|
||||
if (!iconFile || iconFile.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const arrayBuffer = await iconFile.arrayBuffer();
|
||||
return await uploadCoinIcon(
|
||||
symbol,
|
||||
new Uint8Array(arrayBuffer),
|
||||
iconFile.type,
|
||||
iconFile.size
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const symbol = formData.get('symbol') as string;
|
||||
const iconFile = formData.get('icon') as File | null;
|
||||
|
||||
const normalizedSymbol = symbol?.toUpperCase();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
validateInputs(name, normalizedSymbol, iconFile);
|
||||
|
||||
const [currentBalance] = await Promise.all([
|
||||
validateUserBalance(userId),
|
||||
validateSymbolUnique(normalizedSymbol)
|
||||
]);
|
||||
|
||||
let iconKey: string | null = null;
|
||||
try {
|
||||
iconKey = await handleIconUpload(iconFile, normalizedSymbol);
|
||||
} catch (e) {
|
||||
console.error('Icon upload failed, continuing without icon:', e);
|
||||
}
|
||||
|
||||
let createdCoin: any;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: (currentBalance - TOTAL_COST).toString(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
const [newCoin] = await tx.insert(coin).values({
|
||||
name,
|
||||
symbol: normalizedSymbol,
|
||||
icon: iconKey,
|
||||
creatorId: userId,
|
||||
initialSupply: FIXED_SUPPLY.toString(),
|
||||
circulatingSupply: FIXED_SUPPLY.toString(),
|
||||
currentPrice: STARTING_PRICE.toString(),
|
||||
marketCap: (FIXED_SUPPLY * STARTING_PRICE).toString(),
|
||||
poolCoinAmount: FIXED_SUPPLY.toString(),
|
||||
poolBaseCurrencyAmount: INITIAL_LIQUIDITY.toString()
|
||||
}).returning();
|
||||
|
||||
createdCoin = newCoin;
|
||||
|
||||
await tx.insert(userPortfolio).values({
|
||||
userId,
|
||||
coinId: newCoin.id,
|
||||
quantity: FIXED_SUPPLY.toString()
|
||||
});
|
||||
|
||||
await tx.insert(priceHistory).values({
|
||||
coinId: newCoin.id,
|
||||
price: STARTING_PRICE.toString()
|
||||
});
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
coin: {
|
||||
id: createdCoin.id,
|
||||
name: createdCoin.name,
|
||||
symbol: createdCoin.symbol,
|
||||
icon: createdCoin.icon
|
||||
},
|
||||
feePaid: CREATION_FEE,
|
||||
liquidityDeposited: INITIAL_LIQUIDITY,
|
||||
initialPrice: STARTING_PRICE,
|
||||
supply: FIXED_SUPPLY
|
||||
});
|
||||
}
|
||||
37
website/src/routes/api/coins/top/+server.ts
Normal file
37
website/src/routes/api/coins/top/+server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { coin } from '$lib/server/db/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
const topCoins = 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,
|
||||
isListed: coin.isListed
|
||||
})
|
||||
.from(coin)
|
||||
.where(eq(coin.isListed, true))
|
||||
.orderBy(desc(coin.marketCap))
|
||||
.limit(20);
|
||||
|
||||
return json({
|
||||
coins: topCoins.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
symbol: c.symbol,
|
||||
icon: c.icon,
|
||||
price: Number(c.currentPrice),
|
||||
marketCap: Number(c.marketCap),
|
||||
volume24h: Number(c.volume24h || 0),
|
||||
change24h: Number(c.change24h || 0),
|
||||
isListed: c.isListed
|
||||
}))
|
||||
});
|
||||
}
|
||||
62
website/src/routes/api/portfolio/total/+server.ts
Normal file
62
website/src/routes/api/portfolio/total/+server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, userPortfolio, coin } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const holdings = await db
|
||||
.select({
|
||||
quantity: userPortfolio.quantity,
|
||||
currentPrice: coin.currentPrice,
|
||||
symbol: coin.symbol
|
||||
})
|
||||
.from(userPortfolio)
|
||||
.innerJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||
.where(eq(userPortfolio.userId, userId));
|
||||
|
||||
let totalCoinValue = 0;
|
||||
const coinHoldings = holdings.map(holding => {
|
||||
const quantity = Number(holding.quantity);
|
||||
const price = Number(holding.currentPrice);
|
||||
const value = quantity * price;
|
||||
totalCoinValue += value;
|
||||
|
||||
return {
|
||||
symbol: holding.symbol,
|
||||
quantity,
|
||||
currentPrice: price,
|
||||
value
|
||||
};
|
||||
});
|
||||
|
||||
const baseCurrencyBalance = Number(userData.baseCurrencyBalance);
|
||||
|
||||
return json({
|
||||
baseCurrencyBalance,
|
||||
totalCoinValue,
|
||||
totalValue: baseCurrencyBalance + totalCoinValue,
|
||||
coinHoldings,
|
||||
currency: '$'
|
||||
});
|
||||
}
|
||||
71
website/src/routes/api/settings/+server.ts
Normal file
71
website/src/routes/api/settings/+server.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { uploadProfilePicture } from '$lib/server/s3';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
|
||||
function validateInputs(name: string, bio: string, username: string, avatarFile: File | null) {
|
||||
if (name && name.length < 1) {
|
||||
throw error(400, 'Name cannot be empty');
|
||||
}
|
||||
|
||||
if (bio && bio.length > 160) {
|
||||
throw error(400, 'Bio must be 160 characters or less');
|
||||
}
|
||||
|
||||
if (username && (username.length < 3 || username.length > 30)) {
|
||||
throw error(400, 'Username must be between 3 and 30 characters');
|
||||
}
|
||||
|
||||
if (avatarFile && avatarFile.size > MAX_FILE_SIZE) {
|
||||
throw error(400, 'Avatar file must be smaller than 1MB');
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const bio = formData.get('bio') as string;
|
||||
const username = formData.get('username') as string;
|
||||
const avatarFile = formData.get('avatar') as File | null;
|
||||
|
||||
validateInputs(name, bio, username, avatarFile);
|
||||
|
||||
const updates: Record<string, any> = {
|
||||
name,
|
||||
bio,
|
||||
username,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (avatarFile && avatarFile.size > 0) {
|
||||
try {
|
||||
const arrayBuffer = await avatarFile.arrayBuffer();
|
||||
const key = await uploadProfilePicture(
|
||||
session.user.id,
|
||||
new Uint8Array(arrayBuffer),
|
||||
avatarFile.type,
|
||||
avatarFile.size
|
||||
);
|
||||
updates.image = key;
|
||||
} catch (e) {
|
||||
console.error('Avatar upload failed, continuing without update:', e);
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(user)
|
||||
.set(updates)
|
||||
.where(eq(user.id, Number(session.user.id)));
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
17
website/src/routes/api/settings/check-username/+server.ts
Normal file
17
website/src/routes/api/settings/check-username/+server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const username = url.searchParams.get('username');
|
||||
if (!username) {
|
||||
return json({ available: false });
|
||||
}
|
||||
|
||||
const exists = await db.query.user.findFirst({
|
||||
where: eq(user.username, username)
|
||||
});
|
||||
|
||||
return json({ available: !exists });
|
||||
}
|
||||
28
website/src/routes/api/user/[userId]/image/+server.ts
Normal file
28
website/src/routes/api/user/[userId]/image/+server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getPublicUrl } from '$lib/utils';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { userId } = params;
|
||||
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, Number(userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userData) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
const url = getPublicUrl(userData.image);
|
||||
|
||||
return json({ url });
|
||||
} catch (e) {
|
||||
console.error('Failed to get user image:', e);
|
||||
throw error(500, 'Failed to get user image');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +1,452 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { coins } from '$lib/data/coins';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { createChart, CandlestickSeries, type Time, ColorType } from 'lightweight-charts';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Coins,
|
||||
ChartColumn,
|
||||
CalendarDays
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
createChart,
|
||||
ColorType,
|
||||
type Time,
|
||||
type IChartApi,
|
||||
CandlestickSeries
|
||||
} from 'lightweight-charts';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getPublicUrl } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const coin = coins.find((c) => c.symbol === $page.params.coinSymbol);
|
||||
const { data } = $props();
|
||||
const coinSymbol = data.coinSymbol;
|
||||
|
||||
// Generate mock candlestick data
|
||||
const candleData = Array.from({ length: 30 }, (_, i) => {
|
||||
const basePrice = coin?.price || 100;
|
||||
const date = new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000);
|
||||
const open = basePrice * (1 + Math.sin(i / 5) * 0.1);
|
||||
const close = basePrice * (1 + Math.sin((i + 1) / 5) * 0.1);
|
||||
const high = Math.max(open, close) * (1 + Math.random() * 0.02);
|
||||
const low = Math.min(open, close) * (1 - Math.random() * 0.02);
|
||||
let coin = $state<any>(null);
|
||||
let priceHistory = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let creatorImageUrl = $state<string | null>(null);
|
||||
let chartData = $state<any[]>([]);
|
||||
|
||||
return {
|
||||
time: Math.floor(date.getTime() / 1000) as Time,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close
|
||||
};
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/coin/${coinSymbol}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
toast.error('Coin not found');
|
||||
} else {
|
||||
toast.error('Failed to load coin data');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
coin = result.coin;
|
||||
priceHistory = result.priceHistory;
|
||||
chartData = generateCandlesticksFromHistory(priceHistory);
|
||||
|
||||
if (coin.creatorId) {
|
||||
try {
|
||||
const imageResponse = await fetch(`/api/user/${coin.creatorId}/image`);
|
||||
const imageResult = await imageResponse.json();
|
||||
creatorImageUrl = imageResult.url;
|
||||
} catch (e) {
|
||||
console.error('Failed to load creator image:', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch coin data:', e);
|
||||
toast.error('Failed to load coin data');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
let chartContainer: HTMLDivElement;
|
||||
function generateCandlesticksFromHistory(history: any[]) {
|
||||
const dailyData = new Map();
|
||||
|
||||
onMount(() => {
|
||||
const chart = createChart(chartContainer, {
|
||||
layout: {
|
||||
textColor: '#666666',
|
||||
background: { type: ColorType.Solid, color: 'transparent' },
|
||||
attributionLogo: false
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#2B2B43' },
|
||||
horzLines: { color: '#2B2B43' }
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderVisible: false
|
||||
},
|
||||
timeScale: {
|
||||
borderVisible: false,
|
||||
timeVisible: true
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1
|
||||
history.forEach((p) => {
|
||||
const date = new Date(p.timestamp);
|
||||
const dayKey = Math.floor(date.getTime() / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (!dailyData.has(dayKey)) {
|
||||
dailyData.set(dayKey, {
|
||||
time: dayKey * 24 * 60 * 60,
|
||||
open: p.price,
|
||||
high: p.price,
|
||||
low: p.price,
|
||||
close: p.price,
|
||||
prices: [p.price]
|
||||
});
|
||||
} else {
|
||||
const dayData = dailyData.get(dayKey);
|
||||
dayData.high = Math.max(dayData.high, p.price);
|
||||
dayData.low = Math.min(dayData.low, p.price);
|
||||
dayData.close = p.price;
|
||||
dayData.prices.push(p.price);
|
||||
}
|
||||
});
|
||||
|
||||
const candlesticks = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350'
|
||||
});
|
||||
return Array.from(dailyData.values())
|
||||
.map((d) => ({
|
||||
time: d.time as Time,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close
|
||||
}))
|
||||
.sort((a, b) => (a.time as number) - (b.time as number));
|
||||
}
|
||||
|
||||
candlesticks.setData(candleData);
|
||||
chart.timeScale().fitContent();
|
||||
let chartContainer = $state<HTMLDivElement>();
|
||||
let chart: IChartApi | null = null;
|
||||
|
||||
const handleResize = () => {
|
||||
chart.applyOptions({
|
||||
width: chartContainer.clientWidth
|
||||
$effect(() => {
|
||||
if (chartContainer && chartData.length > 0 && !chart) {
|
||||
chart = createChart(chartContainer, {
|
||||
layout: {
|
||||
textColor: '#666666',
|
||||
background: { type: ColorType.Solid, color: 'transparent' },
|
||||
attributionLogo: false
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#2B2B43' },
|
||||
horzLines: { color: '#2B2B43' }
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderVisible: false
|
||||
},
|
||||
timeScale: {
|
||||
borderVisible: false,
|
||||
timeVisible: true
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350'
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart.remove();
|
||||
};
|
||||
candlestickSeries.setData(chartData);
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const handleResize = () => {
|
||||
chart?.applyOptions({
|
||||
width: chartContainer?.clientWidth
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (price < 0.01) {
|
||||
return price.toFixed(6);
|
||||
} else if (price < 1) {
|
||||
return price.toFixed(4);
|
||||
} else {
|
||||
return price.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMarketCap(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)}`;
|
||||
}
|
||||
|
||||
function formatSupply(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.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-6">
|
||||
{#if coin}
|
||||
<header class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold">{coin.name} ({coin.symbol})</h1>
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="text-lg">
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h}%
|
||||
</Badge>
|
||||
<svelte:head>
|
||||
<title>{coin ? `${coin.name} (${coin.symbol})` : 'Loading...'} - Rugplay</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="container mx-auto max-w-7xl p-6">
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-xl">Loading coin data...</div>
|
||||
</div>
|
||||
<p class="mt-4 text-3xl font-semibold">
|
||||
${coin.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: coin.price < 1 ? 3 : 2,
|
||||
maximumFractionDigits: coin.price < 1 ? 3 : 2
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !coin}
|
||||
<div class="container mx-auto max-w-7xl p-6">
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground mb-4 text-xl">Coin not found</div>
|
||||
<Button onclick={() => goto('/')}>Go Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="container mx-auto max-w-7xl p-6">
|
||||
<!-- Header Section -->
|
||||
<header class="mb-8">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="bg-muted/50 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full border"
|
||||
>
|
||||
{#if coin.icon}
|
||||
<img
|
||||
src={getPublicUrl(coin.icon)}
|
||||
alt={coin.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-xl font-bold text-white"
|
||||
>
|
||||
{coin.symbol.slice(0, 2)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold">{coin.name}</h1>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" class="text-lg">*{coin.symbol}</Badge>
|
||||
{#if !coin.isListed}
|
||||
<Badge variant="destructive">Delisted</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-3xl font-bold">
|
||||
${formatPrice(coin.currentPrice)}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
{#if coin.change24h >= 0}
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
{:else}
|
||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||
{/if}
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'}>
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Info -->
|
||||
{#if coin.creatorName}
|
||||
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<span>Created by</span>
|
||||
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-8"
|
||||
onclick={() => goto(`/user/${coin.creatorId}`)}
|
||||
>
|
||||
<Avatar.Root class="h-4 w-4">
|
||||
<Avatar.Image src={creatorImageUrl} alt={coin.creatorName} />
|
||||
<Avatar.Fallback>{coin.creatorName.charAt(0)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span class="font-medium">{coin.creatorName} (@{coin.creatorUsername})</span>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80" side="bottom" sideOffset={3}>
|
||||
<div class="flex justify-between space-x-4">
|
||||
<Avatar.Root class="h-14 w-14">
|
||||
<Avatar.Image src={creatorImageUrl} alt={coin.creatorName} />
|
||||
<Avatar.Fallback>{coin.creatorName.charAt(0)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex-1 space-y-1">
|
||||
<h4 class="text-sm font-semibold">{coin.creatorName}</h4>
|
||||
<p class="text-muted-foreground text-sm">@{coin.creatorUsername}</p>
|
||||
{#if coin.creatorBio}
|
||||
<p class="text-sm">{coin.creatorBio}</p>
|
||||
{/if}
|
||||
<div class="flex items-center pt-2">
|
||||
<CalendarDays class="mr-2 h-4 w-4 opacity-70" />
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Joined {new Date(coin.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Price Chart</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="h-[400px] w-full" bind:this={chartContainer}></div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<!-- Price Chart with Trading Actions -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Chart (2/3 width) -->
|
||||
<div class="lg:col-span-2">
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<ChartColumn class="h-5 w-5" />
|
||||
Price Chart
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<div class="h-[400px] w-full" bind:this={chartContainer}></div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- Right side - Trading Actions + Liquidity Pool (1/3 width) -->
|
||||
<div class="space-y-6 lg:col-span-1">
|
||||
<!-- Trading Actions -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Title>Trade {coin.symbol}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<div class="space-y-3">
|
||||
<Button class="w-full" variant="default" size="lg">
|
||||
<TrendingUp class="mr-2 h-4 w-4" />
|
||||
Buy {coin.symbol}
|
||||
</Button>
|
||||
<Button class="w-full" variant="outline" size="lg">
|
||||
<TrendingDown class="mr-2 h-4 w-4" />
|
||||
Sell {coin.symbol}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Liquidity Pool -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-4">
|
||||
<Card.Title>Liquidity Pool</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Pool Composition</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground text-sm">{coin.symbol}:</span>
|
||||
<span class="font-mono text-sm">{formatSupply(coin.poolCoinAmount)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground text-sm">Base Currency:</span>
|
||||
<span class="font-mono text-sm"
|
||||
>${coin.poolBaseCurrencyAmount.toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Pool Stats</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground text-sm">Total Liquidity:</span>
|
||||
<span class="font-mono text-sm"
|
||||
>${(coin.poolBaseCurrencyAmount * 2).toLocaleString()}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground text-sm">Price Impact:</span>
|
||||
<Badge variant="success" class="text-xs">Low</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Grid -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Market Cap -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Market Cap</Card.Title>
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||
<DollarSign class="h-4 w-4" />
|
||||
Market Cap
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-2xl font-semibold">${(coin.marketCap / 1000000000).toFixed(2)}B</p>
|
||||
<Card.Content class="pt-0">
|
||||
<p class="text-xl font-bold">{formatMarketCap(coin.marketCap)}</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 24h Volume -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>24h Volume</Card.Title>
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||
<ChartColumn class="h-4 w-4" />
|
||||
24h Volume
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-2xl font-semibold">${(coin.volume24h / 1000000000).toFixed(2)}B</p>
|
||||
<Card.Content class="pt-0">
|
||||
<p class="text-xl font-bold">{formatMarketCap(coin.volume24h)}</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Circulating Supply -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>24h Change</Card.Title>
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||
<Coins class="h-4 w-4" />
|
||||
Circulating Supply
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-2xl font-semibold">
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="text-lg">
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h}%
|
||||
</Badge>
|
||||
<Card.Content class="pt-0">
|
||||
<p class="text-xl font-bold">{formatSupply(coin.circulatingSupply)}</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
of {formatSupply(coin.initialSupply)} total
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 24h Change -->
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="text-sm font-medium">24h Change</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if coin.change24h >= 0}
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
{:else}
|
||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||
{/if}
|
||||
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="text-sm">
|
||||
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Coin not found</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
5
website/src/routes/coin/[coinSymbol]/+page.ts
Normal file
5
website/src/routes/coin/[coinSymbol]/+page.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export async function load({ params }) {
|
||||
return {
|
||||
coinSymbol: params.coinSymbol
|
||||
};
|
||||
}
|
||||
329
website/src/routes/coin/create/+page.svelte
Normal file
329
website/src/routes/coin/create/+page.svelte
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Alert, AlertDescription } from '$lib/components/ui/alert';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Info, Loader2, Coins, ImagePlus } from 'lucide-svelte';
|
||||
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||
import { onMount } from 'svelte';
|
||||
import { CREATION_FEE, INITIAL_LIQUIDITY, TOTAL_COST } from '$lib/data/constants';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let name = $state('');
|
||||
let symbol = $state('');
|
||||
let iconFile = $state<File | null>(null);
|
||||
let iconPreview = $state<string | null>(null);
|
||||
let isSubmitting = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
onMount(() => {
|
||||
fetchPortfolioData();
|
||||
});
|
||||
|
||||
let nameError = $derived(
|
||||
name.length > 0 && (name.length < 2 || name.length > 255)
|
||||
? 'Name must be between 2 and 255 characters'
|
||||
: ''
|
||||
);
|
||||
|
||||
let symbolError = $derived(
|
||||
symbol.length > 0 && (symbol.length < 2 || symbol.length > 10)
|
||||
? 'Symbol must be between 2 and 10 characters'
|
||||
: ''
|
||||
);
|
||||
|
||||
let iconError = $derived(
|
||||
iconFile && iconFile.size > 1 * 1024 * 1024 ? 'Icon must be smaller than 1MB' : ''
|
||||
);
|
||||
|
||||
let isFormValid = $derived(
|
||||
name.length >= 2 && symbol.length >= 2 && !nameError && !symbolError && !iconError
|
||||
);
|
||||
|
||||
let hasEnoughFunds = $derived(
|
||||
$PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance >= TOTAL_COST : false
|
||||
);
|
||||
|
||||
let canSubmit = $derived(isFormValid && hasEnoughFunds && !isSubmitting);
|
||||
|
||||
function handleIconChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
iconFile = file;
|
||||
console.log(iconFile.size);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
iconPreview = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
error = 'Please select a valid image file';
|
||||
target.value = '';
|
||||
}
|
||||
} else {
|
||||
iconFile = null;
|
||||
iconPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: { preventDefault: () => void }) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit) return;
|
||||
|
||||
isSubmitting = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('symbol', symbol.toUpperCase());
|
||||
|
||||
if (iconFile) {
|
||||
formData.append('icon', iconFile);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/coin/create', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || 'Failed to create coin');
|
||||
}
|
||||
|
||||
await fetchPortfolioData();
|
||||
|
||||
goto(`/coin/${result.coin.symbol}`);
|
||||
} catch (e) {
|
||||
toast.error('Failed to create coin', {
|
||||
description: (e as Error).message || 'An error occurred while creating the coin'
|
||||
});
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Create Coin - Rugplay</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-5xl px-4 py-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Main Form Column -->
|
||||
<div class="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-lg">Coin Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Icon Upload -->
|
||||
<div>
|
||||
<Label for="icon">Coin Icon (Optional)</Label>
|
||||
<div class="mt-2 space-y-2">
|
||||
<label for="icon" class="block cursor-pointer">
|
||||
<div
|
||||
class="border-muted-foreground/25 bg-muted/50 hover:border-muted-foreground/50 group h-24 w-24 overflow-hidden rounded-full border-2 border-dashed transition-colors"
|
||||
>
|
||||
<Input
|
||||
id="icon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleIconChange}
|
||||
class="hidden"
|
||||
/>
|
||||
{#if iconPreview}
|
||||
<img src={iconPreview} alt="Preview" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<ImagePlus class="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<p class="{iconError ? 'text-destructive' : 'text-muted-foreground'} text-sm">
|
||||
{#if iconError}
|
||||
{iconError}
|
||||
{:else if iconFile}
|
||||
{iconFile.name} ({(iconFile.size / 1024).toFixed(2)} KB)
|
||||
{:else}
|
||||
Click to upload your coin's icon (PNG or JPG, max 1MB)
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Coin Name</Label>
|
||||
<Input id="name" type="text" bind:value={name} placeholder="e.g., Bitcoin" required />
|
||||
{#if nameError}
|
||||
<p class="text-destructive text-xs">{nameError}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose a memorable name for your cryptocurrency
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Symbol Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="symbol">Symbol</Label>
|
||||
<div class="relative">
|
||||
<span class="text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
>*</span
|
||||
>
|
||||
<Input
|
||||
id="symbol"
|
||||
type="text"
|
||||
bind:value={symbol}
|
||||
placeholder="BTC"
|
||||
class="pl-8 uppercase"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{#if symbolError}
|
||||
<p class="text-destructive text-xs">{symbolError}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Short identifier for your coin (e.g., BTC for Bitcoin). Will be displayed as *{symbol ||
|
||||
'SYMBOL'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Fair Launch Info -->
|
||||
<Alert variant="default" class="bg-muted/50">
|
||||
<Info class="h-4 w-4" />
|
||||
<AlertDescription class="space-y-2">
|
||||
<p class="font-medium">Fair Launch Settings</p>
|
||||
<div class="text-muted-foreground space-y-1 text-sm">
|
||||
<p>• Total Supply: <span class="font-medium">1,000,000,000 tokens</span></p>
|
||||
<p>• Starting Price: <span class="font-medium">$0.000001 per token</span></p>
|
||||
<p>• You receive <span class="font-medium">100%</span> of the supply</p>
|
||||
<p>• Initial Market Cap: <span class="font-medium">$1,000</span></p>
|
||||
<p class="mt-2 text-sm">
|
||||
These settings ensure a fair start for all traders. The price will increase
|
||||
naturally as people buy tokens.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button type="submit" disabled={!canSubmit} class="w-full" size="lg">
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
{:else}
|
||||
<Coins class="h-4 w-4" />
|
||||
Create Coin (${TOTAL_COST.toFixed(2)})
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Preview and Info -->
|
||||
<div class="space-y-4">
|
||||
<!-- Cost Summary Card -->
|
||||
{#if $PORTFOLIO_DATA}
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base">Cost Summary</CardTitle>
|
||||
<div class="text-sm">
|
||||
<span class="text-muted-foreground">Balance: </span>
|
||||
<span class={hasEnoughFunds ? 'text-green-600' : 'text-destructive'}>
|
||||
${$PORTFOLIO_DATA.baseCurrencyBalance.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Creation Fee</span>
|
||||
<span>${CREATION_FEE}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Initial Liquidity</span>
|
||||
<span>${INITIAL_LIQUIDITY}</span>
|
||||
</div>
|
||||
<Separator class="my-2" />
|
||||
<div class="flex justify-between font-medium">
|
||||
<span>Total Cost</span>
|
||||
<span class="text-primary">${TOTAL_COST}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Info Card -->
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-base">What Happens After Launch?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Fair Distribution</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Everyone starts buying at the same price - no pre-sales or hidden allocations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Price Discovery</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Token price increases automatically as more people buy, following a bonding curve
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Instant Trading</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Trading begins immediately - buy, sell, or distribute your tokens as you wish
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
</style>
|
||||
12
website/src/routes/settings/+page.server.ts
Normal file
12
website/src/routes/settings/+page.server.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
if (!session?.user) throw error(401, 'Not authenticated');
|
||||
|
||||
return { user: session.user };
|
||||
};
|
||||
213
website/src/routes/settings/+page.svelte
Normal file
213
website/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { getPublicUrl, debounce } from '$lib/utils';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { CheckIcon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let name = $state(data.user.name);
|
||||
let bio = $state(data.user.bio ?? '');
|
||||
let username = $state(data.user.username);
|
||||
|
||||
const initialUsername = data.user.username;
|
||||
let avatarFile: FileList | undefined = $state(undefined);
|
||||
|
||||
let previewUrl: string | null = $state(null);
|
||||
let currentAvatarUrl = $derived(previewUrl || getPublicUrl(data.user.image ?? null));
|
||||
|
||||
let isDirty = $derived(
|
||||
name !== data.user.name ||
|
||||
bio !== (data.user.bio ?? '') ||
|
||||
username !== data.user.username ||
|
||||
avatarFile !== undefined
|
||||
);
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
let loading = $state(false);
|
||||
let usernameAvailable: boolean | null = $state(null);
|
||||
let checkingUsername = $state(false);
|
||||
|
||||
function beforeUnloadHandler(e: BeforeUnloadEvent) {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler);
|
||||
});
|
||||
|
||||
function handleAvatarClick() {
|
||||
fileInput.click();
|
||||
}
|
||||
function handleAvatarChange(e: Event) {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) {
|
||||
// Check file size
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
toast.error('Profile picture must be smaller than 1MB');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!f.type.startsWith('image/')) {
|
||||
toast.error('Please select a valid image file');
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
previewUrl = URL.createObjectURL(f);
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) avatarFile = files;
|
||||
}
|
||||
}
|
||||
|
||||
const checkUsername = debounce(async (val: string) => {
|
||||
if (val.length < 3) return (usernameAvailable = null);
|
||||
checkingUsername = true;
|
||||
const res = await fetch(`/api/settings/check-username?username=${val}`);
|
||||
usernameAvailable = (await res.json()).available;
|
||||
checkingUsername = false;
|
||||
}, 500);
|
||||
|
||||
$effect(() => {
|
||||
if (username !== initialUsername) checkUsername(username);
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('bio', bio);
|
||||
fd.append('username', username);
|
||||
if (avatarFile?.[0]) fd.append('avatar', avatarFile[0]);
|
||||
|
||||
const res = await fetch('/api/settings', { method: 'POST', body: fd });
|
||||
|
||||
if (res.ok) {
|
||||
await invalidateAll();
|
||||
toast.success('Settings updated successfully!', {
|
||||
action: { label: 'Refresh', onClick: () => window.location.reload() }
|
||||
});
|
||||
} else {
|
||||
const result = await res.json();
|
||||
toast.error('Failed to update settings', {
|
||||
description: result.message || 'An error occurred while updating your settings'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update settings', {
|
||||
description: 'An unexpected error occurred'
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-2xl p-6">
|
||||
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Profile Settings</Card.Title>
|
||||
<Card.Description>Update your profile information</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="mb-6 flex items-center gap-4">
|
||||
<div
|
||||
class="group relative cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleAvatarClick}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAvatarClick()}
|
||||
>
|
||||
<Avatar.Root class="size-20">
|
||||
<Avatar.Image src={currentAvatarUrl} alt={name} />
|
||||
<Avatar.Fallback>?</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span class="text-xs text-white">Change</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{name}</h3>
|
||||
<p class="text-muted-foreground text-sm">@{username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Display Name</Label>
|
||||
<Input id="name" bind:value={name} required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Username</Label>
|
||||
<div class="relative">
|
||||
<span class="text-muted-foreground absolute left-3 top-4 -translate-y-1/2 transform"
|
||||
>@</span
|
||||
>
|
||||
<Input
|
||||
id="username"
|
||||
bind:value={username}
|
||||
required
|
||||
pattern={'^[a-zA-Z0-9_]{3,30}$'}
|
||||
class="pl-8"
|
||||
/>
|
||||
<div class="absolute right-3 top-1.5">
|
||||
{#if checkingUsername}
|
||||
<span class="text-muted-foreground text-sm">Checking…</span>
|
||||
{:else if username !== initialUsername}
|
||||
{#if usernameAvailable}
|
||||
<CheckIcon class="text-success" />
|
||||
{:else}
|
||||
<span class="text-destructive text-sm">Taken</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Only letters, numbers, underscores. 3–30 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="bio">Bio</Label>
|
||||
<Textarea id="bio" bind:value={bio} rows={4} placeholder="Tell us about yourself" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || !isDirty}>
|
||||
{loading ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
Reference in a new issue