@neat.is/web 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ interface Project {
6
+ name: string
7
+ path?: string
8
+ status?: 'active' | 'paused' | 'broken'
9
+ }
10
+
11
+ interface SearchResult {
12
+ node: { id: string; type: string; name?: string }
13
+ score: number
14
+ }
15
+
16
+ interface TopBarProps {
17
+ project: string
18
+ onProjectChange: (name: string) => void
19
+ onNodeSelect: (id: string) => void
20
+ onRelayout: () => void
21
+ onToggleLock: () => void
22
+ }
23
+
24
+ export function TopBar({ project, onProjectChange, onNodeSelect, onRelayout, onToggleLock }: TopBarProps) {
25
+ const [projects, setProjects] = useState<Project[]>([])
26
+ const [isLive, setIsLive] = useState(false)
27
+ const [query, setQuery] = useState('')
28
+ const [results, setResults] = useState<SearchResult[]>([])
29
+ const [showResults, setShowResults] = useState(false)
30
+ const [showSwitcher, setShowSwitcher] = useState(false)
31
+ const searchRef = useRef<HTMLDivElement>(null)
32
+ const switcherRef = useRef<HTMLDivElement>(null)
33
+ const inputRef = useRef<HTMLInputElement>(null)
34
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
35
+
36
+ // ADR-051 — list projects via GET /projects, used by the switcher (ADR-057 #7).
37
+ useEffect(() => {
38
+ fetch('/api/projects')
39
+ .then((r) => (r.ok ? r.json() : []))
40
+ .then((data: Project[] | { projects?: Project[] }) => {
41
+ const list = Array.isArray(data) ? data : Array.isArray(data?.projects) ? data.projects : []
42
+ setProjects(list)
43
+ })
44
+ .catch(() => {})
45
+ }, [])
46
+
47
+ useEffect(() => {
48
+ const check = () =>
49
+ fetch('/api/health')
50
+ .then((r) => r.json())
51
+ .then((d: { ok: boolean }) => setIsLive(d.ok === true))
52
+ .catch(() => setIsLive(false))
53
+ check()
54
+ const id = setInterval(check, 15_000)
55
+ return () => clearInterval(id)
56
+ }, [])
57
+
58
+ // ADR-057 #5 — search is project-scoped.
59
+ useEffect(() => {
60
+ if (debounceRef.current) clearTimeout(debounceRef.current)
61
+ if (!query.trim()) {
62
+ setResults([])
63
+ setShowResults(false)
64
+ return
65
+ }
66
+ debounceRef.current = setTimeout(() => {
67
+ fetch(`/api/search?q=${encodeURIComponent(query)}&project=${encodeURIComponent(project)}`)
68
+ .then((r) => r.json())
69
+ .then((d: { results: SearchResult[] }) => {
70
+ if (Array.isArray(d.results)) {
71
+ setResults(d.results.slice(0, 8))
72
+ setShowResults(true)
73
+ }
74
+ })
75
+ .catch(() => {})
76
+ }, 280)
77
+ }, [query, project])
78
+
79
+ useEffect(() => {
80
+ const handler = (e: MouseEvent) => {
81
+ if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
82
+ setShowResults(false)
83
+ }
84
+ if (switcherRef.current && !switcherRef.current.contains(e.target as Node)) {
85
+ setShowSwitcher(false)
86
+ }
87
+ }
88
+ document.addEventListener('mousedown', handler)
89
+ return () => document.removeEventListener('mousedown', handler)
90
+ }, [])
91
+
92
+ // ⌘K / Ctrl+K focuses the search input
93
+ useEffect(() => {
94
+ const handler = (e: KeyboardEvent) => {
95
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
96
+ e.preventDefault()
97
+ inputRef.current?.focus()
98
+ }
99
+ // F key focuses search when not in an input
100
+ if (e.key === 'f' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
101
+ e.preventDefault()
102
+ inputRef.current?.focus()
103
+ }
104
+ }
105
+ document.addEventListener('keydown', handler)
106
+ return () => document.removeEventListener('keydown', handler)
107
+ }, [])
108
+
109
+ return (
110
+ <header className="topbar">
111
+ <div className="brand" title="NEAT">N</div>
112
+
113
+ {/* ADR-057 #6 — active project always visible. ADR-057 #7 — switcher always reachable. */}
114
+ <div className="crumbs" ref={switcherRef}>
115
+ <button
116
+ className="repo project-switcher"
117
+ aria-label={`Active project: ${project}. Click to switch.`}
118
+ aria-expanded={showSwitcher}
119
+ onClick={() => setShowSwitcher((v) => !v)}
120
+ title="Switch project"
121
+ >
122
+ <span className="project-name">{project}</span>
123
+ <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4, opacity: 0.6 }}>
124
+ <path d="m6 9 6 6 6-6" />
125
+ </svg>
126
+ </button>
127
+ {showSwitcher && (
128
+ <div className="project-menu" role="menu">
129
+ {projects.length === 0 ? (
130
+ <div className="project-menu-empty">no registered projects</div>
131
+ ) : (
132
+ projects.map((p) => (
133
+ <button
134
+ key={p.name}
135
+ className={`project-menu-item${p.name === project ? ' active' : ''}`}
136
+ role="menuitem"
137
+ onClick={() => {
138
+ onProjectChange(p.name)
139
+ setShowSwitcher(false)
140
+ }}
141
+ >
142
+ {p.name}
143
+ </button>
144
+ ))
145
+ )}
146
+ </div>
147
+ )}
148
+ <span className="sep">/</span>
149
+ <span className="here">graph view</span>
150
+ </div>
151
+
152
+ <div className="topbar-spacer" />
153
+
154
+ <div className="top-search" ref={searchRef}>
155
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
156
+ <circle cx="11" cy="11" r="7" /><path d="m20 20-3-3" />
157
+ </svg>
158
+ <input
159
+ ref={inputRef}
160
+ aria-label="Search nodes"
161
+ aria-expanded={showResults}
162
+ placeholder="find · query · @author · #service"
163
+ value={query}
164
+ onChange={(e) => setQuery(e.target.value)}
165
+ onFocus={() => results.length > 0 && setShowResults(true)}
166
+ />
167
+ {!query && <span className="kbd">⌘K</span>}
168
+ {showResults && results.length > 0 && (
169
+ <div className="search-results" role="listbox">
170
+ {results.map((r) => (
171
+ <div
172
+ key={r.node.id}
173
+ className="search-result-item"
174
+ role="option"
175
+ aria-selected={false}
176
+ onMouseDown={() => {
177
+ setQuery('')
178
+ setShowResults(false)
179
+ onNodeSelect(r.node.id)
180
+ }}
181
+ >
182
+ <span className="sr-name">{r.node.name ?? r.node.id}</span>
183
+ <span className="sr-type">{r.node.type.replace('Node', '').toLowerCase()}</span>
184
+ <span className="sr-score">{r.score.toFixed(2)}</span>
185
+ </div>
186
+ ))}
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ <div className="top-actions">
192
+ <button className="top-btn" aria-label={isLive ? 'Core connected' : 'Core offline'}>
193
+ <span className={`dot${isLive ? ' live' : ''}`} />
194
+ {isLive ? 'Live' : 'Offline'}
195
+ </button>
196
+ <button className="top-btn" title="Re-run cose layout" onClick={onRelayout}>
197
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
198
+ <path d="M12 8v4l3 2" /><circle cx="12" cy="12" r="9" />
199
+ </svg>
200
+ Layout
201
+ </button>
202
+ <button className="top-btn" title="Toggle node dragging" onClick={onToggleLock}>
203
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
204
+ <rect x="5" y="11" width="14" height="9" rx="1.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
205
+ </svg>
206
+ Lock
207
+ </button>
208
+ </div>
209
+ </header>
210
+ )
211
+ }
Binary file