164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { useListTools, getListToolsQueryKey } from "@workspace/api-client-react";
|
|
import {
|
|
CommandDialog,
|
|
CommandInput,
|
|
CommandList,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
CommandSeparator,
|
|
} from "@/components/ui/command";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useTranslation } from "react-i18next";
|
|
import { getRecentTools } from "@/lib/recent-tools";
|
|
import {
|
|
Compass,
|
|
PlusCircle,
|
|
BarChart3,
|
|
Trash2,
|
|
ShieldCheck,
|
|
Wrench,
|
|
Star,
|
|
History,
|
|
Search,
|
|
} from "lucide-react";
|
|
|
|
const openRequesters = new Set<() => void>();
|
|
|
|
export function requestOpenCommandPalette() {
|
|
for (const request of openRequesters) request();
|
|
}
|
|
|
|
export function CommandPalette() {
|
|
const [, navigate] = useLocation();
|
|
const { t } = useTranslation();
|
|
const { isAuthenticated, isAdmin, hasFeature } = useAuth();
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [recent, setRecent] = useState(() => getRecentTools());
|
|
|
|
useEffect(() => {
|
|
const request = () => setOpen(true);
|
|
openRequesters.add(request);
|
|
return () => {
|
|
openRequesters.delete(request);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
setOpen((o) => !o);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", down);
|
|
return () => window.removeEventListener("keydown", down);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSearch("");
|
|
} else {
|
|
setRecent(getRecentTools());
|
|
}
|
|
}, [open]);
|
|
|
|
const searching = search.trim().length > 0;
|
|
|
|
const { data: tools } = useListTools(
|
|
searching ? { search } : undefined,
|
|
{
|
|
query: {
|
|
queryKey: getListToolsQueryKey(searching ? { search } : undefined),
|
|
enabled: open && searching,
|
|
staleTime: 30_000,
|
|
},
|
|
},
|
|
);
|
|
|
|
function go(path: string) {
|
|
setOpen(false);
|
|
navigate(path);
|
|
}
|
|
|
|
const showTrash = hasFeature("trash") || isAdmin;
|
|
const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated;
|
|
|
|
return (
|
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
<CommandInput
|
|
autoFocus
|
|
placeholder={`${t("common.search")}…`}
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
{searching ? t("command.noResults", { query: search }) : t("command.startTyping")}
|
|
</CommandEmpty>
|
|
|
|
{!searching && (
|
|
<>
|
|
{recent.length > 0 && (
|
|
<CommandGroup heading={t("command.recent")}>
|
|
{recent.map((tool) => (
|
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
|
<History className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
<span className="truncate">{tool.name}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
<CommandGroup heading={t("command.navigate")}>
|
|
<CommandItem onSelect={() => go("/tools")}>
|
|
<Compass className="mr-2 h-4 w-4" /> {t("nav.browseTools")}
|
|
</CommandItem>
|
|
<CommandItem onSelect={() => go("/tools/new")}>
|
|
<PlusCircle className="mr-2 h-4 w-4" /> {t("nav.addTool")}
|
|
</CommandItem>
|
|
<CommandItem onSelect={() => go("/analytics")}>
|
|
<BarChart3 className="mr-2 h-4 w-4" /> {t("nav.analytics")}
|
|
</CommandItem>
|
|
{showWatchlist && (
|
|
<CommandItem onSelect={() => go("/watchlist")}>
|
|
<Star className="mr-2 h-4 w-4" /> {t("nav.watchlist")}
|
|
</CommandItem>
|
|
)}
|
|
{showTrash && (
|
|
<CommandItem onSelect={() => go("/trash")}>
|
|
<Trash2 className="mr-2 h-4 w-4" /> {t("nav.trash")}
|
|
</CommandItem>
|
|
)}
|
|
{isAdmin && (
|
|
<CommandItem onSelect={() => go("/admin")}>
|
|
<ShieldCheck className="mr-2 h-4 w-4" /> {t("nav.admin")}
|
|
</CommandItem>
|
|
)}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
|
|
{searching && (
|
|
<>
|
|
<CommandSeparator />
|
|
<CommandGroup heading={t("command.tools")}>
|
|
{(tools ?? []).slice(0, 10).map((tool) => (
|
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{tool.name}</span>
|
|
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
|
{tool.avgCombined ? `${tool.avgCombined.toFixed(1)}★` : ""}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</CommandDialog>
|
|
);
|
|
}
|