- 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
476 lines
No EOL
17 KiB
Vue
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>
|
|
|