@checkmate-monitor/command-frontend 0.0.2 → 0.1.0
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.
- package/CHANGELOG.md +35 -0
- package/package.json +6 -3
- package/src/components/NavbarSearch.tsx +89 -0
- package/src/components/SearchDialog.tsx +231 -0
- package/src/{index.ts → index.tsx} +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# @checkmate-monitor/command-frontend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ae33df2: Move command palette from dashboard to centered navbar position
|
|
8
|
+
|
|
9
|
+
- Converted `command-frontend` into a plugin with `NavbarCenterSlot` extension
|
|
10
|
+
- Added compact `NavbarSearch` component with responsive search trigger
|
|
11
|
+
- Moved `SearchDialog` from dashboard-frontend to command-frontend
|
|
12
|
+
- Keyboard shortcut (⌘K / Ctrl+K) now works on every page
|
|
13
|
+
- Renamed navbar slots for clarity:
|
|
14
|
+
- `NavbarSlot` → `NavbarRightSlot`
|
|
15
|
+
- `NavbarMainSlot` → `NavbarLeftSlot`
|
|
16
|
+
- Added new `NavbarCenterSlot` for centered content
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [52231ef]
|
|
21
|
+
- Updated dependencies [b0124ef]
|
|
22
|
+
- Updated dependencies [54cc787]
|
|
23
|
+
- Updated dependencies [a65e002]
|
|
24
|
+
- Updated dependencies [ae33df2]
|
|
25
|
+
- Updated dependencies [32ea706]
|
|
26
|
+
- @checkmate-monitor/ui@0.1.2
|
|
27
|
+
- @checkmate-monitor/common@0.2.0
|
|
28
|
+
- @checkmate-monitor/frontend-api@0.1.0
|
|
29
|
+
- @checkmate-monitor/command-common@0.0.3
|
|
30
|
+
|
|
31
|
+
## 0.0.3
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies [0f8cc7d]
|
|
36
|
+
- @checkmate-monitor/frontend-api@0.0.3
|
|
37
|
+
|
|
3
38
|
## 0.0.2
|
|
4
39
|
|
|
5
40
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkmate-monitor/command-frontend",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"import": "./src/index.
|
|
7
|
+
"import": "./src/index.tsx"
|
|
8
8
|
}
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@checkmate-monitor/command-common": "workspace:*",
|
|
12
12
|
"@checkmate-monitor/common": "workspace:*",
|
|
13
13
|
"@checkmate-monitor/frontend-api": "workspace:*",
|
|
14
|
-
"
|
|
14
|
+
"@checkmate-monitor/ui": "workspace:*",
|
|
15
|
+
"lucide-react": "^0.468.0",
|
|
16
|
+
"react": "^18.2.0",
|
|
17
|
+
"react-router-dom": "^7.1.1"
|
|
15
18
|
},
|
|
16
19
|
"devDependencies": {
|
|
17
20
|
"typescript": "^5.7.2",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from "react";
|
|
2
|
+
import { cn } from "@checkmate-monitor/ui";
|
|
3
|
+
import { Search, Command } from "lucide-react";
|
|
4
|
+
import { SearchDialog } from "./SearchDialog";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* NavbarSearch - Compact command palette trigger for the navbar.
|
|
8
|
+
* Displays a small search button that opens the global search dialog.
|
|
9
|
+
* Includes the ⌘K keyboard shortcut listener.
|
|
10
|
+
*/
|
|
11
|
+
export const NavbarSearch = () => {
|
|
12
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
13
|
+
|
|
14
|
+
// Detect Mac for keyboard shortcut display
|
|
15
|
+
const isMac = useMemo(
|
|
16
|
+
() =>
|
|
17
|
+
typeof navigator !== "undefined" &&
|
|
18
|
+
/Mac|iPhone|iPad/.test(navigator.userAgent),
|
|
19
|
+
[]
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Global keyboard shortcut for search (⌘K / Ctrl+K)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
25
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
setSearchOpen(true);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
32
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
{/* Search Dialog */}
|
|
38
|
+
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
|
39
|
+
|
|
40
|
+
{/* Compact trigger button */}
|
|
41
|
+
<button
|
|
42
|
+
onClick={() => setSearchOpen(true)}
|
|
43
|
+
className={cn(
|
|
44
|
+
// Base styles
|
|
45
|
+
"flex items-center gap-2 px-3 py-1.5 rounded-lg",
|
|
46
|
+
// Glassmorphism effect
|
|
47
|
+
"bg-muted/50 border border-primary/30",
|
|
48
|
+
// Subtle primary pulse animation
|
|
49
|
+
"animate-pulse-subtle ring-1 ring-primary/20",
|
|
50
|
+
// Hover state
|
|
51
|
+
"hover:bg-muted hover:border-primary/50 hover:ring-primary/40",
|
|
52
|
+
// Transition
|
|
53
|
+
"transition-all duration-200",
|
|
54
|
+
// Focus ring
|
|
55
|
+
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background",
|
|
56
|
+
// Cursor
|
|
57
|
+
"cursor-pointer"
|
|
58
|
+
)}
|
|
59
|
+
style={{
|
|
60
|
+
animation: "pulse-glow 3s ease-in-out infinite",
|
|
61
|
+
}}
|
|
62
|
+
aria-label="Open search"
|
|
63
|
+
>
|
|
64
|
+
<Search className="w-4 h-4 text-muted-foreground" />
|
|
65
|
+
{/* Show placeholder text only on larger screens */}
|
|
66
|
+
<span className="hidden md:inline text-sm text-muted-foreground">
|
|
67
|
+
Search...
|
|
68
|
+
</span>
|
|
69
|
+
{/* Keyboard shortcut badge - hidden on small screens */}
|
|
70
|
+
<kbd
|
|
71
|
+
className={cn(
|
|
72
|
+
"hidden sm:flex items-center gap-0.5 px-1.5 py-0.5 rounded",
|
|
73
|
+
"bg-background/50 border border-border/50",
|
|
74
|
+
"text-xs text-muted-foreground font-mono"
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{isMac ? (
|
|
78
|
+
<>
|
|
79
|
+
<Command className="w-3 h-3" />
|
|
80
|
+
<span>K</span>
|
|
81
|
+
</>
|
|
82
|
+
) : (
|
|
83
|
+
<span>Ctrl+K</span>
|
|
84
|
+
)}
|
|
85
|
+
</kbd>
|
|
86
|
+
</button>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
Input,
|
|
7
|
+
DynamicIcon,
|
|
8
|
+
type LucideIconName,
|
|
9
|
+
} from "@checkmate-monitor/ui";
|
|
10
|
+
import { useDebouncedSearch, useFormatShortcut } from "../index";
|
|
11
|
+
import type { SearchResult } from "@checkmate-monitor/command-common";
|
|
12
|
+
import { Search, ArrowUp, ArrowDown, CornerDownLeft } from "lucide-react";
|
|
13
|
+
|
|
14
|
+
interface SearchDialogProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onOpenChange: (open: boolean) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const SearchDialog: React.FC<SearchDialogProps> = ({
|
|
20
|
+
open,
|
|
21
|
+
onOpenChange,
|
|
22
|
+
}) => {
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const formatShortcut = useFormatShortcut();
|
|
25
|
+
const { results, loading, search, reset } = useDebouncedSearch(300);
|
|
26
|
+
|
|
27
|
+
const [query, setQuery] = useState("");
|
|
28
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
29
|
+
|
|
30
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
|
+
|
|
32
|
+
// Trigger search when dialog opens or query changes
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (open) {
|
|
35
|
+
search(query);
|
|
36
|
+
// Focus input after dialog opens
|
|
37
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
38
|
+
} else {
|
|
39
|
+
// Reset state when dialog closes
|
|
40
|
+
setQuery("");
|
|
41
|
+
setSelectedIndex(0);
|
|
42
|
+
reset();
|
|
43
|
+
}
|
|
44
|
+
}, [open, query, search, reset]);
|
|
45
|
+
|
|
46
|
+
// Group results by category
|
|
47
|
+
const groupedResults: Record<string, SearchResult[]> = {};
|
|
48
|
+
for (const result of results) {
|
|
49
|
+
const category = result.category;
|
|
50
|
+
if (!groupedResults[category]) {
|
|
51
|
+
groupedResults[category] = [];
|
|
52
|
+
}
|
|
53
|
+
groupedResults[category].push(result);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Flatten for navigation
|
|
57
|
+
const flatResults = Object.values(groupedResults).flat();
|
|
58
|
+
|
|
59
|
+
// Reset selection when results change
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
setSelectedIndex(0);
|
|
62
|
+
}, [results]);
|
|
63
|
+
|
|
64
|
+
const handleSelect = useCallback(
|
|
65
|
+
(result: SearchResult) => {
|
|
66
|
+
onOpenChange(false);
|
|
67
|
+
if (result.route) {
|
|
68
|
+
navigate(result.route);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[navigate, onOpenChange]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Keyboard navigation
|
|
75
|
+
const handleKeyDown = useCallback(
|
|
76
|
+
(e: React.KeyboardEvent) => {
|
|
77
|
+
switch (e.key) {
|
|
78
|
+
case "ArrowDown": {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
setSelectedIndex((prev) =>
|
|
81
|
+
Math.min(prev + 1, flatResults.length - 1)
|
|
82
|
+
);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "ArrowUp": {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "Enter": {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (flatResults[selectedIndex]) {
|
|
93
|
+
handleSelect(flatResults[selectedIndex]);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "Escape": {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
onOpenChange(false);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[flatResults, selectedIndex, handleSelect, onOpenChange]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Render a single result item
|
|
108
|
+
const renderResult = (result: SearchResult, globalIndex: number) => {
|
|
109
|
+
const isSelected = globalIndex === selectedIndex;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
key={result.id}
|
|
114
|
+
onClick={() => handleSelect(result)}
|
|
115
|
+
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
|
116
|
+
className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors ${
|
|
117
|
+
isSelected
|
|
118
|
+
? "bg-primary/10 text-foreground"
|
|
119
|
+
: "text-muted-foreground hover:bg-muted/50"
|
|
120
|
+
}`}
|
|
121
|
+
>
|
|
122
|
+
<DynamicIcon
|
|
123
|
+
name={result.iconName as LucideIconName}
|
|
124
|
+
className="w-4 h-4 flex-shrink-0"
|
|
125
|
+
/>
|
|
126
|
+
<div className="flex-1 min-w-0">
|
|
127
|
+
<span className="block truncate">{result.title}</span>
|
|
128
|
+
{result.subtitle && (
|
|
129
|
+
<span className="block text-xs text-muted-foreground truncate">
|
|
130
|
+
{result.subtitle}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
{/* Show shortcuts for commands */}
|
|
135
|
+
{result.type === "command" &&
|
|
136
|
+
result.shortcuts &&
|
|
137
|
+
result.shortcuts.length > 0 && (
|
|
138
|
+
<div className="flex gap-1">
|
|
139
|
+
{result.shortcuts.slice(0, 1).map((shortcut) => (
|
|
140
|
+
<kbd
|
|
141
|
+
key={shortcut}
|
|
142
|
+
className="px-1.5 py-0.5 text-xs rounded bg-muted border border-border font-mono"
|
|
143
|
+
>
|
|
144
|
+
{formatShortcut(shortcut)}
|
|
145
|
+
</kbd>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
{isSelected && (
|
|
150
|
+
<CornerDownLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
151
|
+
)}
|
|
152
|
+
</button>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Track global index for selection
|
|
157
|
+
let globalIndex = 0;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
161
|
+
<DialogContent
|
|
162
|
+
size="lg"
|
|
163
|
+
className="p-0 gap-0 overflow-hidden"
|
|
164
|
+
onKeyDown={handleKeyDown}
|
|
165
|
+
>
|
|
166
|
+
{/* Search input */}
|
|
167
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
168
|
+
<Search className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
|
169
|
+
<Input
|
|
170
|
+
ref={inputRef}
|
|
171
|
+
value={query}
|
|
172
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
173
|
+
setQuery(e.target.value)
|
|
174
|
+
}
|
|
175
|
+
placeholder="Search commands and systems..."
|
|
176
|
+
className="border-0 bg-transparent focus-visible:ring-0 px-0 text-base"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Results */}
|
|
181
|
+
<div className="max-h-[300px] overflow-y-auto py-2">
|
|
182
|
+
{loading ? (
|
|
183
|
+
<div className="px-4 py-8 text-center text-muted-foreground">
|
|
184
|
+
Searching...
|
|
185
|
+
</div>
|
|
186
|
+
) : flatResults.length === 0 ? (
|
|
187
|
+
<div className="px-4 py-8 text-center text-muted-foreground">
|
|
188
|
+
{query ? "No results found" : "Start typing to search..."}
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
Object.entries(groupedResults).map(
|
|
192
|
+
([category, categoryResults]) => (
|
|
193
|
+
<div key={category}>
|
|
194
|
+
{/* Category header */}
|
|
195
|
+
<div className="px-4 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider bg-muted/30">
|
|
196
|
+
{category} ({categoryResults.length})
|
|
197
|
+
</div>
|
|
198
|
+
{/* Category results */}
|
|
199
|
+
{categoryResults.map((result) => {
|
|
200
|
+
const element = renderResult(result, globalIndex);
|
|
201
|
+
globalIndex++;
|
|
202
|
+
return element;
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Footer with keyboard hints */}
|
|
211
|
+
<div className="flex items-center gap-4 px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
|
|
212
|
+
<div className="flex items-center gap-1">
|
|
213
|
+
<ArrowUp className="w-3 h-3" />
|
|
214
|
+
<ArrowDown className="w-3 h-3" />
|
|
215
|
+
<span>Navigate</span>
|
|
216
|
+
</div>
|
|
217
|
+
<div className="flex items-center gap-1">
|
|
218
|
+
<CornerDownLeft className="w-3 h-3" />
|
|
219
|
+
<span>Select</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex items-center gap-1">
|
|
222
|
+
<kbd className="px-1 rounded bg-muted border border-border font-mono">
|
|
223
|
+
esc
|
|
224
|
+
</kbd>
|
|
225
|
+
<span>Close</span>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</DialogContent>
|
|
229
|
+
</Dialog>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
@@ -3,12 +3,32 @@ import {
|
|
|
3
3
|
useApi,
|
|
4
4
|
rpcApiRef,
|
|
5
5
|
createApiRef,
|
|
6
|
+
createFrontendPlugin,
|
|
7
|
+
NavbarCenterSlot,
|
|
6
8
|
} from "@checkmate-monitor/frontend-api";
|
|
7
9
|
import {
|
|
8
10
|
CommandApi,
|
|
11
|
+
pluginMetadata,
|
|
9
12
|
type SearchResult,
|
|
10
13
|
} from "@checkmate-monitor/command-common";
|
|
11
14
|
import type { InferClient } from "@checkmate-monitor/common";
|
|
15
|
+
import { NavbarSearch } from "./components/NavbarSearch";
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// PLUGIN
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
export const commandPlugin = createFrontendPlugin({
|
|
22
|
+
metadata: pluginMetadata,
|
|
23
|
+
routes: [],
|
|
24
|
+
extensions: [
|
|
25
|
+
{
|
|
26
|
+
id: "command.navbar.search",
|
|
27
|
+
slot: NavbarCenterSlot,
|
|
28
|
+
component: NavbarSearch,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
12
32
|
|
|
13
33
|
// =============================================================================
|
|
14
34
|
// API REF
|