archy/neode-ui/src/components/SpotlightSearch.vue
Dorian bc879b3581 fix: add dev-mode warnings to all 24 silent catch blocks
Every empty/comment-only catch block now logs a descriptive warning
in dev mode via `if (import.meta.env.DEV) console.warn(...)`. Covers
15 files across views, stores, components, and utils. Zero silent
catches remaining.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:58:55 +00:00

414 lines
13 KiB
Vue

<template>
<Teleport to="body">
<Transition name="spotlight">
<div
v-if="spotlightStore.isOpen"
class="fixed inset-0 z-[2500] flex items-center justify-center p-4"
@click.self="spotlightStore.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
ref="panelRef"
class="glass-card w-full max-w-2xl relative z-10 overflow-hidden flex flex-col"
:style="panelStyle"
@mousedown="onPanelMouseDown"
>
<!-- Header: drag handle grip + search -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/10">
<div
ref="dragHandleRef"
class="flex items-center justify-center w-8 h-8 rounded cursor-grab hover:bg-white/10 transition-colors shrink-0"
:class="{ 'cursor-grabbing': isDragging }"
title="Drag to move"
>
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" />
</svg>
</div>
<div class="flex-1 flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search or type a command..."
class="flex-1 bg-transparent text-white placeholder-white/50 outline-none text-base"
@keydown="onInputKeydown"
/>
</div>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<div class="flex-1 overflow-y-auto max-h-[60vh] min-h-[200px]">
<!-- Recent items (when no query and we have recent) -->
<div v-if="!query.trim() && spotlightStore.recentItems.length > 0" class="p-2 border-b border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">Recent</div>
<button
v-for="(item, idx) in spotlightStore.recentItems"
:key="`recent-${item.id}-${item.timestamp}`"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectRecent(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.type }}</span>
</button>
</div>
<!-- Search results or help tree -->
<template v-if="query.trim()">
<div v-if="filteredItems.length > 0" class="p-2">
<button
v-for="(item, idx) in filteredItems"
:key="item.id + item.section"
type="button"
class="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(idx)"
@click="selectItem(item)"
>
<span class="text-white/90">{{ item.label }}</span>
<span class="text-xs text-white/40">{{ item.section }}</span>
</button>
</div>
<div v-else class="p-8 text-center text-white/50">
No results for "{{ query }}"
</div>
</template>
<template v-else>
<!-- Help tree when no search -->
<div v-for="section in helpTree" :key="section.id" class="p-2">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">{{ section.label }}</div>
<button
v-for="(item, idx) in section.items"
:key="item.id"
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="getItemClass(recentOffset + getFlatIndex(section.id, idx))"
@click="selectHelpItem(section, item)"
>
<span class="text-white/90">{{ item.label }}</span>
</button>
</div>
</template>
<!-- AI Assistant placeholder -->
<div class="p-2 border-t border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
Coming soon ask questions about your node, apps, and Bitcoin.
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import Fuse from 'fuse.js'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { helpTree, flattenForSearch, type SearchableItem } from '@/data/helpTree'
const router = useRouter()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const appLauncherStore = useAppLauncherStore()
const inputRef = ref<HTMLInputElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const query = ref('')
const isDragging = ref(false)
const dragStart = ref<{ x: number; y: number; panelX: number; panelY: number } | null>(null)
const staticItems = flattenForSearch()
// Build dynamic app items from installed packages
const dynamicAppItems = computed<SearchableItem[]>(() => {
const pkgs = appStore.packages
return Object.entries(pkgs).map(([id, pkg]) => ({
id: `app-${id}`,
label: pkg.manifest?.title || id,
path: `__launch_app__:${id}`,
type: 'action' as const,
section: 'Installed Apps',
}))
})
const allSearchableItems = computed(() => [...staticItems, ...dynamicAppItems.value])
const fuse = computed(() => new Fuse(allSearchableItems.value, {
keys: ['label', 'section'],
threshold: 0.4,
}))
const filteredItems = computed(() => {
const q = query.value.trim()
if (!q) return []
const results = fuse.value.search(q)
return results.map((r) => r.item)
})
const recentOffset = computed(() =>
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
)
const selectableCount = computed(() => {
if (query.value.trim()) return filteredItems.value.length
return recentOffset.value + allSearchableItems.value.length
})
const panelStyle = computed(() => {
const pos = savedPosition.value
if (!pos) return {}
return {
transform: `translate(${pos.x}px, ${pos.y}px)`,
margin: 0,
}
})
const SAVED_POSITION_KEY = 'archipelago-spotlight-position'
const savedPosition = ref<{ x: number; y: number } | null>(null)
function loadSavedPosition() {
try {
const raw = localStorage.getItem(SAVED_POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw)
savedPosition.value = { x: parsed.x ?? 0, y: parsed.y ?? 0 }
} else {
savedPosition.value = null
}
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to load saved spotlight position', e)
savedPosition.value = null
}
}
function savePosition(x: number, y: number) {
savedPosition.value = { x, y }
try {
localStorage.setItem(SAVED_POSITION_KEY, JSON.stringify({ x, y }))
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to save spotlight position', e)
}
}
function getFlatIndex(sectionId: string, itemIdx: number): number {
let idx = 0
for (const s of helpTree) {
if (s.id === sectionId) return idx + itemIdx
idx += s.items.length
}
return -1
}
function getItemClass(index: number) {
const selected = spotlightStore.selectedIndex
return index === selected
? 'bg-amber-500/20 text-amber-200'
: 'hover:bg-white/10 text-white/90'
}
function launchInstalledApp(appId: string) {
const pkg = appStore.packages[appId]
if (!pkg) return
let lanAddress = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (lanAddress && lanAddress.includes('localhost')) {
lanAddress = lanAddress.replace('localhost', window.location.hostname)
}
if (lanAddress) {
appLauncherStore.open({ url: lanAddress, title: pkg.manifest?.title || appId })
} else {
router.push(`/dashboard/apps/${appId}`).catch(() => {})
}
}
function selectItem(item: SearchableItem) {
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type: item.type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
spotlightStore.addRecentItem({
id: item.id,
label: item.label,
path: item.path,
type,
})
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
} else if (item.path === '__cli__') {
cliStore.open()
} else if (item.path) {
router.push(item.path)
} else if (item.content) {
spotlightStore.showHelpModal({ title: item.label, content: item.content, relatedPath: item.relatedPath })
}
}
function selectRecent(item: { id: string; label: string; path?: string; type: 'navigate' | 'learn' | 'action' | 'goal' }) {
spotlightStore.close()
if (item.path?.startsWith('__launch_app__:')) {
launchInstalledApp(item.path.replace('__launch_app__:', ''))
return
}
if (item.path === '__cli__') {
cliStore.open()
return
}
if (item.path) {
router.push(item.path)
return
}
if (item.type === 'learn') {
for (const s of helpTree) {
const helpItem = s.items.find((i) => i.id === item.id)
if (helpItem?.content) {
spotlightStore.showHelpModal({ title: helpItem.label, content: helpItem.content, relatedPath: helpItem.relatedPath })
return
}
}
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
spotlightStore.close()
e.preventDefault()
e.stopPropagation()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
spotlightStore.setSelectedIndex(
Math.min(spotlightStore.selectedIndex + 1, Math.max(0, selectableCount.value - 1))
)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
spotlightStore.setSelectedIndex(Math.max(spotlightStore.selectedIndex - 1, 0))
return
}
if (e.key === 'Enter') {
e.preventDefault()
const idx = spotlightStore.selectedIndex
if (query.value.trim()) {
const item = filteredItems.value[idx]
if (item) selectItem(item)
return
}
if (idx < recentOffset.value) {
const item = spotlightStore.recentItems[idx]
if (item) selectRecent(item)
return
}
const helpIdx = idx - recentOffset.value
let count = 0
for (const s of helpTree) {
for (const item of s.items) {
if (count === helpIdx) {
selectHelpItem(s, item)
return
}
count++
}
}
}
}
function onPanelMouseDown(e: MouseEvent) {
if (!dragHandleRef.value?.contains(e.target as Node)) return
isDragging.value = true
const rect = panelRef.value?.getBoundingClientRect()
if (!rect) return
const currentX = savedPosition.value?.x ?? 0
const currentY = savedPosition.value?.y ?? 0
dragStart.value = { x: e.clientX, y: e.clientY, panelX: currentX, panelY: currentY }
}
function onMouseMove(e: MouseEvent) {
if (!dragStart.value) return
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
savePosition(dragStart.value.panelX + dx, dragStart.value.panelY + dy)
}
function onMouseUp() {
isDragging.value = false
dragStart.value = null
}
watch(
() => spotlightStore.isOpen,
(open) => {
if (open) {
query.value = ''
loadSavedPosition()
nextTick(() => {
inputRef.value?.focus()
spotlightStore.setSelectedIndex(0)
})
}
}
)
watch(
[query, filteredItems],
() => {
spotlightStore.setSelectedIndex(0)
}
)
onMounted(() => {
loadSavedPosition()
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<style scoped>
.spotlight-enter-active,
.spotlight-leave-active {
transition: opacity 0.2s ease;
}
.spotlight-enter-from,
.spotlight-leave-to {
opacity: 0;
}
</style>