Sanitized mirror from private repository - 2026-04-18 11:19:59 UTC
This commit is contained in:
153
dashboard/ui/components/command-search.tsx
Normal file
153
dashboard/ui/components/command-search.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface SearchResult {
|
||||
type: "page" | "container" | "node" | "dns" | "action";
|
||||
title: string;
|
||||
description?: string;
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
const PAGES: SearchResult[] = [
|
||||
{ type: "page", title: "Dashboard", href: "/" },
|
||||
{ type: "page", title: "Infrastructure", href: "/infrastructure" },
|
||||
{ type: "page", title: "Media", href: "/media" },
|
||||
{ type: "page", title: "Automations", href: "/automations" },
|
||||
{ type: "page", title: "Expenses", href: "/expenses" },
|
||||
{ type: "page", title: "Network", href: "/network" },
|
||||
{ type: "page", title: "Logs", href: "/logs" },
|
||||
];
|
||||
|
||||
export function CommandSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Open on Cmd+K / Ctrl+K
|
||||
useEffect(() => {
|
||||
function handler(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(prev => !prev);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSelected(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Search
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults(PAGES);
|
||||
return;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const filtered = PAGES.filter(p => p.title.toLowerCase().includes(q));
|
||||
|
||||
// Also search for dynamic items (async fetch could go here in future)
|
||||
// For now just do page search + some action shortcuts
|
||||
const actions: SearchResult[] = [];
|
||||
if ("restart".includes(q) || "jellyfin".includes(q)) {
|
||||
actions.push({ type: "action", title: "Restart Jellyfin", description: "Restart Jellyfin on Olares" });
|
||||
}
|
||||
if ("restart".includes(q) || "ollama".includes(q)) {
|
||||
actions.push({ type: "action", title: "Restart Ollama", description: "Restart Ollama on Olares" });
|
||||
}
|
||||
if ("backup".includes(q)) {
|
||||
actions.push({ type: "action", title: "Run Backup", description: "Run email backup now" });
|
||||
}
|
||||
|
||||
setResults([...filtered, ...actions]);
|
||||
setSelected(0);
|
||||
}, [query]);
|
||||
|
||||
const execute = useCallback((result: SearchResult) => {
|
||||
setOpen(false);
|
||||
if (result.href) router.push(result.href);
|
||||
if (result.action) result.action();
|
||||
}, [router]);
|
||||
|
||||
// Keyboard nav
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
|
||||
if (e.key === "Enter" && results[selected]) { execute(results[selected]); }
|
||||
}, [results, selected, execute]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
page: "->", container: "[]", node: "(o)", dns: "<>", action: "!",
|
||||
};
|
||||
const typeColors: Record<string, string> = {
|
||||
page: "text-blue-400", container: "text-cyan-400", node: "text-green-400",
|
||||
dns: "text-amber-400", action: "text-violet-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[200]" onClick={() => setOpen(false)} />
|
||||
<div className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-lg z-[201]">
|
||||
<div className="rounded-2xl overflow-hidden shadow-2xl" style={{
|
||||
background: "rgba(10, 10, 25, 0.95)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
backdropFilter: "blur(30px)",
|
||||
}}>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.08]">
|
||||
<span className="text-muted-foreground text-sm">Ctrl+K</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search pages, containers, actions..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
style={{ color: "#f1f5f9" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||
{results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground/60 text-center py-6">No results</p>
|
||||
)}
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={`${r.type}-${r.title}`}
|
||||
onClick={() => execute(r)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
i === selected ? "bg-white/[0.08]" : "hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm ${typeColors[r.type] ?? ""}`}>{typeIcons[r.type] ?? "·"}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: "#f1f5f9" }}>{r.title}</p>
|
||||
{r.description && <p className="text-xs text-muted-foreground/60 truncate">{r.description}</p>}
|
||||
</div>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/40 capitalize">{r.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-white/[0.06] flex gap-4 text-[10px] text-muted-foreground/40">
|
||||
<span>Up/Down navigate</span>
|
||||
<span>Enter select</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user