Wire up TopBar search across pages, integrations, and bookmarks

Search now filters static nav pages, integrations, and bookmarks live as you type, with a results dropdown, Enter-to-navigate, and click-outside-to-close. Browser-verified end-to-end.
This commit is contained in:
Claude 2026-06-19 16:37:28 +00:00
parent 5b17bba80e
commit b74a0e2d36
No known key found for this signature in database

View file

@ -1,7 +1,8 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react' import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle, Server, Bookmark as BookmarkIcon, LayoutGrid } from 'lucide-react'
import { useAuth } from '../lib/AuthContext' import { useAuth } from '../lib/AuthContext'
import { api } from '../lib/api'
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
'/': 'Glance', '/': 'Glance',
@ -15,14 +16,75 @@ const pageSubtitles: Record<string, string> = {
'/booknest': 'Your Digital Library', '/booknest': 'Your Digital Library',
} }
const staticPages: { name: string; path: string }[] = [
{ name: 'Glance', path: '/' },
{ name: 'Infrastructure', path: '/infrastructure' },
{ name: 'BookNest', path: '/booknest' },
{ name: 'Terminal', path: '/terminal' },
{ name: 'Tunnels', path: '/tunnels' },
{ name: 'Files', path: '/files' },
{ name: 'Containers', path: '/containers' },
{ name: 'Remote Desktop', path: '/remote-desktop' },
{ name: 'Host Metrics', path: '/host-metrics' },
{ name: 'Settings', path: '/settings' },
]
type SearchResult =
| { kind: 'page'; key: string; label: string; sublabel?: string; path: string }
| { kind: 'integration'; key: string; label: string; sublabel?: string; path: string }
| { kind: 'bookmark'; key: string; label: string; sublabel?: string; path: string }
export default function TopBar() { export default function TopBar() {
const { logout, user } = useAuth() const { logout, user } = useAuth()
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const title = pageTitles[location.pathname] ?? 'Glance' const title = pageTitles[location.pathname] ?? 'Glance'
const subtitle = pageSubtitles[location.pathname] const subtitle = pageSubtitles[location.pathname]
const [query, setQuery] = useState('')
const [searchOpen, setSearchOpen] = useState(false)
const [integrations, setIntegrations] = useState<{ id: number; name: string; type: string }[]>([])
const [bookmarks, setBookmarks] = useState<{ id: number; title: string; url: string }[]>([])
const searchRef = useRef<HTMLDivElement>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations)).catch(() => {})
api.listBookmarks().then(({ bookmarks }) => setBookmarks(bookmarks)).catch(() => {})
}, [])
useEffect(() => {
function handleClick(e: MouseEvent) {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setSearchOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const results = useMemo<SearchResult[]>(() => {
const q = query.trim().toLowerCase()
if (!q) return []
const pageMatches: SearchResult[] = staticPages
.filter((p) => p.name.toLowerCase().includes(q))
.map((p) => ({ kind: 'page', key: `page-${p.path}`, label: p.name, path: p.path }))
const integrationMatches: SearchResult[] = integrations
.filter((i) => i.name.toLowerCase().includes(q) || i.type.toLowerCase().includes(q))
.map((i) => ({ kind: 'integration', key: `int-${i.id}`, label: i.name, sublabel: i.type, path: '/infrastructure' }))
const bookmarkMatches: SearchResult[] = bookmarks
.filter((b) => b.title.toLowerCase().includes(q) || b.url.toLowerCase().includes(q))
.map((b) => ({ kind: 'bookmark', key: `bm-${b.id}`, label: b.title, sublabel: b.url, path: '/booknest' }))
return [...pageMatches, ...integrationMatches, ...bookmarkMatches].slice(0, 8)
}, [query, integrations, bookmarks])
function handleSelectResult(r: SearchResult) {
navigate(r.path)
setQuery('')
setSearchOpen(false)
}
const displayName = user?.display_name || user?.username || '' const displayName = user?.display_name || user?.username || ''
const initials = displayName const initials = displayName
.split(/\s+/) .split(/\s+/)
@ -60,14 +122,59 @@ export default function TopBar() {
{/* Center section — Search bar */} {/* Center section — Search bar */}
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<div className="relative"> <div className="relative" ref={searchRef}>
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: '#7A7D85' }} /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: '#7A7D85' }} />
<input <input
type="text" type="text"
placeholder="Search resources..." placeholder="Search resources..."
value={query}
onChange={(e) => {
setQuery(e.target.value)
setSearchOpen(true)
}}
onFocus={() => setSearchOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' && results.length > 0) {
handleSelectResult(results[0])
} else if (e.key === 'Escape') {
setSearchOpen(false)
}
}}
className="w-[300px] h-8 rounded-full border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors" className="w-[300px] h-8 rounded-full border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors"
style={{ paddingLeft: '36px', paddingRight: '16px', backgroundColor: 'rgba(255,255,255,0.04)', backdropFilter: 'blur(6px)' }} style={{ paddingLeft: '36px', paddingRight: '16px', backgroundColor: 'rgba(255,255,255,0.04)', backdropFilter: 'blur(6px)' }}
/> />
{searchOpen && query.trim() && (
<div className="absolute left-0 top-full mt-2 w-[320px] bg-card border border-border rounded-xl overflow-hidden shadow-lg z-50">
{results.length === 0 ? (
<div className="px-3 py-3 text-[12px]" style={{ color: '#7A7D85' }}>
No matches for "{query}"
</div>
) : (
<div className="py-1 max-h-[280px] overflow-y-auto">
{results.map((r) => (
<button
key={r.key}
onClick={() => handleSelectResult(r)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
>
{r.kind === 'page' && <LayoutGrid size={14} />}
{r.kind === 'integration' && <Server size={14} />}
{r.kind === 'bookmark' && <BookmarkIcon size={14} />}
<span className="flex flex-col">
<span>{r.label}</span>
{r.sublabel && (
<span className="text-[10px]" style={{ color: '#7A7D85' }}>
{r.sublabel}
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
</div> </div>