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:
Face 2025-05-23 16:26:02 +03:00
parent 9aa4ba157b
commit 16ad425bb5
48 changed files with 3030 additions and 326 deletions

View file

@ -2,12 +2,15 @@
@import "tw-animate-css";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
@custom-variant dark (&:is(.dark *));
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
:root {
--radius: 0.5rem;
--success: oklch(0.637 0.237 205.331);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
@ -96,6 +99,7 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-success: var(--success);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);

View file

@ -1,7 +1,11 @@
// src/lib/auth.ts (or your auth config file)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { env } from '$env/dynamic/private';
import { db } from "./server/db";
import * as schema from "./server/db/schema";
import { generateUsername } from "./utils/random";
import { uploadProfilePicture } from "./server/s3";
if (!env.GOOGLE_CLIENT_ID) throw new Error('GOOGLE_CLIENT_ID is not set');
if (!env.GOOGLE_CLIENT_SECRET) throw new Error('GOOGLE_CLIENT_SECRET is not set');
@ -13,44 +17,67 @@ export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: schema,
}),
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
}
},
mapProfileToUser: async (profile) => {
const newUsername = generateUsername();
let s3ImageKey: string | null = null;
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
if (profile.picture) {
try {
const response = await fetch(profile.picture);
if (!response.ok) {
console.error(`Failed to fetch profile picture: ${response.statusText}`);
} else {
const blob = await response.blob();
const arrayBuffer = await blob.arrayBuffer();
s3ImageKey = await uploadProfilePicture(
profile.sub, // Using Google 'sub' for a unique identifier
new Uint8Array(arrayBuffer),
blob.type,
blob.size
);
}
} catch (error) {
console.error('Failed to upload profile picture during social login:', error);
}
}
return {
name: profile.name,
email: profile.email,
image: s3ImageKey, // Store S3 key in the standard 'image' field
username: newUsername,
};
},
}
},
user: {
additionalFields: {
isAdmin: {
type: "boolean",
required: true,
defaultValue: false,
input: false
},
isBanned: {
type: "boolean",
required: true,
defaultValue: false,
input: false
},
banReason: {
type: "string",
required: false,
defaultValue: null,
input: false
}
},
deleteUser: { enabled: true }
username: { type: "string", required: true, input: false },
isAdmin: { type: "boolean", required: false, input: false },
isBanned: { type: "boolean", required: false, input: false },
banReason: { type: "string", required: false, input: false },
baseCurrencyBalance: { type: "string", required: false, input: false },
bio: { type: "string", required: false },
// Ensure 'image' is not listed here if it's a core field,
// or ensure 'avatarUrl' is used consistently if it is an additional field.
// Based on current setup, 'image' is core.
}
},
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5,
}
},
advanced: {
generateId: false,
database: {
generateId: false,
}
}
});

View file

