154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|