diff --git a/website/src/lib/components/self/CoinIcon.svelte b/website/src/lib/components/self/CoinIcon.svelte index 73054d3..9f0fd50 100644 --- a/website/src/lib/components/self/CoinIcon.svelte +++ b/website/src/lib/components/self/CoinIcon.svelte @@ -23,6 +23,8 @@ src={getPublicUrl(icon)} alt={name} class="{sizeClass} rounded-full object-cover {className}" + loading="lazy" + decoding="async" /> {:else}
+ import type { HTMLAttributes } from "svelte/elements"; + import { cn, type WithElementRef } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef> = $props(); + + + diff --git a/website/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/website/src/lib/components/ui/pagination/pagination-ellipsis.svelte new file mode 100644 index 0000000..3be94c9 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination-ellipsis.svelte @@ -0,0 +1,22 @@ + + + diff --git a/website/src/lib/components/ui/pagination/pagination-item.svelte b/website/src/lib/components/ui/pagination/pagination-item.svelte new file mode 100644 index 0000000..fd7ffc3 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination-item.svelte @@ -0,0 +1,14 @@ + + +
  • + {@render children?.()} +
  • diff --git a/website/src/lib/components/ui/pagination/pagination-link.svelte b/website/src/lib/components/ui/pagination/pagination-link.svelte new file mode 100644 index 0000000..69579a0 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination-link.svelte @@ -0,0 +1,39 @@ + + +{#snippet Fallback()} + {page.value} +{/snippet} + + diff --git a/website/src/lib/components/ui/pagination/pagination-next-button.svelte b/website/src/lib/components/ui/pagination/pagination-next-button.svelte new file mode 100644 index 0000000..d4b9553 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination-next-button.svelte @@ -0,0 +1,33 @@ + + +{#snippet Fallback()} + Next + +{/snippet} + + diff --git a/website/src/lib/components/ui/pagination/pagination-prev-button.svelte b/website/src/lib/components/ui/pagination/pagination-prev-button.svelte new file mode 100644 index 0000000..2d3dc70 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination-prev-button.svelte @@ -0,0 +1,33 @@ + + +{#snippet Fallback()} + + Previous +{/snippet} + + diff --git a/website/src/lib/components/ui/pagination/pagination.svelte b/website/src/lib/components/ui/pagination/pagination.svelte new file mode 100644 index 0000000..60e3471 --- /dev/null +++ b/website/src/lib/components/ui/pagination/pagination.svelte @@ -0,0 +1,28 @@ + + + diff --git a/website/src/lib/components/ui/popover/index.ts b/website/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..9f30922 --- /dev/null +++ b/website/src/lib/components/ui/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from "bits-ui"; +import Content from "./popover-content.svelte"; +import Trigger from "./popover-trigger.svelte"; +const Root = PopoverPrimitive.Root; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, +}; diff --git a/website/src/lib/components/ui/popover/popover-content.svelte b/website/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..9bced7a --- /dev/null +++ b/website/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/website/src/lib/components/ui/popover/popover-trigger.svelte b/website/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..586323c --- /dev/null +++ b/website/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/website/src/lib/types/market.ts b/website/src/lib/types/market.ts new file mode 100644 index 0000000..3fb508c --- /dev/null +++ b/website/src/lib/types/market.ts @@ -0,0 +1,38 @@ +export interface CoinData { + symbol: string; + name: string; + icon: string; + currentPrice: number; + marketCap: number; + volume24h: number; + change24h: number; + createdAt: string; + creatorName: string | null; +} + +export interface MarketFilters { + searchQuery: string; + sortBy: string; + sortOrder: string; + priceFilter: string; + changeFilter: string; + page: number; +} + +export interface MarketResponse { + coins: CoinData[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface FilterOption { + value: string; + label: string; +} + +export interface VolatilityBadge { + text: string; + variant: 'default' | 'secondary' | 'outline'; +} diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index c7f3226..ceeaa6e 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -3,7 +3,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -15,7 +15,7 @@ export type WithElementRef = T & { ref?: export function getTimeBasedGreeting(name: string): string { const hour = new Date().getHours(); - + if (hour < 12) { return `Good morning, ${name}`; } else if (hour < 17) { @@ -81,4 +81,48 @@ export function formatDate(timestamp: string): string { }); } -export const formatMarketCap = formatValue; \ No newline at end of file +export function formatRelativeTime(timestamp: string | Date): string { + const now = new Date(); + const past = new Date(timestamp); + const ms = now.getTime() - past.getTime(); + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return `${seconds}s`; + if (minutes < 60) return `${minutes}m`; + + if (hours < 24) { + const extraMinutes = minutes % 60; + return extraMinutes === 0 ? `${hours}hr` : `${hours}hr ${extraMinutes}m`; + } + + if (days < 7) return `${days}d`; + + const yearsDiff = now.getFullYear() - past.getFullYear(); + const monthsDiff = now.getMonth() - past.getMonth(); + const totalMonths = yearsDiff * 12 + monthsDiff; + const adjustedMonths = totalMonths + (now.getDate() < past.getDate() ? -1 : 0); + const years = Math.floor(adjustedMonths / 12); + + if (adjustedMonths < 1) { + const weeks = Math.floor(days / 7); + const extraDays = days % 7; + return extraDays === 0 ? `${weeks}w` : `${weeks}w ${extraDays}d`; + } + + if (years < 1) { + const tempDate = new Date(past); + tempDate.setMonth(tempDate.getMonth() + adjustedMonths); + const remainingDays = Math.floor((now.getTime() - tempDate.getTime()) / (1000 * 60 * 60 * 24)); + const weeks = Math.floor(remainingDays / 7); + return weeks === 0 ? `${adjustedMonths}m` : `${adjustedMonths}m ${weeks}w`; + } + + const remainingMonths = adjustedMonths % 12; + return remainingMonths === 0 ? `${years}y` : `${years}y ${remainingMonths}m`; +} + +export const formatMarketCap = formatValue; diff --git a/website/src/routes/api/market/+server.ts b/website/src/routes/api/market/+server.ts new file mode 100644 index 0000000..c0bdabc --- /dev/null +++ b/website/src/routes/api/market/+server.ts @@ -0,0 +1,168 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { coin, user } from '$lib/server/db/schema'; +import { eq, desc, asc, like, ilike, and, gte, lte, between, or, count } from 'drizzle-orm'; + +const VALID_SORT_FIELDS = ['marketCap', 'currentPrice', 'change24h', 'volume24h', 'createdAt']; +const VALID_SORT_ORDERS = ['asc', 'desc']; + +const PRICE_FILTERS = { + ALL: 'all', + UNDER_1: 'under1', + BETWEEN_1_TO_10: '1to10', + BETWEEN_10_TO_100: '10to100', + OVER_100: 'over100' +} as const; + +const CHANGE_FILTERS = { + ALL: 'all', + GAINERS: 'gainers', + LOSERS: 'losers', + HOT: 'hot', + WILD: 'wild' +} as const; + +const PRICE_RANGES = { + ONE: '1.00000000', + TEN: '10.00000000', + HUNDRED: '100.00000000' +} as const; + +const CHANGE_THRESHOLDS = { + ZERO: '0.0000', + MODERATE: '10.0000', + MODERATE_NEGATIVE: '-10.0000', + EXTREME: '50.0000', + EXTREME_NEGATIVE: '-50.0000' +} as const; + +export async function GET({ url }) { + const searchQuery = url.searchParams.get('search') || ''; + const sortBy = url.searchParams.get('sortBy') || 'marketCap'; + const sortOrder = url.searchParams.get('sortOrder') || 'desc'; + const priceFilter = url.searchParams.get('priceFilter') || 'all'; + const changeFilter = url.searchParams.get('changeFilter') || 'all'; + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '12'); + + if (!VALID_SORT_FIELDS.includes(sortBy) || !VALID_SORT_ORDERS.includes(sortOrder)) { + return json({ error: 'Invalid sort parameters' }, { status: 400 }); + } if (page < 1 || limit < 1 || limit > 100) { + return json({ error: 'Invalid pagination parameters' }, { status: 400 }); + } + + if (!Object.values(PRICE_FILTERS).includes(priceFilter as any) || !Object.values(CHANGE_FILTERS).includes(changeFilter as any)) { + return json({ error: 'Invalid filter parameters' }, { status: 400 }); + } + + try { + const conditions = [eq(coin.isListed, true)]; + + if (searchQuery) { + conditions.push( + or( + ilike(coin.name, `%${searchQuery}%`), + ilike(coin.symbol, `%${searchQuery}%`) + )! + ); + } + + switch (priceFilter) { + case PRICE_FILTERS.UNDER_1: + conditions.push(lte(coin.currentPrice, PRICE_RANGES.ONE)); + break; + case PRICE_FILTERS.BETWEEN_1_TO_10: + conditions.push(between(coin.currentPrice, PRICE_RANGES.ONE, PRICE_RANGES.TEN)); + break; + case PRICE_FILTERS.BETWEEN_10_TO_100: + conditions.push(between(coin.currentPrice, PRICE_RANGES.TEN, PRICE_RANGES.HUNDRED)); + break; + case PRICE_FILTERS.OVER_100: + conditions.push(gte(coin.currentPrice, PRICE_RANGES.HUNDRED)); + break; + } + + switch (changeFilter) { + case CHANGE_FILTERS.GAINERS: + conditions.push(gte(coin.change24h, CHANGE_THRESHOLDS.ZERO)); + break; + case CHANGE_FILTERS.LOSERS: + conditions.push(lte(coin.change24h, CHANGE_THRESHOLDS.ZERO)); + break; + case CHANGE_FILTERS.HOT: + conditions.push( + or( + gte(coin.change24h, CHANGE_THRESHOLDS.MODERATE), + lte(coin.change24h, CHANGE_THRESHOLDS.MODERATE_NEGATIVE) + )! + ); + break; + case CHANGE_FILTERS.WILD: + conditions.push( + or( + gte(coin.change24h, CHANGE_THRESHOLDS.EXTREME), + lte(coin.change24h, CHANGE_THRESHOLDS.EXTREME_NEGATIVE) + )! + ); + break; + } + + const whereCondition = and(...conditions); + const orderFn = sortOrder === 'asc' ? asc : desc; + + const sortColumn = (() => { + switch (sortBy) { + case 'marketCap': return coin.marketCap; + case 'currentPrice': return coin.currentPrice; + case 'change24h': return coin.change24h; + case 'volume24h': return coin.volume24h; + case 'createdAt': return coin.createdAt; + default: return coin.marketCap; // fallback + } + })(); + + const [[{ total }], coins] = await Promise.all([ + db.select({ total: count() }).from(coin).where(whereCondition), + db.select({ + symbol: coin.symbol, + name: coin.name, + icon: coin.icon, + currentPrice: coin.currentPrice, + marketCap: coin.marketCap, + volume24h: coin.volume24h, + change24h: coin.change24h, + createdAt: coin.createdAt, + creatorName: user.name + }) + .from(coin) + .leftJoin(user, eq(coin.creatorId, user.id)) + .where(whereCondition) + .orderBy(orderFn(sortColumn)) + .limit(limit) + .offset((page - 1) * limit) + ]); + + const formattedCoins = coins.map(c => ({ + symbol: c.symbol, + name: c.name, + icon: c.icon, + currentPrice: Number(c.currentPrice), + marketCap: Number(c.marketCap), + volume24h: Number(c.volume24h), + change24h: Number(c.change24h), + createdAt: c.createdAt, + creatorName: c.creatorName + })); + + return json({ + coins: formattedCoins, + total: total || 0, + page, + limit, + totalPages: Math.ceil((total || 0) / limit) + }); + } catch (e) { + console.error('Error fetching market data:', e); + return json({ error: 'Failed to fetch market data' }, { status: 500 }); + } +} diff --git a/website/src/routes/market/+page.server.ts b/website/src/routes/market/+page.server.ts new file mode 100644 index 0000000..4a8685f --- /dev/null +++ b/website/src/routes/market/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types'; +import type { MarketFilters } from '$lib/types/market'; + +export const load: PageServerLoad = async ({ url }): Promise<{ filters: MarketFilters }> => { + return { + filters: { + searchQuery: url.searchParams.get('search') || '', + sortBy: url.searchParams.get('sortBy') || 'marketCap', + sortOrder: url.searchParams.get('sortOrder') || 'desc', + priceFilter: url.searchParams.get('priceFilter') || 'all', + changeFilter: url.searchParams.get('changeFilter') || 'all', + page: Math.max(1, parseInt(url.searchParams.get('page') || '1') || 1) + } + }; +}; diff --git a/website/src/routes/market/+page.svelte b/website/src/routes/market/+page.svelte new file mode 100644 index 0000000..d8f0eed --- /dev/null +++ b/website/src/routes/market/+page.svelte @@ -0,0 +1,568 @@ + + + + Market - Rugplay + + + +
    +
    +
    +

    Market

    +

    + Discover coins, track performance, and find your next investment +

    + +
    +
    + + +
    + + + + + + +
    +
    + +
    + + + + +
    +
    + +
    + + + +
    + + {currentSortOrderLabel} +
    + +
    + + {#each sortOrderOptions as option} + handleSortOrderChange(option.value)} + class="cursor-pointer" + > + + {option.label} + {#if sortOrder === option.value} +
    + {/if} +
    + {/each} +
    +
    +
    + +
    + + + +
    + + {currentPriceFilterLabel} +
    + +
    + + {#each priceFilterOptions as option} + handlePriceFilterChange(option.value)} + class="cursor-pointer" + > + + {option.label} + {#if priceFilter === option.value} +
    + {/if} +
    + {/each} +
    +
    +
    + +
    + + + +
    + + {currentChangeFilterLabel} +
    + +
    + + {#each changeFilterOptions as option} + handleChangeFilterChange(option.value)} + class="cursor-pointer" + > + + {option.label} + {#if changeFilter === option.value} +
    + {/if} +
    + {/each} +
    +
    +
    + +
    + + +
    +
    +
    +
    + + +
    +
    +
    + + + {#if !loading && totalCount > 0} +
    +
    + Showing {startIndex}-{endIndex} of {totalCount} coins +
    + {#if hasActiveFilters} + + {/if} +
    + {/if} + + + {#if loading} +
    +
    +
    Loading market data...
    +
    Fetching the latest coin prices and chaos levels
    +
    +
    + {:else if coins.length === 0} +
    +
    +
    No coins found
    +
    + {#if searchQuery} + No coins match your search "{searchQuery}". Try different keywords or adjust filters. + {:else} + The market seems quiet... create a coin? :) + {/if} +
    + {#if hasActiveFilters} + + {/if} +
    +
    + {:else} +
    + {#each coins as coin, index} + {@const volatilityBadge = getVolatilityBadge(coin.change24h)} + {@const globalIndex = (currentPage - 1) * perPage + index + 1} + goto(`/coin/${coin.symbol}`)} + > + +
    +
    + +
    +

    {coin.name}

    +

    *{coin.symbol}

    +
    +
    +
    + #{globalIndex} +
    +
    +
    + + +
    + +
    +
    + ${formatPrice(coin.currentPrice)} +
    +
    + = 0 ? 'success' : 'destructive'} class="text-xs"> + {coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}% + + {#if volatilityBadge} + + {volatilityBadge.text} + + {/if} +
    +
    + + +
    +
    + Market Cap + {formatMarketCap(coin.marketCap)} +
    +
    + Volume (24h) + {formatMarketCap(coin.volume24h)} +
    +
    + Created + {formatRelativeTime(coin.createdAt)} +
    +
    +
    +
    +
    + {/each} +
    + + + {#if totalPages > 1} +
    + + {#snippet children({ pages, currentPage: paginationCurrentPage })} + + + + + + + + {#each pages as page (page.key)} + {#if page.type === 'ellipsis'} + + + + {:else} + + + {page.value} + + + {/if} + {/each} + + + + + + + + {/snippet} + +
    + {/if} + {/if} +