Files
homelab-optimized/dashboard/ui/components/command-search.tsx
Gitea Mirror Bot e7652c8dab
Some checks failed
Documentation / Build Docusaurus (push) Failing after 5m3s
Documentation / Deploy to GitHub Pages (push) Has been skipped
Sanitized mirror from private repository - 2026-04-20 01:32:01 UTC
2026-04-20 01:32:01 +00:00

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>
</>
);
}