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,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,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
23
website/src/lib/components/ui/alert/alert-description.svelte
Normal file
23
website/src/lib/components/ui/alert/alert-description.svelte
Normal 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>
|
||||
20
website/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
website/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
44
website/src/lib/components/ui/alert/alert.svelte
Normal file
44
website/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
14
website/src/lib/components/ui/alert/index.ts
Normal file
14
website/src/lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
14
website/src/lib/components/ui/hover-card/index.ts
Normal file
14
website/src/lib/components/ui/hover-card/index.ts
Normal 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,
|
||||
};
|
||||
7
website/src/lib/components/ui/label/index.ts
Normal file
7
website/src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
website/src/lib/components/ui/label/label.svelte
Normal file
20
website/src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
1
website/src/lib/components/ui/sonner/index.ts
Normal file
1
website/src/lib/components/ui/sonner/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Toaster } from "./sonner.svelte";
|
||||
13
website/src/lib/components/ui/sonner/sonner.svelte
Normal file
13
website/src/lib/components/ui/sonner/sonner.svelte
Normal 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}
|
||||
/>
|
||||
7
website/src/lib/components/ui/textarea/index.ts
Normal file
7
website/src/lib/components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
22
website/src/lib/components/ui/textarea/textarea.svelte
Normal file
22
website/src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
||||
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
];
|
||||
9
website/src/lib/data/constants.ts
Normal file
9
website/src/lib/data/constants.ts
Normal 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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
95
website/src/lib/server/s3.ts
Normal file
95
website/src/lib/server/s3.ts
Normal 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 };
|
||||
30
website/src/lib/stores/portfolio-data.ts
Normal file
30
website/src/lib/stores/portfolio-data.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
9
website/src/lib/utils/random.ts
Normal file
9
website/src/lib/utils/random.ts
Normal 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}`;
|
||||
}
|
||||
Reference in a new issue