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>
414 lines
13 KiB
Vue
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>
|