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

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