Ziya/app/pages/hunting-ground.vue
rizary 67fb3a203e
feat: implement CEX analysis cards and real-time token monitoring
- Add TokenCard and CexAnalysisCard components for displaying token data
- Implement real-time Redis event streaming for token updates
- Add environment-based configuration system for dev/prod Redis servers
- Create comprehensive hunting ground dashboard with card management
- Add individual and bulk card removal functionality
- Implement browser integration for token details viewing
- Add timestamp utilities and proper type handling for Redis events
- Create production-ready configuration with 154.38.185.112 Redis server
- Add comprehensive documentation in README.md and CONTRIBUTORS.md
- Restructure project architecture with proper Electron-Vue integration

BREAKING CHANGE: Redis configuration now uses environment-based settings
2025-06-23 09:03:39 +07:00

476 lines
No EOL
17 KiB
Vue

<template>
<div class="hunting-ground h-screen flex bg-base-100 overflow-hidden">
<!-- Professional Sidebar -->
<aside class="w-64 bg-base-200 border-r border-base-300 flex flex-col shadow-lg">
<!-- Sidebar Header -->
<div class="p-4 border-b border-base-300">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Icon name="heroicons:chart-bar-square" class="w-5 h-5 text-primary-content" />
</div>
<div>
<h1 class="font-semibold text-base text-base-content">Hunting Ground</h1>
<p class="text-xs text-base-content/60">Real-time discovery</p>
</div>
</div>
</div>
<!-- Navigation Menu -->
<nav class="flex-1 p-3">
<div class="space-y-1">
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg bg-primary text-primary-content"
>
<Icon name="heroicons:magnifying-glass" class="w-4 h-4" />
Token Discovery
</button>
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg text-base-content/70 hover:bg-base-300 transition-colors"
@click="navigateToDashboard"
>
<Icon name="heroicons:squares-2x2" class="w-4 h-4" />
Dashboard
</button>
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg text-base-content/70 hover:bg-base-300 transition-colors"
>
<Icon name="heroicons:chart-pie" class="w-4 h-4" />
Analytics
</button>
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg text-base-content/70 hover:bg-base-300 transition-colors"
>
<Icon name="heroicons:bookmark" class="w-4 h-4" />
Watchlist
</button>
</div>
<!-- Stats Section -->
<div class="mt-6 p-3 bg-base-300/50 rounded-lg">
<h3 class="text-xs font-semibold text-base-content/70 uppercase tracking-wider mb-2">Live Stats</h3>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-base-content/60">New Tokens</span>
<span class="font-medium text-success">{{ newTokens.length }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">CEX Updates</span>
<span class="font-medium text-info">{{ cexTokens.length }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Analysis Done</span>
<span class="font-medium text-warning">{{ maxDepthTokens.length }}</span>
</div>
</div>
</div>
</nav>
<!-- User Section -->
<div class="p-3 border-t border-base-300">
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-300 transition-colors cursor-pointer">
<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center">
<span class="text-xs font-bold text-primary-content">U</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content truncate">User</p>
<p class="text-xs text-base-content/60">Connected</p>
</div>
<button
class="p-1 rounded hover:bg-base-200 transition-colors"
title="Logout"
@click="handleLogout"
>
<Icon name="heroicons:arrow-right-on-rectangle" class="w-4 h-4 text-base-content/60" />
</button>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col overflow-hidden">
<!-- Top Bar -->
<header class="bg-base-100 border-b border-base-300 px-6 py-3 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-4">
<h2 class="text-lg font-semibold text-base-content">Token Streams</h2>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-success rounded-full animate-pulse" />
<span class="text-sm text-base-content/60">Live</span>
</div>
</div>
<div class="flex items-center gap-3">
<button class="btn btn-ghost btn-sm">
<Icon name="heroicons:funnel" class="w-4 h-4" />
Filters
</button>
<button class="btn btn-ghost btn-sm">
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4" />
Settings
</button>
</div>
</header>
<!-- Content Grid -->
<div class="flex-1 grid grid-cols-3 gap-0 overflow-hidden">
<!-- New Tokens Column -->
<section class="flex flex-col border-r border-base-300">
<header class="bg-base-200/50 px-4 py-3 border-b border-base-300 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-success rounded-full" />
<h3 class="font-semibold text-sm text-base-content">New Tokens</h3>
</div>
<div class="flex items-center gap-2">
<button
v-if="newTokens.length > 0"
class="text-xs text-base-content/60 hover:text-error transition-colors underline"
title="Clear all new tokens"
@click="clearAllNewTokens"
>
Clear All
</button>
<span class="badge badge-success badge-sm">{{ newTokens.length }}</span>
</div>
</header>
<div class="flex-1 overflow-y-auto">
<div class="divide-y divide-base-300">
<TokenCard
v-for="token in newTokens"
:key="byteArrayToAddress(token.mint)"
:token="token"
type="new"
@click="openToken"
@hide="hideToken"
@watch="watchToken"
@quick-buy="quickBuyToken"
@close="removeNewToken"
/>
<div v-if="newTokens.length === 0" class="p-8 text-center text-base-content/60">
<Icon name="heroicons:plus-circle" class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="font-medium">Waiting for new tokens</p>
<p class="text-sm mt-1">New tokens will appear here</p>
</div>
</div>
</div>
</section>
<!-- CEX Tokens Column -->
<section class="flex flex-col border-r border-base-300">
<header class="bg-base-200/50 px-4 py-3 border-b border-base-300 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-info rounded-full" />
<h3 class="font-semibold text-sm text-base-content">CEX Updates</h3>
</div>
<div class="flex items-center gap-2">
<button
v-if="cexTokens.length > 0"
class="text-xs text-base-content/60 hover:text-error transition-colors underline"
title="Clear all CEX tokens"
@click="clearAllCexTokens"
>
Clear All
</button>
<span class="badge badge-info badge-sm">{{ cexTokens.length }}</span>
</div>
</header>
<div class="flex-1 overflow-y-auto">
<div class="divide-y divide-base-300">
<CexAnalysisCard
v-for="token in cexTokens"
:key="token.mint"
:token="token.data"
@click="openToken"
@hide="hideToken"
@watch="watchToken"
@quick-buy="quickBuyToken"
@close="removeCexToken"
/>
<div v-if="cexTokens.length === 0" class="p-8 text-center text-base-content/60">
<Icon name="heroicons:building-office" class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="font-medium">No CEX updates yet</p>
<p class="text-sm mt-1">CEX listings will appear here</p>
</div>
</div>
</div>
</section>
<!-- Analysis Complete Column -->
<section class="flex flex-col">
<header class="bg-base-200/50 px-4 py-3 border-b border-base-300 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 bg-warning rounded-full" />
<h3 class="font-semibold text-sm text-base-content">Analysis Complete</h3>
</div>
<div class="flex items-center gap-2">
<button
v-if="maxDepthTokens.length > 0"
class="text-xs text-base-content/60 hover:text-error transition-colors underline"
title="Clear all analysis complete tokens"
@click="clearAllMaxDepthTokens"
>
Clear All
</button>
<span class="badge badge-warning badge-sm">{{ maxDepthTokens.length }}</span>
</div>
</header>
<div class="flex-1 overflow-y-auto">
<div class="divide-y divide-base-300">
<CexAnalysisCard
v-for="token in maxDepthTokens"
:key="token.mint"
:token="token.data"
@click="openToken"
@hide="hideToken"
@watch="watchToken"
@quick-buy="quickBuyToken"
@close="removeMaxDepthToken"
/>
<div v-if="maxDepthTokens.length === 0" class="p-8 text-center text-base-content/60">
<Icon name="heroicons:cpu-chip" class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="font-medium">No analysis complete yet</p>
<p class="text-sm mt-1">Completed analyses will appear here</p>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { isAddress } from '@solana/kit';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { byteArrayToAddress, toSolanaAddress } from '~/utils/address';
import type {
MaxDepthReachedData,
NewTokenCreatedData,
RedisMessage,
TokenCexUpdatedData
} from '../../types/redis-events';
import CexAnalysisCard from '../components/CexAnalysisCard.vue';
import TokenCard from '../components/TokenCard.vue';
import { useAppStore } from '../stores/app';
const appStore = useAppStore();
const router = useRouter();
// Reactive data for three columns
const newTokens = ref<NewTokenCreatedData[]>([]);
const cexTokens = ref<Array<{ mint: string; data: TokenCexUpdatedData }>>([]);
const maxDepthTokens = ref<Array<{ mint: string; data: MaxDepthReachedData }>>([]);
// Redirect if not authenticated
onMounted(() => {
if (!appStore.isAuthenticated) {
router.push('/login');
}
// Set up Redis pubsub listener
if (window.electronAPI) {
window.electronAPI.onRedisData(handleRedisMessage);
}
});
onUnmounted(() => {
// Clean up Redis listener
if (window.electronAPI) {
window.electronAPI.removeRedisDataListener();
}
});
const handleRedisMessage = (message: RedisMessage) => {
switch (message.channel) {
case 'new_token_created':
addNewToken(message.data as NewTokenCreatedData);
break;
case 'token_cex_updated':
addCexToken(message.data as TokenCexUpdatedData);
break;
case 'max_depth_reached':
addMaxDepthToken(message.data as MaxDepthReachedData);
break;
}
};
const addNewToken = (data: NewTokenCreatedData) => {
// Add to front of array (latest first) - data is already properly typed
newTokens.value.unshift(data);
// Keep only latest 100 items for performance
if (newTokens.value.length > 100) {
newTokens.value = newTokens.value.slice(0, 100);
}
};
const addCexToken = (data: TokenCexUpdatedData) => {
// Add to front of array (latest first)
cexTokens.value.unshift({
mint: data.mint, // mint is already a string
data,
});
// Keep only latest 100 items for performance
if (cexTokens.value.length > 100) {
cexTokens.value = cexTokens.value.slice(0, 100);
}
};
const addMaxDepthToken = (data: MaxDepthReachedData) => {
// Add to front of array (latest first)
maxDepthTokens.value.unshift({
mint: data.mint, // mint is already a string
data,
});
// Keep only latest 100 items for performance
if (maxDepthTokens.value.length > 100) {
maxDepthTokens.value = maxDepthTokens.value.slice(0, 100);
}
};
// Event handlers for TokenCard
const openToken = (token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData) => {
try {
let bondingCurveAddress = '';
// Always prioritize bonding curve address
if ('bonding_curve' in token && token.bonding_curve) {
if (Array.isArray(token.bonding_curve)) {
bondingCurveAddress = byteArrayToAddress(token.bonding_curve);
} else if (typeof token.bonding_curve === 'string') {
if (isAddress(token.bonding_curve)) {
bondingCurveAddress = token.bonding_curve;
} else {
bondingCurveAddress = toSolanaAddress(token.bonding_curve);
}
}
}
if (bondingCurveAddress) {
const axiomUrl = `https://axiom.trade/meme/${bondingCurveAddress}`;
// Use Electron API to open external URL
if (window.electronAPI) {
window.electronAPI.openExternal(axiomUrl);
} else {
// Fallback for development or non-Electron environment
window.open(axiomUrl, '_blank');
}
} else {
console.warn('No bonding curve address found for token:', token);
}
} catch (error) {
console.error('Error opening token in Axiom:', error);
}
};
const hideToken = (_token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData) => {
// TODO: Implement hide functionality
};
const watchToken = (_token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData) => {
// TODO: Implement watch functionality
};
const quickBuyToken = (_token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData) => {
// TODO: Implement quick buy functionality
};
// Navigation handlers
const handleLogout = async () => {
await appStore.logout();
router.push('/login');
};
const navigateToDashboard = () => {
router.push('/dashboard');
};
// Clear all functions (performance optimized)
const clearAllNewTokens = () => {
newTokens.value.length = 0; // Fastest way to clear array
};
const clearAllCexTokens = () => {
cexTokens.value.length = 0;
};
const clearAllMaxDepthTokens = () => {
maxDepthTokens.value.length = 0;
};
// Individual remove functions (using findIndex for performance)
const removeNewToken = (token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData) => {
// Type guard to ensure it's a NewTokenCreatedData
if ('symbol' in token && Array.isArray(token.mint)) {
const mintAddr = byteArrayToAddress(token.mint);
const index = newTokens.value.findIndex(t => byteArrayToAddress(t.mint) === mintAddr);
if (index > -1) {
newTokens.value.splice(index, 1);
}
}
};
const removeCexToken = (token: TokenCexUpdatedData | MaxDepthReachedData) => {
// Type guard to ensure it's a TokenCexUpdatedData (doesn't have bonding_curve)
if (!('bonding_curve' in token)) {
const index = cexTokens.value.findIndex(t => t.mint === token.mint);
if (index > -1) {
cexTokens.value.splice(index, 1);
}
}
};
const removeMaxDepthToken = (token: TokenCexUpdatedData | MaxDepthReachedData) => {
// Type guard to ensure it's a MaxDepthReachedData (has bonding_curve)
if ('bonding_curve' in token) {
const index = maxDepthTokens.value.findIndex(t => t.mint === token.mint);
if (index > -1) {
maxDepthTokens.value.splice(index, 1);
}
}
};
</script>
<style scoped>
/* Professional scrollbar styling */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.15);
border-radius: 3px;
transition: background-color 0.2s;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.25);
}
/* Ensure proper grid layout */
.grid-cols-3 > section {
min-height: 0;
}
/* Smooth transitions */
* {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
</style>