2026-06-19 16:37:28 +00:00
|
|
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
|
|
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
|
import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle, Server, Bookmark as BookmarkIcon, LayoutGrid } from 'lucide-react'
|
2026-06-18 19:13:27 +00:00
|
|
|
import { useAuth } from '../lib/AuthContext'
|
2026-06-19 16:37:28 +00:00
|
|
|
import { api } from '../lib/api'
|
2026-06-18 08:14:00 -04:00
|
|
|
|
2026-06-18 16:15:34 +00:00
|
|
|
const pageTitles: Record<string, string> = {
|
|
|
|
|
'/': 'Glance',
|
|
|
|
|
'/infrastructure': 'Infrastructure',
|
|
|
|
|
'/booknest': 'BookNest',
|
|
|
|
|
'/terminal': 'Terminal',
|
2026-06-19 21:34:04 +00:00
|
|
|
'/tunnels': 'Tunnels',
|
|
|
|
|
'/files': 'Files',
|
|
|
|
|
'/containers': 'Containers',
|
|
|
|
|
'/remote-desktop': 'Remote Desktop',
|
|
|
|
|
'/host-metrics': 'Host Metrics',
|
2026-06-18 16:15:34 +00:00
|
|
|
'/settings': 'Settings',
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
'/help': 'Help',
|
2026-06-18 16:15:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:13:26 +00:00
|
|
|
const pageSubtitles: Record<string, string> = {
|
|
|
|
|
'/booknest': 'Your Digital Library',
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 16:37:28 +00:00
|
|
|
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' },
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
{ name: 'Help', path: '/help' },
|
2026-06-19 16:37:28 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
|
2026-06-18 08:14:00 -04:00
|
|
|
export default function TopBar() {
|
2026-06-18 20:08:30 +00:00
|
|
|
const { logout, user } = useAuth()
|
2026-06-18 08:14:00 -04:00
|
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
|
|
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
2026-06-18 16:15:34 +00:00
|
|
|
const location = useLocation()
|
2026-06-19 16:37:28 +00:00
|
|
|
const navigate = useNavigate()
|
2026-06-18 16:15:34 +00:00
|
|
|
const title = pageTitles[location.pathname] ?? 'Glance'
|
2026-06-18 18:13:26 +00:00
|
|
|
const subtitle = pageSubtitles[location.pathname]
|
2026-06-18 08:14:00 -04:00
|
|
|
|
2026-06-19 16:37:28 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 20:08:30 +00:00
|
|
|
const displayName = user?.display_name || user?.username || ''
|
|
|
|
|
const initials = displayName
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.map((p) => p[0])
|
|
|
|
|
.join('')
|
|
|
|
|
.slice(0, 2)
|
|
|
|
|
.toUpperCase()
|
|
|
|
|
|
2026-06-18 08:14:00 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
|
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
|
|
|
setUserMenuOpen(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener('mousedown', handleClick)
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-18 18:25:19 +00:00
|
|
|
<header className="flex items-center px-6 sticky top-0 z-40" style={{ height: subtitle ? '72px' : '56px' }}>
|
2026-06-18 08:14:00 -04:00
|
|
|
{/* Page Title — pushed away from sidebar edge */}
|
2026-06-18 18:13:26 +00:00
|
|
|
<div style={{ marginLeft: '20px' }}>
|
2026-06-18 18:25:19 +00:00
|
|
|
<h1
|
|
|
|
|
className="font-bold uppercase tracking-wide"
|
|
|
|
|
style={{ color: '#C8A434', fontSize: subtitle ? '28px' : '18px' }}
|
|
|
|
|
>
|
2026-06-18 18:13:26 +00:00
|
|
|
{title}
|
|
|
|
|
</h1>
|
|
|
|
|
{subtitle && (
|
2026-06-18 18:25:19 +00:00
|
|
|
<p className="text-[13px]" style={{ color: '#A8A6A0', marginTop: '2px' }}>
|
2026-06-18 18:13:26 +00:00
|
|
|
{subtitle}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-18 08:14:00 -04:00
|
|
|
|
|
|
|
|
{/* Center section — Search bar */}
|
|
|
|
|
<div className="flex-1 flex justify-center">
|
2026-06-19 16:37:28 +00:00
|
|
|
<div className="relative" ref={searchRef}>
|
2026-06-18 08:14:00 -04:00
|
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: '#7A7D85' }} />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search resources..."
|
2026-06-19 16:37:28 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-06-18 16:50:06 +00:00
|
|
|
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)' }}
|
2026-06-18 08:14:00 -04:00
|
|
|
/>
|
2026-06-19 16:37:28 +00:00
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-18 08:14:00 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right section — Bell + Avatar, moved toward center (away from window edge) */}
|
|
|
|
|
<div className="flex items-center gap-4" style={{ marginRight: '40px' }}>
|
|
|
|
|
|
|
|
|
|
{/* Notifications */}
|
|
|
|
|
<button className="relative p-1.5 text-text-secondary hover:text-gold transition-colors bg-transparent border-none cursor-pointer">
|
|
|
|
|
<Bell size={17} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* User Avatar + Dropdown */}
|
|
|
|
|
<div className="relative" ref={menuRef}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
|
|
|
|
className="flex items-center gap-2 bg-transparent border-none cursor-pointer p-0"
|
|
|
|
|
>
|
2026-06-18 20:08:30 +00:00
|
|
|
<div className="w-9 h-9 rounded-full border-2 border-gold bg-card flex items-center justify-center text-gold font-bold text-[12px] shadow-[0_0_8px_rgba(200,164,52,0.4)] overflow-hidden">
|
|
|
|
|
{user?.avatar_data_url ? (
|
|
|
|
|
<img src={user.avatar_data_url} alt={displayName} className="w-full h-full object-cover" />
|
|
|
|
|
) : (
|
|
|
|
|
initials
|
|
|
|
|
)}
|
2026-06-18 08:14:00 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col text-left">
|
2026-06-18 20:08:30 +00:00
|
|
|
<span className="text-[12px] text-text-primary font-medium leading-tight">{displayName}</span>
|
2026-06-18 08:14:00 -04:00
|
|
|
<span className="text-[9px] text-text-secondary leading-tight">Administrator</span>
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronDown size={12} className={`text-text-secondary transition-transform duration-200 ${userMenuOpen ? 'rotate-180' : ''}`} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{userMenuOpen && (
|
|
|
|
|
<div className="absolute right-0 top-full mt-2 w-48 bg-card border border-border rounded-xl overflow-hidden shadow-lg z-50">
|
|
|
|
|
<div className="p-3 border-b border-border">
|
2026-06-18 20:08:30 +00:00
|
|
|
<p className="text-[12px] text-text-primary font-medium">{displayName}</p>
|
|
|
|
|
<p className="text-[10px] text-text-secondary">{user?.email || user?.username}</p>
|
2026-06-18 08:14:00 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="py-1">
|
|
|
|
|
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors no-underline">
|
|
|
|
|
<User size={14} />
|
|
|
|
|
<span>Profile</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors no-underline">
|
|
|
|
|
<Palette size={14} />
|
|
|
|
|
<span>Appearance</span>
|
|
|
|
|
</a>
|
|
|
|
|
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors no-underline">
|
|
|
|
|
<Shield size={14} />
|
|
|
|
|
<span>Security</span>
|
|
|
|
|
</a>
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => { setUserMenuOpen(false); navigate('/help') }}
|
|
|
|
|
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"
|
|
|
|
|
>
|
2026-06-18 08:14:00 -04:00
|
|
|
<HelpCircle size={14} />
|
|
|
|
|
<span>Help & Support</span>
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
</button>
|
2026-06-18 08:14:00 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="border-t border-border py-1">
|
2026-06-18 19:13:27 +00:00
|
|
|
<button
|
|
|
|
|
onClick={logout}
|
|
|
|
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
|
|
|
|
|
>
|
2026-06-18 08:14:00 -04:00
|
|
|
<LogOut size={14} />
|
|
|
|
|
<span>Sign Out</span>
|
2026-06-18 19:13:27 +00:00
|
|
|
</button>
|
2026-06-18 08:14:00 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
)
|
|
|
|
|
}
|