feat: market tab
This commit is contained in:
parent
35237c3470
commit
800b5d1a09
17 changed files with 1115 additions and 3 deletions
|
|
@ -23,6 +23,8 @@
|
||||||
src={getPublicUrl(icon)}
|
src={getPublicUrl(icon)}
|
||||||
alt={name}
|
alt={name}
|
||||||
class="{sizeClass} rounded-full object-cover {className}"
|
class="{sizeClass} rounded-full object-cover {className}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
25
website/src/lib/components/ui/pagination/index.ts
Normal file
25
website/src/lib/components/ui/pagination/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import Root from "./pagination.svelte";
|
||||||
|
import Content from "./pagination-content.svelte";
|
||||||
|
import Item from "./pagination-item.svelte";
|
||||||
|
import Link from "./pagination-link.svelte";
|
||||||
|
import PrevButton from "./pagination-prev-button.svelte";
|
||||||
|
import NextButton from "./pagination-next-button.svelte";
|
||||||
|
import Ellipsis from "./pagination-ellipsis.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Item,
|
||||||
|
Link,
|
||||||
|
PrevButton,
|
||||||
|
NextButton,
|
||||||
|
Ellipsis,
|
||||||
|
//
|
||||||
|
Root as Pagination,
|
||||||
|
Content as PaginationContent,
|
||||||
|
Item as PaginationItem,
|
||||||
|
Link as PaginationLink,
|
||||||
|
PrevButton as PaginationPrevButton,
|
||||||
|
NextButton as PaginationNextButton,
|
||||||
|
Ellipsis as PaginationEllipsis,
|
||||||
|
};
|
||||||
|
|
@ -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<HTMLUListElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="pagination-content"
|
||||||
|
class={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ul>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<EllipsisIcon class="size-4" />
|
||||||
|
<span class="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
size = "icon",
|
||||||
|
isActive = false,
|
||||||
|
page,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.PageProps &
|
||||||
|
Props & {
|
||||||
|
isActive: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
{page.value}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.Page
|
||||||
|
bind:ref
|
||||||
|
{page}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.NextButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRightIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.NextButton
|
||||||
|
bind:ref
|
||||||
|
aria-label="Go to next page"
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({
|
||||||
|
size: "default",
|
||||||
|
variant: "ghost",
|
||||||
|
class: "gap-1 px-2.5 sm:pr-2.5",
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.PrevButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronLeftIcon class="size-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PaginationPrimitive.PrevButton
|
||||||
|
bind:ref
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({
|
||||||
|
size: "default",
|
||||||
|
variant: "ghost",
|
||||||
|
class: "gap-1 px-2.5 sm:pl-2.5",
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
28
website/src/lib/components/ui/pagination/pagination.svelte
Normal file
28
website/src/lib/components/ui/pagination/pagination.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
count = 0,
|
||||||
|
perPage = 10,
|
||||||
|
page = $bindable(1),
|
||||||
|
siblingCount = 1,
|
||||||
|
...restProps
|
||||||
|
}: PaginationPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PaginationPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:page
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
class={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{count}
|
||||||
|
{perPage}
|
||||||
|
{siblingCount}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
17
website/src/lib/components/ui/popover/index.ts
Normal file
17
website/src/lib/components/ui/popover/index.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
29
website/src/lib/components/ui/popover/popover-content.svelte
Normal file
29
website/src/lib/components/ui/popover/popover-content.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: PopoverPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-content"
|
||||||
|
{sideOffset}
|
||||||
|
{align}
|
||||||
|
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 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
17
website/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
website/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
class={cn("", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
38
website/src/lib/types/market.ts
Normal file
38
website/src/lib/types/market.ts
Normal file
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -81,4 +81,48 @@ export function formatDate(timestamp: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
export const formatMarketCap = formatValue;
|
||||||
168
website/src/routes/api/market/+server.ts
Normal file
168
website/src/routes/api/market/+server.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
website/src/routes/market/+page.server.ts
Normal file
15
website/src/routes/market/+page.server.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
568
website/src/routes/market/+page.svelte
Normal file
568
website/src/routes/market/+page.svelte
Normal file
|
|
@ -0,0 +1,568 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowUpDown
|
||||||
|
} from 'lucide-svelte'; import { formatPrice, formatMarketCap, debounce, formatRelativeTime } from '$lib/utils';
|
||||||
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
|
import type { CoinData, FilterOption, VolatilityBadge, MarketResponse } from '$lib/types/market';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let coins = $state<CoinData[]>([]);
|
||||||
|
let totalCount = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchQuery = $state(data.filters.searchQuery);
|
||||||
|
let sortBy = $state(data.filters.sortBy);
|
||||||
|
let sortOrder = $state(data.filters.sortOrder);
|
||||||
|
let priceFilter = $state(data.filters.priceFilter);
|
||||||
|
let changeFilter = $state(data.filters.changeFilter);
|
||||||
|
let showFilterPopover = $state(false);
|
||||||
|
let currentPage = $state(data.filters.page);
|
||||||
|
|
||||||
|
const isDesktop = new MediaQuery('(min-width: 768px)');
|
||||||
|
let perPage = $derived(isDesktop.current ? 12 : 9);
|
||||||
|
let siblingCount = $derived(isDesktop.current ? 1 : 0);
|
||||||
|
const priceFilterOptions: FilterOption[] = [
|
||||||
|
{ value: 'all', label: 'All prices' },
|
||||||
|
{ value: 'under1', label: 'Under $1' },
|
||||||
|
{ value: '1to10', label: '$1 - $10' },
|
||||||
|
{ value: '10to100', label: '$10 - $100' },
|
||||||
|
{ value: 'over100', label: 'Over $100' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const changeFilterOptions: FilterOption[] = [
|
||||||
|
{ value: 'all', label: 'All changes' },
|
||||||
|
{ value: 'gainers', label: 'Gainers only' },
|
||||||
|
{ value: 'losers', label: 'Losers only' },
|
||||||
|
{ value: 'hot', label: 'Hot (±10%)' },
|
||||||
|
{ value: 'wild', label: 'Wild (±50%)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOrderOptions: FilterOption[] = [
|
||||||
|
{ value: 'desc', label: 'High to Low' },
|
||||||
|
{ value: 'asc', label: 'Low to High' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const debouncedSearch = debounce(performSearch, 300);
|
||||||
|
let previousSearchQueryForEffect = $state(data.filters.searchQuery);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchMarketData();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateURL() {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
url.searchParams.set('search', searchQuery);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy !== 'marketCap') {
|
||||||
|
url.searchParams.set('sortBy', sortBy);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('sortBy');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder !== 'desc') {
|
||||||
|
url.searchParams.set('sortOrder', sortOrder);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('sortOrder');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceFilter !== 'all') {
|
||||||
|
url.searchParams.set('priceFilter', priceFilter);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('priceFilter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeFilter !== 'all') {
|
||||||
|
url.searchParams.set('changeFilter', changeFilter);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('changeFilter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage !== 1) {
|
||||||
|
url.searchParams.set('page', currentPage.toString());
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(url.toString(), { noScroll: true, replaceState: true });
|
||||||
|
}
|
||||||
|
async function fetchMarketData() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search: searchQuery,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
priceFilter,
|
||||||
|
changeFilter,
|
||||||
|
page: currentPage.toString(),
|
||||||
|
limit: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/market?${params}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result: MarketResponse = await response.json();
|
||||||
|
coins = result.coins;
|
||||||
|
totalCount = result.total;
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to load market data');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch market data:', e);
|
||||||
|
toast.error('Failed to load market data');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (searchQuery !== previousSearchQueryForEffect) {
|
||||||
|
debouncedSearch();
|
||||||
|
previousSearchQueryForEffect = searchQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSortChange(newSortBy: string) {
|
||||||
|
if (sortBy === newSortBy) {
|
||||||
|
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortBy = newSortBy;
|
||||||
|
sortOrder = 'desc';
|
||||||
|
}
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortOrderChange(newSortOrder: string) {
|
||||||
|
sortOrder = newSortOrder;
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
searchQuery = '';
|
||||||
|
sortBy = 'marketCap';
|
||||||
|
sortOrder = 'desc';
|
||||||
|
priceFilter = 'all';
|
||||||
|
changeFilter = 'all';
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
goto('/market', { noScroll: true, replaceState: true });
|
||||||
|
fetchMarketData();
|
||||||
|
showFilterPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
showFilterPopover = false;
|
||||||
|
}
|
||||||
|
function getVolatilityBadge(change: number): VolatilityBadge | null {
|
||||||
|
const absChange = Math.abs(change);
|
||||||
|
if (absChange > 50) return { text: '🚀 WILD', variant: 'default' as const };
|
||||||
|
if (absChange > 20) return { text: '📈 HOT', variant: 'secondary' as const };
|
||||||
|
if (absChange > 10) return { text: '⚡ ACTIVE', variant: 'outline' as const };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePriceFilterChange(value: string) {
|
||||||
|
priceFilter = value;
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeFilterChange(value: string) {
|
||||||
|
changeFilter = value;
|
||||||
|
currentPage = 1;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasActiveFilters = $derived(
|
||||||
|
searchQuery !== '' ||
|
||||||
|
priceFilter !== 'all' ||
|
||||||
|
changeFilter !== 'all' ||
|
||||||
|
sortBy !== 'marketCap' ||
|
||||||
|
sortOrder !== 'desc'
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.ceil(totalCount / perPage));
|
||||||
|
let startIndex = $derived((currentPage - 1) * perPage + 1);
|
||||||
|
let endIndex = $derived(Math.min(currentPage * perPage, totalCount));
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage = page;
|
||||||
|
updateURL();
|
||||||
|
fetchMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPriceFilterLabel = $derived(
|
||||||
|
priceFilterOptions.find((option) => option.value === priceFilter)?.label || 'All prices'
|
||||||
|
);
|
||||||
|
let currentChangeFilterLabel = $derived(
|
||||||
|
changeFilterOptions.find((option) => option.value === changeFilter)?.label || 'All changes'
|
||||||
|
);
|
||||||
|
let currentSortOrderLabel = $derived(
|
||||||
|
sortOrderOptions.find((option) => option.value === sortOrder)?.label || 'High to Low'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Market - Rugplay</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Discover and trade cryptocurrencies. Search, filter, and sort through all available coins."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
|
<header class="mb-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="mb-2 text-3xl font-bold">Market</h1>
|
||||||
|
<p class="text-muted-foreground mb-6">
|
||||||
|
Discover coins, track performance, and find your next investment
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mx-auto flex max-w-2xl items-center justify-center gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search class="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search coins by name or symbol..."
|
||||||
|
class="pl-10 pr-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover.Root bind:open={showFilterPopover}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
<Button variant="outline" size="default" class="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal class="h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<Badge variant="secondary" class="h-5 w-5 rounded-full p-0 text-xs">•</Badge>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content class="w-80 p-4" align="end">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Sort By</Label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'marketCap' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('marketCap')}
|
||||||
|
>
|
||||||
|
Market Cap
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'currentPrice' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('currentPrice')}
|
||||||
|
>
|
||||||
|
Price
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'change24h' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('change24h')}
|
||||||
|
>
|
||||||
|
24h Change
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={sortBy === 'volume24h' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => handleSortChange('volume24h')}
|
||||||
|
>
|
||||||
|
Volume
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Sort Order</Label>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ArrowUpDown class="h-4 w-4" />
|
||||||
|
<span>{currentSortOrderLabel}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content class="w-56">
|
||||||
|
{#each sortOrderOptions as option}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => handleSortOrderChange(option.value)}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowUpDown class="h-4 w-4" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{#if sortOrder === option.value}
|
||||||
|
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">Price Range</Label>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DollarSign class="h-4 w-4" />
|
||||||
|
<span>{currentPriceFilterLabel}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content class="w-56">
|
||||||
|
{#each priceFilterOptions as option}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => handlePriceFilterChange(option.value)}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<DollarSign class="h-4 w-4" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{#if priceFilter === option.value}
|
||||||
|
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium">24h Change</Label>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<TrendingUp class="h-4 w-4" />
|
||||||
|
<span>{currentChangeFilterLabel}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content class="w-56">
|
||||||
|
{#each changeFilterOptions as option}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => handleChangeFilterChange(option.value)}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<TrendingUp class="h-4 w-4" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{#if changeFilter === option.value}
|
||||||
|
<div class="bg-primary ml-auto h-2 w-2 rounded-full"></div>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onclick={resetFilters} class="flex-1">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={applyFilters} class="flex-1">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<Button variant="outline" size="default" onclick={fetchMarketData} disabled={loading}>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Pagination Info -->
|
||||||
|
{#if !loading && totalCount > 0}
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div class="text-muted-foreground text-sm">
|
||||||
|
Showing {startIndex}-{endIndex} of {totalCount} coins
|
||||||
|
</div>
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<Button variant="link" size="sm" onclick={resetFilters} class="h-auto p-0">
|
||||||
|
Clear all filters
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Market Grid -->
|
||||||
|
{#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 class="text-muted-foreground">Fetching the latest coin prices and chaos levels</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if coins.length === 0}
|
||||||
|
<div class="flex h-96 items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4 text-xl">No coins found</div>
|
||||||
|
<div class="text-muted-foreground mb-4">
|
||||||
|
{#if searchQuery}
|
||||||
|
No coins match your search "{searchQuery}". Try different keywords or adjust filters.
|
||||||
|
{:else}
|
||||||
|
The market seems quiet... <a href="/coin/create" class="text-primary underline"
|
||||||
|
>create a coin</a
|
||||||
|
>? :)
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if hasActiveFilters}
|
||||||
|
<Button variant="outline" onclick={resetFilters}>Clear all filters</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{#each coins as coin, index}
|
||||||
|
{@const volatilityBadge = getVolatilityBadge(coin.change24h)}
|
||||||
|
{@const globalIndex = (currentPage - 1) * perPage + index + 1}
|
||||||
|
<Card.Root
|
||||||
|
class="group cursor-pointer gap-1 transition-all duration-200 hover:shadow-lg"
|
||||||
|
onclick={() => goto(`/coin/${coin.symbol}`)}
|
||||||
|
>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<CoinIcon icon={coin.icon} symbol={coin.symbol} size={8} />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold leading-tight">{coin.name}</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">*{coin.symbol}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-muted-foreground font-mono text-xs">#{globalIndex}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Price -->
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-2xl font-bold">
|
||||||
|
${formatPrice(coin.currentPrice)}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant={coin.change24h >= 0 ? 'success' : 'destructive'} class="text-xs">
|
||||||
|
{coin.change24h >= 0 ? '+' : ''}{coin.change24h.toFixed(2)}%
|
||||||
|
</Badge>
|
||||||
|
{#if volatilityBadge}
|
||||||
|
<Badge variant={volatilityBadge.variant} class="text-xs">
|
||||||
|
{volatilityBadge.text}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Market Cap</span>
|
||||||
|
<span class="font-mono">{formatMarketCap(coin.marketCap)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Volume (24h)</span>
|
||||||
|
<span class="font-mono">{formatMarketCap(coin.volume24h)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Created</span>
|
||||||
|
<span class="text-xs">{formatRelativeTime(coin.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<Pagination.Root
|
||||||
|
count={totalCount}
|
||||||
|
{perPage}
|
||||||
|
{siblingCount}
|
||||||
|
page={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
>
|
||||||
|
{#snippet children({ pages, currentPage: paginationCurrentPage })}
|
||||||
|
<Pagination.Content>
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
<span class="hidden sm:block">Previous</span>
|
||||||
|
</Pagination.PrevButton>
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type === 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Ellipsis />
|
||||||
|
</Pagination.Item>
|
||||||
|
{:else}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={paginationCurrentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton>
|
||||||
|
<span class="hidden sm:block">Next</span>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Pagination.NextButton>
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in a new issue