- 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
395 lines
No EOL
13 KiB
Vue
395 lines
No EOL
13 KiB
Vue
<template>
|
|
<div
|
|
class="token-card relative bg-base-100 hover:bg-base-200/50 transition-all duration-200 cursor-pointer border-b border-base-300 last:border-b-0"
|
|
:class="cardClass"
|
|
@click="$emit('click', token)"
|
|
>
|
|
<!-- Quick actions (visible on hover) -->
|
|
<div class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
|
|
<button
|
|
class="w-6 h-6 bg-base-300/90 hover:bg-error rounded text-base-content/60 hover:text-error-content transition-colors flex items-center justify-center text-xs"
|
|
title="Close token"
|
|
@click.stop="$emit('close', token)"
|
|
>
|
|
<Icon name="heroicons:x-mark" class="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
class="w-6 h-6 bg-base-300/90 hover:bg-base-300 rounded text-base-content/60 hover:text-primary transition-colors flex items-center justify-center text-xs"
|
|
title="Hide token"
|
|
@click.stop="$emit('hide', token)"
|
|
>
|
|
<Icon name="heroicons:eye-slash" class="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
class="w-6 h-6 bg-base-300/90 hover:bg-base-300 rounded text-base-content/60 hover:text-warning transition-colors flex items-center justify-center text-xs"
|
|
title="Watch token"
|
|
@click.stop="$emit('watch', token)"
|
|
>
|
|
<Icon name="heroicons:bookmark" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Quick buy button (bottom right) -->
|
|
<div class="absolute bottom-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
<button
|
|
class="bg-primary hover:bg-primary/80 text-primary-content px-2 py-1 rounded text-xs font-medium flex items-center gap-1 shadow-sm"
|
|
@click.stop="$emit('quick-buy', token)"
|
|
>
|
|
<Icon name="heroicons:bolt" class="w-3 h-3" />
|
|
Quick Buy
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div class="p-3 group">
|
|
<div class="flex items-start gap-3">
|
|
<!-- Token image/avatar -->
|
|
<div class="flex-shrink-0">
|
|
<div v-if="metadata?.image && !imageError" class="w-10 h-10 rounded-lg overflow-hidden bg-base-300 relative">
|
|
<img
|
|
:src="metadata.image"
|
|
:alt="token.name"
|
|
class="w-full h-full object-cover"
|
|
@error="handleImageError"
|
|
>
|
|
</div>
|
|
<div v-else-if="_metadataLoading" class="w-10 h-10 bg-base-300 rounded-lg flex items-center justify-center">
|
|
<div class="loading loading-spinner loading-xs text-primary" />
|
|
</div>
|
|
<div v-else-if="_metadataError" class="w-10 h-10 bg-error/20 rounded-lg flex items-center justify-center" :title="_metadataError">
|
|
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4 text-error" />
|
|
</div>
|
|
<div v-else class="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
|
|
<span class="text-primary-content font-bold text-sm">{{ getTokenSymbol(token)?.charAt(0) || '?' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Token info -->
|
|
<div class="flex-1 min-w-0">
|
|
<!-- Header with name and symbol -->
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<h3 class="font-semibold text-sm text-base-content truncate">{{ token.name }}</h3>
|
|
<span v-if="getTokenSymbol(token)" class="badge badge-primary badge-xs">{{ getTokenSymbol(token) }}</span>
|
|
</div>
|
|
|
|
<!-- Address -->
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs text-base-content/60 font-mono">{{ truncateAddress(getMintAddress()) }}</span>
|
|
<button
|
|
class="text-base-content/40 hover:text-primary transition-colors"
|
|
title="Copy address"
|
|
@click.stop="copyToClipboard(getMintAddress())"
|
|
>
|
|
<Icon name="heroicons:clipboard-document" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Type-specific info -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Type indicator -->
|
|
<span
|
|
class="badge badge-xs"
|
|
:class="typeClass"
|
|
>
|
|
{{ typeLabel }}
|
|
</span>
|
|
|
|
<!-- Creator info for new tokens -->
|
|
<a
|
|
v-if="type === 'new' && devAddress"
|
|
:href="`https://solscan.io/account/${devAddress}`"
|
|
target="_blank"
|
|
class="flex items-center gap-1 text-xs text-base-content/50 hover:text-primary transition-colors"
|
|
title="View creator on Solscan"
|
|
@click.stop
|
|
>
|
|
<Icon name="heroicons:user" class="w-3 h-3" />
|
|
{{ truncateAddress(devAddress) }}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Timestamp -->
|
|
<span class="text-xs text-base-content/50">
|
|
{{ formatTimeAgoUtil(getDisplayTimestamp(token), currentTime) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Social links (if available) -->
|
|
<div v-if="metadata && hasSocialLinks" class="flex items-center gap-1 mt-2">
|
|
<a
|
|
v-if="metadata.twitter"
|
|
:href="metadata.twitter"
|
|
target="_blank"
|
|
class="w-5 h-5 text-base-content/40 hover:text-info transition-colors"
|
|
title="Twitter"
|
|
@click.stop
|
|
>
|
|
<Icon name="simple-icons:x" class="w-3 h-3" />
|
|
</a>
|
|
<a
|
|
v-if="metadata.telegram"
|
|
:href="metadata.telegram"
|
|
target="_blank"
|
|
class="w-5 h-5 text-base-content/40 hover:text-info transition-colors"
|
|
title="Telegram"
|
|
@click.stop
|
|
>
|
|
<Icon name="simple-icons:telegram" class="w-3 h-3" />
|
|
</a>
|
|
<a
|
|
v-if="metadata.website"
|
|
:href="metadata.website"
|
|
target="_blank"
|
|
class="w-5 h-5 text-base-content/40 hover:text-info transition-colors"
|
|
title="Website"
|
|
@click.stop
|
|
>
|
|
<Icon name="heroicons:globe-alt" class="w-3 h-3" />
|
|
</a>
|
|
<a
|
|
v-if="metadata.discord"
|
|
:href="metadata.discord"
|
|
target="_blank"
|
|
class="w-5 h-5 text-base-content/40 hover:text-info transition-colors"
|
|
title="Discord"
|
|
@click.stop
|
|
>
|
|
<Icon name="simple-icons:discord" class="w-3 h-3" />
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Additional info for analysis type -->
|
|
<div v-if="(type === 'analysis' || type === 'dev') && 'node_count' in token" class="mt-2 text-xs text-base-content/60">
|
|
<span>{{ token.node_count }} nodes, {{ token.edge_count }} edges</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { isAddress } from '@solana/kit';
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { byteArrayToAddress, toSolanaAddress, truncateAddress as truncateAddr } from '~/utils/address';
|
|
import type {
|
|
MaxDepthReachedData,
|
|
NewTokenCreatedData,
|
|
TokenCexUpdatedData,
|
|
TokenMetadata
|
|
} from '../../types/redis-events';
|
|
import { formatTimeAgo as formatTimeAgoUtil, useRealTimeUpdate } from '../composables/useRealTimeUpdate';
|
|
import { fetchTokenMetadata } from '../utils/ipfs';
|
|
|
|
// Props
|
|
interface Props {
|
|
token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData;
|
|
type: 'new' | 'cex' | 'analysis' | 'dev';
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
// Emits
|
|
interface Emits {
|
|
(e: 'click' | 'hide' | 'watch' | 'quick-buy' | 'close', token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData): void;
|
|
}
|
|
|
|
defineEmits<Emits>();
|
|
|
|
// Reactive data
|
|
const imageError = ref(false);
|
|
const mintAddress = ref<string>('');
|
|
const devAddress = ref<string>('');
|
|
const bondingCurveAddress = ref<string>('');
|
|
|
|
// Simple metadata state management
|
|
const metadata = ref<TokenMetadata | null>(null);
|
|
const _metadataLoading = ref(false);
|
|
const _metadataError = ref<string | null>(null);
|
|
|
|
// Real-time updates
|
|
const { currentTime } = useRealTimeUpdate();
|
|
|
|
// Computed properties
|
|
const cardClass = computed(() => {
|
|
const baseClass = 'h-[120px] min-h-[120px]';
|
|
switch (props.type) {
|
|
case 'new':
|
|
return `${baseClass} border-l-2 border-l-success`;
|
|
case 'cex':
|
|
return `${baseClass} border-l-2 border-l-info`;
|
|
case 'analysis':
|
|
case 'dev':
|
|
return `${baseClass} border-l-2 border-l-warning`;
|
|
default:
|
|
return baseClass;
|
|
}
|
|
});
|
|
|
|
const typeClass = computed(() => {
|
|
switch (props.type) {
|
|
case 'new':
|
|
return 'badge-success';
|
|
case 'cex':
|
|
return 'badge-info';
|
|
case 'analysis':
|
|
case 'dev':
|
|
return 'badge-warning';
|
|
default:
|
|
return 'badge-neutral';
|
|
}
|
|
});
|
|
|
|
const typeLabel = computed(() => {
|
|
switch (props.type) {
|
|
case 'new':
|
|
return 'NEW';
|
|
case 'cex':
|
|
return 'CEX';
|
|
case 'analysis':
|
|
return 'ANALYSIS';
|
|
case 'dev':
|
|
return 'DEV';
|
|
default:
|
|
return 'UNKNOWN';
|
|
}
|
|
});
|
|
|
|
const hasSocialLinks = computed(() => {
|
|
return metadata.value && (
|
|
metadata.value.twitter ||
|
|
metadata.value.telegram ||
|
|
metadata.value.website ||
|
|
metadata.value.discord
|
|
);
|
|
});
|
|
|
|
// Methods
|
|
const truncateAddress = (address: string): string => {
|
|
return truncateAddr(address);
|
|
};
|
|
|
|
const getTokenSymbol = (token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData): string | undefined => {
|
|
if ('symbol' in token) {
|
|
return token.symbol;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const getMintAddress = (): string => {
|
|
return mintAddress.value;
|
|
};
|
|
|
|
const getDisplayTimestamp = (token: NewTokenCreatedData | TokenCexUpdatedData | MaxDepthReachedData): number => {
|
|
// For CEX and analysis types, prefer updated_at if available
|
|
if ((props.type === 'cex' || props.type === 'analysis') && 'updated_at' in token) {
|
|
return token.updated_at;
|
|
}
|
|
// For new tokens or fallback, use created_at
|
|
return token.created_at;
|
|
};
|
|
|
|
const copyToClipboard = async (text: string): Promise<void> => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
// You could add a toast notification here
|
|
} catch (err) {
|
|
console.error('Failed to copy to clipboard:', err);
|
|
}
|
|
};
|
|
|
|
const handleImageError = (): void => {
|
|
imageError.value = true;
|
|
};
|
|
|
|
function convertAddresses() {
|
|
try {
|
|
// Convert mint address
|
|
if (Array.isArray(props.token.mint)) {
|
|
mintAddress.value = byteArrayToAddress(props.token.mint);
|
|
} else if (typeof props.token.mint === 'string') {
|
|
if (isAddress(props.token.mint)) {
|
|
mintAddress.value = props.token.mint;
|
|
} else {
|
|
mintAddress.value = toSolanaAddress(props.token.mint);
|
|
}
|
|
}
|
|
|
|
// Convert creator address (only for NewTokenCreatedData)
|
|
if (props.type === 'new' && 'creator' in props.token) {
|
|
const token = props.token as NewTokenCreatedData;
|
|
if (Array.isArray(token.creator)) {
|
|
devAddress.value = byteArrayToAddress(token.creator);
|
|
} else if (typeof token.creator === 'string') {
|
|
if (isAddress(token.creator)) {
|
|
devAddress.value = token.creator;
|
|
} else {
|
|
devAddress.value = toSolanaAddress(token.creator);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert bonding curve address (NewTokenCreatedData and MaxDepthReachedData)
|
|
if ('bonding_curve' in props.token && props.token.bonding_curve) {
|
|
const token = props.token as NewTokenCreatedData | MaxDepthReachedData;
|
|
if (Array.isArray(token.bonding_curve)) {
|
|
bondingCurveAddress.value = byteArrayToAddress(token.bonding_curve);
|
|
} else if (typeof token.bonding_curve === 'string') {
|
|
if (isAddress(token.bonding_curve)) {
|
|
bondingCurveAddress.value = token.bonding_curve;
|
|
} else {
|
|
bondingCurveAddress.value = toSolanaAddress(token.bonding_curve);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error converting addresses:', error);
|
|
// Fallback to string representation
|
|
mintAddress.value = String(props.token.mint);
|
|
if (props.type === 'new' && 'creator' in props.token) {
|
|
devAddress.value = String((props.token as NewTokenCreatedData).creator);
|
|
}
|
|
if ('bonding_curve' in props.token && props.token.bonding_curve) {
|
|
bondingCurveAddress.value = String((props.token as NewTokenCreatedData | MaxDepthReachedData).bonding_curve);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load metadata and convert addresses on mount
|
|
onMounted(async () => {
|
|
// Convert addresses first
|
|
convertAddresses();
|
|
|
|
// Then load metadata if URI exists
|
|
if (props.token.uri) {
|
|
_metadataLoading.value = true;
|
|
_metadataError.value = null;
|
|
|
|
try {
|
|
const result = await fetchTokenMetadata(props.token.uri);
|
|
metadata.value = result;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch metadata';
|
|
_metadataError.value = errorMessage;
|
|
} finally {
|
|
_metadataLoading.value = false;
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.token-card {
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.token-card:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.group:hover .opacity-0 {
|
|
opacity: 1;
|
|
}
|
|
</style> |