@ -2,6 +2,7 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
import { Badge } from '$lib/components/ui/badge';
import {
Moon,
Sun,
@ -15,15 +16,19 @@
BadgeCheckIcon,
CreditCardIcon,
BellIcon,
LogOutIcon
LogOutIcon,
Wallet
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import SignInConfirmDialog from './SignInConfirmDialog.svelte';
import { signOut } from '$lib/auth-client';
import { getPublicUrl } from '$lib/utils';
import { goto } from '$app/navigation';
const data = {
navMain: [
@ -39,6 +44,15 @@
const { setOpenMobile, isMobile } = useSidebar();
let shouldSignIn = $state(false);
// Fetch portfolio data when user is authenticated
$effect(() => {
if ($USER_DATA) {
fetchPortfolioData();
} else {
PORTFOLIO_DATA.set(null);
}
});
function handleNavClick(title: string) {
setOpenMobile(false);
}
@ -47,6 +61,13 @@
setMode(mode.current === 'light' ? 'dark' : 'light');
setOpenMobile(false);
}
function formatCurrency(value: number): string {
return value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
</script>
<SignInConfirmDialog bind:open={shouldSignIn} />
@ -121,6 +142,36 @@
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
<!-- Portfolio Summary -->
{#if $USER_DATA && $PORTFOLIO_DATA}
<Sidebar.Group>
<Sidebar.GroupLabel>Portfolio</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<div class="px-2 py-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Wallet class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">Total Value</span>
</div>
<Badge variant="secondary" class="font-mono">
${formatCurrency($PORTFOLIO_DATA.totalValue)}
</Badge>
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<div class="flex justify-between">
<span>Cash:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.baseCurrencyBalance)}</span>
</div>
<div class="flex justify-between">
<span>Coins:</span>
<span class="font-mono">${formatCurrency($PORTFOLIO_DATA.totalCoinValue)}</span>
</div>
</div>
</div>
</Sidebar.GroupContent>
</Sidebar.Group>
{/if}
</Sidebar.Content>
{#if $USER_DATA}
@ -136,13 +187,12 @@
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={$USER_DATA.image} alt={$USER_DATA.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
<Avatar.Image src={getPublicUrl($USER_DATA.image)} alt={$USER_DATA.name} />
<Avatar.Fallback class="rounded-lg">?</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{$USER_DATA.name}</span>
<span class="truncate text-xs">$35,674.34</span>
<!-- TODO: replace with actual db entry -->
<span class="truncate text-xs">@{$USER_DATA.username}</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
@ -157,12 +207,12 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={$USER_DATA.image} alt={$USER_DATA.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
<Avatar.Image src={getPublicUrl($USER_DATA.image)} alt={$USER_DATA.name} />
<Avatar.Fallback class="rounded-lg">?</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{$USER_DATA.name}</span>
<span class="truncate text-xs">{$USER_DATA.email}</span>
<span class="truncate text-xs">@{$USER_DATA.username}</span>
</div>
</div>
</DropdownMenu.Label>
@ -175,7 +225,7 @@
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<DropdownMenu.Item onclick={() => goto('/settings')}>
<BadgeCheckIcon />
Account
</DropdownMenu.Item>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View file

@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
align = "center",
sideOffset = 4,
portalProps,
...restProps
}: HoverCardPrimitive.ContentProps & {
portalProps?: HoverCardPrimitive.PortalProps;
} = $props();
</script>
<HoverCardPrimitive.Portal {...portalProps}>
<HoverCardPrimitive.Content
bind:ref
data-slot="hover-card-content"
{align}
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-hidden z-50 mt-3 w-64 rounded-md border p-4 shadow-md outline-none",
className
)}
{...restProps}
/>
</HoverCardPrimitive.Portal>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: HoverCardPrimitive.TriggerProps = $props();
</script>
<HoverCardPrimitive.Trigger bind:ref data-slot="hover-card-trigger" {...restProps} />

View file

@ -0,0 +1,14 @@
import { LinkPreview as HoverCardPrimitive } from "bits-ui";
import Content from "./hover-card-content.svelte";
import Trigger from "./hover-card-trigger.svelte";
const Root = HoverCardPrimitive.Root;
export {
Root,
Content,
Trigger,
Root as HoverCard,
Content as HoverCardContent,
Trigger as HoverCardTrigger,
};

View file

@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>

View file

@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--popover); --normal-text: var(--popover-foreground); --normal-border: var(--border);"
{...restProps}
/>

View file

@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot="textarea"
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>

View file

@ -1,103 +0,0 @@
export interface Coin {
id: number;
name: string;
symbol: string;
price: number;
change24h: number;
volume24h: number;
marketCap: number;
priceHistory: { date: string; price: number }[];
}
export const coins: Coin[] = [
{
id: 1,
name: 'Bitcoin',
symbol: 'BTC',
price: 67890.42,
change24h: 2.3,
volume24h: 28500000000,
marketCap: 1320000000000,
priceHistory: [
{ date: '2025-05-14', price: 66250.18 },
{ date: '2025-05-15', price: 65890.34 },
{ date: '2025-05-16', price: 66780.12 },
{ date: '2025-05-17', price: 66920.45 },
{ date: '2025-05-18', price: 67120.78 },
{ date: '2025-05-19', price: 67450.23 },
{ date: '2025-05-20', price: 67890.42 }
]
},
{
id: 2,
name: 'Ethereum',
symbol: 'ETH',
price: 3456.78,
change24h: -1.2,
volume24h: 15200000000,
marketCap: 420000000000,
priceHistory: [
{ date: '2025-05-14', price: 3520.45 },
{ date: '2025-05-15', price: 3490.23 },
{ date: '2025-05-16', price: 3475.67 },
{ date: '2025-05-17', price: 3460.12 },
{ date: '2025-05-18', price: 3470.54 },
{ date: '2025-05-19', price: 3465.89 },
{ date: '2025-05-20', price: 3456.78 }
]
},
{
id: 3,
name: 'Ripple',
symbol: 'XRP',
price: 0.54,
change24h: 5.7,
volume24h: 2100000000,
marketCap: 28500000000,
priceHistory: [
{ date: '2025-05-14', price: 0.49 },
{ date: '2025-05-15', price: 0.50 },
{ date: '2025-05-16', price: 0.51 },
{ date: '2025-05-17', price: 0.52 },
{ date: '2025-05-18', price: 0.53 },
{ date: '2025-05-19', price: 0.54 },
{ date: '2025-05-20', price: 0.54 }
]
},
{
id: 4,
name: 'Solana',
symbol: 'SOL',
price: 156.89,
change24h: 7.2,
volume24h: 5600000000,
marketCap: 67800000000,
priceHistory: [
{ date: '2025-05-14', price: 142.34 },
{ date: '2025-05-15', price: 145.67 },
{ date: '2025-05-16', price: 148.90 },
{ date: '2025-05-17', price: 150.25 },
{ date: '2025-05-18', price: 152.30 },
{ date: '2025-05-19', price: 154.75 },
{ date: '2025-05-20', price: 156.89 }
]
},
{
id: 5,
name: 'Dogecoin',
symbol: 'DOGE',
price: 0.12,
change24h: -2.5,
volume24h: 980000000,
marketCap: 16500000000,
priceHistory: [
{ date: '2025-05-14', price: 0.125 },
{ date: '2025-05-15', price: 0.124 },
{ date: '2025-05-16', price: 0.123 },
{ date: '2025-05-17', price: 0.122 },
{ date: '2025-05-18', price: 0.121 },
{ date: '2025-05-19', price: 0.120 },
{ date: '2025-05-20', price: 0.120 }
]
}
];

View file

@ -0,0 +1,9 @@
// FILE UPLOAD
export const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
// COIN CREATION COSTS
export const CREATION_FEE = 100; // $100 creation fee
export const FIXED_SUPPLY = 1000000000; // 1 billion tokens
export const STARTING_PRICE = 0.000001; // $0.000001 per token
export const INITIAL_LIQUIDITY = FIXED_SUPPLY * STARTING_PRICE; // $1000
export const TOTAL_COST = CREATION_FEE + INITIAL_LIQUIDITY; // $1100

View file

@ -17,6 +17,8 @@ export const user = pgTable("user", {
precision: 19,
scale: 4,
}).notNull().default("10000.0000"), // 10,000 *BUSS
bio: varchar("bio", { length: 160 }).default("Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”"),
username: varchar("username", { length: 30 }).notNull().unique(),
});
export const session = pgTable("session", {
@ -59,6 +61,7 @@ export const coin = pgTable("coin", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
symbol: varchar("symbol", { length: 10 }).notNull().unique(),
icon: text("icon"), // New field for coin icon
creatorId: integer("creator_id").references(() => user.id, { onDelete: "set null", }), // Coin can exist even if creator is deleted
initialSupply: decimal("initial_supply", { precision: 28, scale: 8 }).notNull(),
circulatingSupply: decimal("circulating_supply", { precision: 28, scale: 8 }).notNull(),

View file

@ -0,0 +1,95 @@
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private';
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public';
const s3Client = new S3Client({
endpoint: PUBLIC_B2_ENDPOINT,
region: PUBLIC_B2_REGION,
credentials: {
accessKeyId: PRIVATE_B2_KEY_ID,
secretAccessKey: PRIVATE_B2_APP_KEY
},
forcePathStyle: true,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
export async function generatePresignedUrl(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
ContentType: contentType
});
return getSignedUrl(s3Client, command, { expiresIn: 3600 }); // 1 hour
}
export async function deleteObject(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key
});
await s3Client.send(command);
}
export async function generateDownloadUrl(key: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key
});
return getSignedUrl(s3Client, command, { expiresIn: 3600 });
}
export async function uploadProfilePicture(
identifier: string, // Can be user ID or a unique ID from social provider
body: Uint8Array,
contentType: string,
contentLength?: number
): Promise<string> {
let fileExtension = contentType.split('/')[1];
// Ensure a valid image extension or default to jpg
if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) {
fileExtension = 'jpg';
}
const key = `avatars/${identifier}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
});
await s3Client.send(command);
return key;
}
export async function uploadCoinIcon(
coinSymbol: string,
body: Uint8Array,
contentType: string,
contentLength?: number
): Promise<string> {
let fileExtension = contentType.split('/')[1];
if (!fileExtension || !['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExtension.toLowerCase())) {
fileExtension = 'png';
}
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
});
await s3Client.send(command);
return key;
}
export { s3Client };

View file

@ -0,0 +1,30 @@
import { writable } from 'svelte/store';
export interface PortfolioData {
baseCurrencyBalance: number;
totalCoinValue: number;
totalValue: number;
coinHoldings: Array<{
symbol: string;
quantity: number;
currentPrice: number;
value: number;
}>;
currency: string;
}
export const PORTFOLIO_DATA = writable<PortfolioData | null>(null);
export async function fetchPortfolioData() {
try {
const response = await fetch('/api/portfolio/total');
if (response.ok) {
const data = await response.json();
PORTFOLIO_DATA.set(data);
return data;
}
} catch (error) {
console.error('Failed to fetch portfolio data:', error);
}
return null;
}

View file

@ -3,11 +3,14 @@ import { writable } from 'svelte/store';
export type User = {
id: string;
name: string;
username: string;
email: string;
isAdmin: boolean;
image: string;
isBanned: boolean;
banReason: string | null;
avatarUrl: string | null;
bio: string;
} | null;
export const USER_DATA = writable<User>(undefined);

View file

@ -1,3 +1,4 @@
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT } from "$env/static/public";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@ -25,3 +26,20 @@ export function getTimeBasedGreeting(name: string): string {
return `Good night, ${name}`;
}
}
export function getPublicUrl(key: string | null): string | null {
if (!key) return null;
return `${PUBLIC_B2_ENDPOINT}/${PUBLIC_B2_BUCKET}/${key}`;
}
export function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | undefined;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View file

@ -0,0 +1,9 @@
const adjectives = ['happy', 'lucky', 'sunny', 'clever', 'brave', 'bright', 'cool', 'wild', 'calm', 'kind'];
const nouns = ['panda', 'tiger', 'whale', 'eagle', 'lion', 'wolf', 'bear', 'fox', 'deer', 'seal'];
export function generateUsername(): string {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 9999);
return `${adj}_${noun}${number}`;
}

View file

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

View file

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

View file

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

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

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

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

View 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: '$'
});
}

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

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

View 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');
}
}

View file

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

View file

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

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

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

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