mlstocks / frontend /src /components /SearchModal.vue
github-actions[bot]
Deploy to Hugging Face Space
abf702c
<script setup>
import {
Search,
History,
Plus,
Star,
TrendingUp,
X,
ArrowUp,
ArrowDown,
Activity,
Zap,
ChevronRight
} from 'lucide-vue-next'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { api } from '../services/api'
const props = defineProps({
isOpen: Boolean
})
const emit = defineEmits(['close', 'added', 'analyze'])
const activeTab = ref('Popular')
const tabs = [
{ name: 'Recent', icon: History },
{ name: 'Watchlist', icon: Star },
{ name: 'Popular', icon: TrendingUp }
]
const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)
const selectedIndex = ref(-1)
const POPULAR_SYMBOLS = [
{ symbol: 'NVDA', name: 'NVIDIA Corporation', exchDisp: 'NASDAQ' },
{ symbol: 'TSLA', name: 'Tesla, Inc.', exchDisp: 'NASDAQ' },
{ symbol: 'AAPL', name: 'Apple Inc.', exchDisp: 'NASDAQ' },
{ symbol: 'AMZN', name: 'Amazon.com, Inc.', exchDisp: 'NASDAQ' },
{ symbol: 'BTC-USD', name: 'Bitcoin USD', exchDisp: 'BTC' },
{ symbol: 'PLTR', name: 'Palantir Technologies Inc.', exchDisp: 'NYSE' },
{ symbol: 'MSFT', name: 'Microsoft Corporation', exchDisp: 'NASDAQ' },
{ symbol: 'SPY', name: 'SPDR S&P 500 ETF', exchDisp: 'NYSE' }
]
const recentSearches = ref(JSON.parse(localStorage.getItem('recent_searches') || '[]'))
const watchlistItems = ref([])
const fetchWatchlist = async () => {
try {
const data = await api.get('/user/watchlist')
watchlistItems.value = data || []
} catch (error) {
console.error('Failed to fetch watchlist:', error)
}
}
const performSearch = async (query) => {
if (!query.trim()) {
searchResults.value = []
selectedIndex.value = -1
return
}
isLoading.value = true
try {
const data = await api.get(`/search?q=${encodeURIComponent(query)}`)
if (data.results) {
searchResults.value = data.results
selectedIndex.value = searchResults.value.length > 0 ? 0 : -1
}
} catch (error) {
console.error('Search failed:', error)
} finally {
isLoading.value = false
}
}
const addToWatchlist = async (item) => {
try {
await api.post('/user/watchlist', {
symbol: item.symbol,
name: item.name,
exchange: item.exchDisp || item.exchange || 'Unknown'
})
// Add to recent
const exists = recentSearches.value.find(s => s.symbol === item.symbol)
if (!exists) {
recentSearches.value.unshift({ symbol: item.symbol, name: item.name, exchDisp: item.exchDisp || item.exchange })
recentSearches.value = recentSearches.value.slice(0, 10)
localStorage.setItem('recent_searches', JSON.stringify(recentSearches.value))
}
emit('added')
closeModal()
} catch (error) {
console.error('Error adding to watchlist:', error)
}
}
const startAnalysis = (item) => {
emit('analyze', item.symbol)
closeModal()
}
const clearSearch = () => {
searchQuery.value = ''
searchResults.value = []
selectedIndex.value = -1
}
let debounceTimer = null
watch(searchQuery, (newQuery) => {
clearTimeout(debounceTimer)
if (newQuery) {
debounceTimer = setTimeout(() => {
performSearch(newQuery)
}, 300)
} else {
searchResults.value = []
selectedIndex.value = -1
}
})
watch(activeTab, (newTab) => {
selectedIndex.value = -1
if (newTab === 'Watchlist') {
fetchWatchlist()
}
})
const currentDisplayList = computed(() => {
if (searchQuery.value) return searchResults.value
if (activeTab.value === 'Popular') return POPULAR_SYMBOLS
if (activeTab.value === 'Watchlist') return watchlistItems.value
if (activeTab.value === 'Recent') return recentSearches.value
return []
})
const closeModal = () => {
searchQuery.value = ''
searchResults.value = []
emit('close')
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') closeModal()
const list = currentDisplayList.value
if (list.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % list.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = (selectedIndex.value - 1 + list.length) % list.length
} else if (e.key === 'Enter' && selectedIndex.value !== -1) {
e.preventDefault()
addToWatchlist(list[selectedIndex.value])
} else if (e.key === 'Tab') {
e.preventDefault()
const currentIndex = tabs.findIndex(t => t.name === activeTab.value)
activeTab.value = tabs[(currentIndex + 1) % tabs.length].name
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
if (activeTab.value === 'Watchlist') fetchWatchlist()
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-[100] flex items-start md:items-center justify-center md:p-4 overflow-hidden">
<!-- BACKDROP -->
<div class="absolute inset-0 bg-slate-950/95 backdrop-blur-md" @click="closeModal"></div>
<!-- MODAL CONTAINER -->
<div class="relative w-full h-full md:h-auto md:max-w-3xl bg-[#0b1120] border-0 md:border md:border-slate-800 md:rounded-[2rem] shadow-2xl overflow-hidden flex flex-col md:max-h-[85vh]">
<!-- MODAL HEADER / SEARCH BAR -->
<div class="p-4 md:p-8 bg-slate-900/40 border-b border-slate-800">
<div class="flex items-center gap-4 mb-6 md:hidden">
<button @click="closeModal" class="p-2 -ml-2 text-slate-400 hover:text-white"><X class="w-6 h-6" /></button>
<h2 class="text-xl font-black text-white tracking-tight">Intelligence Search</h2>
</div>
<div class="relative group">
<div class="absolute left-5 top-1/2 -translate-y-1/2 flex items-center z-10">
<Search v-if="!isLoading" class="w-5 h-5 md:w-6 md:h-6 text-blue-500" />
<div v-else class="w-5 h-5 md:w-6 md:h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search symbols or assets..."
autofocus
class="w-full bg-slate-950 border-2 border-slate-800 group-hover:border-blue-500/30 focus:border-blue-500 rounded-2xl md:rounded-3xl py-4 md:py-6 pl-14 md:pl-16 pr-14 text-lg md:text-2xl text-white placeholder:text-slate-600 focus:outline-none focus:ring-4 focus:ring-blue-500/10 transition-all font-black tracking-tight"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="absolute right-5 top-1/2 -translate-y-1/2 p-2 bg-slate-800 hover:bg-slate-700 rounded-xl transition-all"
>
<X class="w-4 h-4 text-slate-200" />
</button>
</div>
</div>
<!-- TABS (Visible when no query) -->
<div v-if="!searchQuery" class="flex px-4 md:px-8 border-b border-slate-800 bg-slate-950/20 overflow-x-auto custom-scrollbar">
<button
v-for="tab in tabs"
:key="tab.name"
@click="activeTab = tab.name"
class="flex items-center gap-3 px-6 py-5 text-[10px] md:text-xs font-black uppercase tracking-[.2em] transition-all relative shrink-0"
:class="activeTab === tab.name ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'"
>
<component :is="tab.icon" class="w-4 h-4" />
{{ tab.name }}
<div v-if="activeTab === tab.name" class="absolute bottom-0 left-0 right-0 h-1 bg-blue-500 rounded-t-full"></div>
</button>
</div>
<!-- RESULTS CONTENT -->
<div class="flex-1 overflow-y-auto custom-scrollbar bg-slate-950/20 min-h-0 overscroll-contain" style="-webkit-overflow-scrolling: touch;">
<!-- EMPTY STATES -->
<div v-if="currentDisplayList.length === 0" class="flex flex-col items-center justify-center py-24 md:py-32 text-center px-8">
<div class="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center mb-6 border border-slate-800 shadow-2xl">
<component :is="searchQuery ? Search : (activeTab === 'Recent' ? History : (activeTab === 'Watchlist' ? Star : TrendingUp))" class="w-8 h-8 text-slate-700" />
</div>
<h3 class="text-xl font-bold text-white mb-2">{{ searchQuery ? 'No assets found' : `Your ${activeTab} is empty` }}</h3>
<p class="text-sm text-slate-500 max-w-[280px] leading-relaxed">
{{ searchQuery ? 'Unable to locate a matching instrument in the global vault. Please refine your query.' : 'Initialize a search above to populate your intelligence hub.' }}
</p>
</div>
<!-- LIST ITEMS -->
<div class="divide-y divide-slate-800/30">
<div
v-for="(item, index) in currentDisplayList"
:key="item.symbol"
@click="addToWatchlist(item)"
class="group flex items-center justify-between px-4 md:px-8 py-5 md:py-6 cursor-pointer relative transition-all border-l-4 border-transparent"
:class="selectedIndex === index ? 'bg-blue-600/10 border-blue-600' : 'hover:bg-white/[0.02]'"
>
<div class="flex items-center gap-4 md:gap-6 flex-1 min-w-0">
<div class="flex flex-col">
<span class="text-xl md:text-2xl font-black text-white leading-none tracking-tighter">{{ item.symbol }}</span>
<span class="text-[9px] md:text-[10px] font-black text-blue-500 uppercase tracking-widest mt-1 block md:hidden">{{ item.exchDisp || item.exchange }}</span>
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm md:text-lg text-slate-300 font-bold truncate group-hover:text-white transition-colors">{{ item.name }}</span>
<span class="hidden md:block text-[10px] font-black text-slate-600 uppercase tracking-widest mt-1">{{ item.exchDisp || item.exchange || 'Active Engine' }}</span>
</div>
</div>
<!-- ACTIONS -->
<div class="flex items-center gap-2 md:gap-4 pl-4">
<button
@click.stop="startAnalysis(item)"
class="flex items-center gap-2 px-3 md:px-5 py-2.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-xl transition-all shadow-lg active:scale-95 group/btn"
>
<Zap class="w-4 h-4 md:w-5 md:h-5" />
<span class="text-[10px] font-black uppercase tracking-widest hidden sm:inline">Analyze</span>
</button>
<button
@click.stop="addToWatchlist(item)"
class="p-2.5 md:p-3 bg-slate-900 hover:bg-slate-800 text-slate-500 hover:text-white border border-slate-800 rounded-xl transition-all active:scale-95"
title="Add to Vault"
>
<Plus class="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
<!-- FOOTER SHORTCUTS (HIDDEN ON MOBILE) -->
<div class="hidden md:flex items-center gap-8 py-5 px-8 bg-slate-900/60 border-t border-slate-800">
<div class="flex items-center gap-3 text-[10px] font-black text-slate-500 uppercase tracking-widest">
<div class="flex gap-1.5"><ArrowUp class="w-3.5 h-3.5" /><ArrowDown class="w-3.5 h-3.5" /></div>
Navigate
</div>
<div class="flex items-center gap-3 text-[10px] font-black text-slate-500 uppercase tracking-widest">
<span class="px-2 py-0.5 bg-slate-950 border border-slate-800 rounded text-slate-400">ENTER</span>
Add to Watchlist
</div>
<div class="flex items-center gap-3 text-[10px] font-black text-slate-500 uppercase tracking-widest ml-auto">
<span class="px-2 py-0.5 bg-slate-950 border border-slate-800 rounded text-slate-400">ESC</span>
Dismiss
</div>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 10px;
}
.animate-in {
animation-duration: 0.3s;
animation-fill-mode: both;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.fade-in { animation-name: fadeIn; }
</style>