This commit is contained in:
MD1125 2025-06-15 20:52:26 +02:00
commit d066bd9be1
31 changed files with 6196 additions and 109 deletions

View file

@ -42,8 +42,7 @@ export const auth = betterAuth({
s3ImageKey = await uploadProfilePicture(
profile.sub,
new Uint8Array(arrayBuffer),
blob.type,
blob.size
blob.type || 'image/jpeg'
);
}
} catch (error) {

View file

@ -31,7 +31,8 @@
Hammer,
BookOpen,
Info,
Bell
Bell,
Crown
} from 'lucide-svelte';
import { mode, setMode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@ -85,7 +86,7 @@
function handleModeToggle() {
setMode(mode.current === 'light' ? 'dark' : 'light');
setOpenMobile(false);
// Remove setOpenMobile(false) to keep menu open
}
function formatCurrency(value: number): string {
@ -152,6 +153,11 @@
showUserManual = true;
setOpenMobile(false);
}
function handlePrestigeClick() {
goto('/prestige');
setOpenMobile(false);
}
</script>
<SignInConfirmDialog bind:open={shouldSignIn} />
@ -195,22 +201,6 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props }: { props: MenuButtonProps })}
<button onclick={handleModeToggle} {...props}>
{#if mode.current === 'light'}
<Moon class="h-5 w-5" />
<span>Dark Mode</span>
{:else}
<Sun class="h-5 w-5" />
<span>Light Mode</span>
{/if}
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
@ -421,6 +411,8 @@
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<!-- Profile & Settings Group -->
<DropdownMenu.Group>
<DropdownMenu.Item onclick={handleAccountClick}>
<User />
@ -430,10 +422,16 @@
<Settings />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleUserManualClick}>
<BookOpen />
User Manual
<DropdownMenu.Item onclick={handlePrestigeClick}>
<Crown />
Prestige
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<!-- Features Group -->
<DropdownMenu.Group>
<DropdownMenu.Item
onclick={() => {
showPromoCode = true;
@ -443,10 +441,24 @@
<Gift />
Promo code
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleUserManualClick}>
<BookOpen />
User Manual
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleModeToggle}>
{#if mode.current === 'light'}
<Moon />
Dark Mode
{:else}
<Sun />
Light Mode
{/if}
</DropdownMenu.Item>
</DropdownMenu.Group>
{#if $USER_DATA?.isAdmin}
<DropdownMenu.Separator />
<!-- Admin Group -->
<DropdownMenu.Group>
<DropdownMenu.Item
onclick={handleAdminClick}
@ -471,8 +483,11 @@
</DropdownMenu.Item>
</DropdownMenu.Group>
{/if}
<DropdownMenu.Separator />
<!-- Legal Group -->
<DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleTermsClick}>
<Scale />
Terms of Service
@ -482,7 +497,10 @@
Privacy Policy
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<!-- Sign Out -->
<DropdownMenu.Item
onclick={() => {
signOut().then(() => {

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { UserProfile } from '$lib/types/user-profile';
import SilentBadge from './SilentBadge.svelte';
import { Hash, Hammer, Flame } from 'lucide-svelte';
import { Hash, Hammer, Flame, Star } from 'lucide-svelte';
import { getPrestigeName, getPrestigeColor } from '$lib/utils';
let {
user,
@ -14,14 +15,23 @@
} = $props();
let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
let prestigeName = $derived(user.prestigeLevel ? getPrestigeName(user.prestigeLevel) : null);
let prestigeColor = $derived(user.prestigeLevel ? getPrestigeColor(user.prestigeLevel) : 'text-gray-500');
</script>
<div class="flex items-center gap-1">
{#if showId}
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
{/if}
{#if prestigeName}
<SilentBadge icon={Star} text={prestigeName} class="{prestigeColor} {badgeClass}" />
{/if}
{#if user.loginStreak && user.loginStreak > 1}
<SilentBadge icon={Flame} text="{user.loginStreak} day streak" class="text-orange-500 {badgeClass}" />
<SilentBadge
icon={Flame}
text="{user.loginStreak} day streak"
class="text-orange-500 {badgeClass}"
/>
{/if}
{#if user.isAdmin}
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />

View file

@ -0,0 +1,150 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Avatar from '$lib/components/ui/avatar';
import { Star } from 'lucide-svelte';
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Main Content Column -->
<div class="flex flex-col lg:col-span-2">
<!-- How Card Skeleton -->
<Card.Root class="mb-6 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">How</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
{#each Array(3) as _, i}
<div class="flex gap-3">
<div
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
>
{i + 1}
</div>
<div class="flex-1 space-y-2">
<Skeleton class="h-4 w-24" />
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-3/4" />
</div>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Progress Card Skeleton -->
<Card.Root class="flex flex-1 flex-col gap-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Star class="h-5 w-5" />
Progress
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-1 flex-col space-y-6">
<!-- Progress Section -->
<div class="space-y-6">
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-12" />
</div>
<Skeleton class="h-2 w-full rounded-full" />
</div>
<!-- Financial Details Table Skeleton -->
<div class="overflow-hidden rounded-xl border">
<table class="w-full text-sm">
<tbody class="divide-y">
{#each Array(3) as _}
<tr>
<td class="px-3 py-2">
<Skeleton class="h-4 w-20" />
</td>
<td class="px-3 py-2 text-right">
<Skeleton class="ml-auto h-4 w-24" />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Skeleton class="h-4 w-40" />
<!-- Prestige Button Skeleton -->
<Skeleton class="h-12 w-full rounded-lg" />
</Card.Content>
</Card.Root>
</div>
<!-- Right Column - Info -->
<div class="flex flex-col space-y-4">
<!-- Profile Preview Card Skeleton -->
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Preview</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<!-- Current Profile -->
<div class="space-y-2">
<Skeleton class="h-3 w-12" />
<div class="flex items-center gap-3 rounded-lg border p-3">
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Fallback>
<Skeleton class="h-full w-full rounded-full" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-center gap-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-16" />
</div>
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
<!-- Prestige Preview -->
<div class="space-y-2">
<Skeleton class="h-3 w-10" />
<div
class="flex items-center gap-3 rounded-lg border-2 border-yellow-500/30 bg-yellow-50/50 p-3 dark:bg-yellow-950/20"
>
<Avatar.Root class="h-10 w-10 shrink-0">
<Avatar.Fallback>
<Skeleton class="h-full w-full rounded-full" />
</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-center gap-2">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-20" />
</div>
<Skeleton class="h-3 w-16" />
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- All Prestige Levels Skeleton -->
<Card.Root class="flex-1 gap-1">
<Card.Header class="pb-2">
<Card.Title class="text-base">Levels</Card.Title>
</Card.Header>
<Card.Content class="space-y-1">
{#each Array(5) as _}
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2">
<Skeleton class="h-4 w-4" />
<Skeleton class="h-4 w-20" />
</div>
<Skeleton class="h-3 w-16" />
</div>
{/each}
</Card.Content>
</Card.Root>
</div>
</div>

View file

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

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { Progress as ProgressPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
data-slot="progress"
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{value}
{max}
{...restProps}
>
<div
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
></div>
</ProgressPrimitive.Root>

View file

@ -31,7 +31,8 @@ export const user = pgTable("user", {
precision: 20,
scale: 8,
}).notNull().default("0.00000000"),
loginStreak: integer("login_streak").notNull().default(0)
loginStreak: integer("login_streak").notNull().default(0),
prestigeLevel: integer("prestige_level").default(0),
});
export const session = pgTable("session", {
@ -190,6 +191,7 @@ export const predictionQuestion = pgTable("prediction_question", {
creatorIdIdx: index("prediction_question_creator_id_idx").on(table.creatorId),
statusIdx: index("prediction_question_status_idx").on(table.status),
resolutionDateIdx: index("prediction_question_resolution_date_idx").on(table.resolutionDate),
statusResolutionIdx: index("prediction_question_status_resolution_idx").on(table.status, table.resolutionDate),
};
});

View file

@ -0,0 +1,39 @@
import sharp from 'sharp';
const MAX_SIZE = 128;
const WEBP_QUALITY = 50;
export interface ProcessedImage {
buffer: Buffer;
contentType: string;
size: number;
}
export async function processImage(
inputBuffer: Buffer,
): Promise<ProcessedImage> {
try {
const image = sharp(inputBuffer, { animated: true });
const processedBuffer = await image
.resize(MAX_SIZE, MAX_SIZE, {
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 6
})
.toBuffer();
return {
buffer: processedBuffer,
contentType: 'image/webp',
size: processedBuffer.length
};
} catch (error) {
console.error('Image processing failed:', error);
throw new Error('Failed to process image');
}
}

View file

@ -38,7 +38,91 @@ export async function resolveExpiredQuestions() {
);
if (resolution.confidence < 50) {
console.log(`Skipping question ${question.id} due to low confidence: ${resolution.confidence}`);
console.log(`Cancelling question ${question.id} due to low confidence: ${resolution.confidence}`);
await db.transaction(async (tx) => {
// Mark question as cancelled
await tx
.update(predictionQuestion)
.set({
status: 'CANCELLED',
resolvedAt: now,
})
.where(eq(predictionQuestion.id, question.id));
// Get all bets for this question
const bets = await tx
.select({
id: predictionBet.id,
userId: predictionBet.userId,
side: predictionBet.side,
amount: predictionBet.amount,
})
.from(predictionBet)
.where(and(
eq(predictionBet.questionId, question.id),
isNull(predictionBet.settledAt)
));
const notificationsToCreate: Array<{
userId: number;
amount: number;
}> = [];
// Refund all bets
for (const bet of bets) {
const refundAmount = Number(bet.amount);
// Mark bet as settled with full refund
await tx
.update(predictionBet)
.set({
actualWinnings: refundAmount.toFixed(8),
settledAt: now,
})
.where(eq(predictionBet.id, bet.id));
// Refund the user
if (bet.userId !== null) {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, bet.userId))
.limit(1);
if (userData) {
const newBalance = Number(userData.baseCurrencyBalance) + refundAmount;
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: now,
})
.where(eq(user.id, bet.userId));
}
notificationsToCreate.push({
userId: bet.userId,
amount: refundAmount
});
}
}
// Create refund notifications for all users who had bets
for (const notifData of notificationsToCreate) {
const { userId, amount } = notifData;
const title = 'Prediction skipped 🥀';
const message = `You received a full refund of ${formatValue(amount)} for "${question.question}". We recommend betting on more reliable predictions!`;
await createNotification(
userId.toString(),
'HOPIUM',
title,
message,
);
}
});
continue;
}
@ -139,7 +223,6 @@ export async function resolveExpiredQuestions() {
}
});
console.log(`Successfully resolved question ${question.id}: ${resolution.resolution ? 'YES' : 'NO'} (confidence: ${resolution.confidence}%)`);
} catch (error) {
console.error(`Failed to resolve question ${question.id}:`, error);
}

View file

@ -2,6 +2,7 @@ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } fro
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';
import { processImage } from './image.js';
const s3Client = new S3Client({
endpoint: PUBLIC_B2_ENDPOINT,
@ -47,7 +48,6 @@ 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> {
if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.');
@ -58,17 +58,16 @@ export async function uploadProfilePicture(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
}
let fileExtension = contentType.split('/')[1];
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const key = `avatars/${identifier}.${fileExtension}`;
const processedImage = await processImage(Buffer.from(body));
const key = `avatars/${identifier}.webp`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
Body: processedImage.buffer,
ContentType: processedImage.contentType,
ContentLength: processedImage.size,
});
await s3Client.send(command);
@ -79,7 +78,6 @@ export async function uploadCoinIcon(
coinSymbol: string,
body: Uint8Array,
contentType: string,
contentLength?: number
): Promise<string> {
if (!contentType || !contentType.startsWith('image/')) {
throw new Error('Invalid file type. Only images are allowed.');
@ -90,17 +88,16 @@ export async function uploadCoinIcon(
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
}
let fileExtension = contentType.split('/')[1];
if (fileExtension === 'jpeg') fileExtension = 'jpg';
const processedImage = await processImage(Buffer.from(body));
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
const key = `coins/${coinSymbol.toLowerCase()}.webp`;
const command = new PutObjectCommand({
Bucket: PUBLIC_B2_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(contentLength && { ContentLength: contentLength }),
Body: processedImage.buffer,
ContentType: processedImage.contentType,
ContentLength: processedImage.size,
});
await s3Client.send(command);

View file

@ -16,6 +16,8 @@ export type User = {
volumeMaster: number;
volumeMuted: boolean;
prestigeLevel: number;
} | null;
export const USER_DATA = writable<User>(undefined);

View file

@ -9,6 +9,8 @@ export interface UserProfile {
isAdmin: boolean;
totalPortfolioValue: number;
loginStreak: number;
prestigeLevel: number | null;
}
export interface UserStats {

View file

@ -335,4 +335,50 @@ export const formatMarketCap = formatValue;
export function timeToLocal(originalTime: number): number {
const d = new Date(originalTime * 1000);
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000);
}
}
export const PRESTIGE_COSTS = {
1: 100_000,
2: 250_000,
3: 1_000_000,
4: 5_000_000,
5: 25_000_000
} as const;
export const PRESTIGE_NAMES = {
1: 'Prestige I',
2: 'Prestige II',
3: 'Prestige III',
4: 'Prestige IV',
5: 'Prestige V'
} as const;
export const PRESTIGE_COLORS = {
1: 'text-blue-500',
2: 'text-purple-500',
3: 'text-yellow-500',
4: 'text-orange-500',
5: 'text-red-500'
} as const;
export function getPrestigeName(level: number): string | null {
if (level <= 0) return null;
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_NAMES;
return PRESTIGE_NAMES[clampedLevel];
}
export function getPrestigeCost(level: number): number | null {
if (level <= 0) return null;
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COSTS;
return PRESTIGE_COSTS[clampedLevel];
}
export function getPrestigeColor(level: number): string {
if (level <= 0) return 'text-gray-500';
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COLORS;
return PRESTIGE_COLORS[clampedLevel];
}
export function getMaxPrestigeLevel(): number {
return 5;
}