feat(ui): add global search palette with Cmd+K shortcut
- Add GlobalSearchPalette component for global search overlay - Introduce global search event system for cross-component communication - Add project permissions library for access control checks
This commit is contained in:
parent
f597da6e33
commit
7ec848470e
@ -7,6 +7,7 @@ import { issueSummary } from "@/client/api";
|
||||
import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
|
||||
import { SettingsModal } from "@/components/settings/SettingsModal";
|
||||
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
|
||||
import { GlobalSearchPalette } from "@/components/search/GlobalSearchPalette";
|
||||
import { initWsClient, getWsClient } from "@/ws/hooks";
|
||||
import type { WsClient } from "@/ws/client";
|
||||
|
||||
@ -94,6 +95,7 @@ export function RootLayout() {
|
||||
<SettingsDataCacheProvider>
|
||||
<SettingsModalContext.Provider value={modalCtx}>
|
||||
<Outlet />
|
||||
<GlobalSearchPalette />
|
||||
{showSettingsModal && <SettingsModal />}
|
||||
</SettingsModalContext.Provider>
|
||||
</SettingsDataCacheProvider>
|
||||
|
||||
248
src/components/search/GlobalSearchPalette.tsx
Normal file
248
src/components/search/GlobalSearchPalette.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Boxes,
|
||||
FolderGit2,
|
||||
Hash,
|
||||
Kanban,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
} from "lucide-react";
|
||||
import { search as globalSearch } from "@/client/api";
|
||||
import { useBoardsQuery } from "@/hooks/useBoardsQuery";
|
||||
import { useProjectInfo } from "@/hooks/useProjectInfo";
|
||||
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
|
||||
import { useProjectReposQuery } from "@/hooks/useReposQuery";
|
||||
import { useRoomsQuery } from "@/hooks/useRoomsQuery";
|
||||
import { isProjectAdminRole } from "@/lib/project-permissions";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import { OPEN_GLOBAL_SEARCH_EVENT } from "@/components/search/global-search-events";
|
||||
|
||||
function useDebouncedValue(value: string, delay = 180) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delay, value]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function getProjectNameFromPath(pathname: string) {
|
||||
const first = pathname.split("/").filter(Boolean)[0];
|
||||
if (!first || first === "me" || first === "auth" || first === "explore") return undefined;
|
||||
return first;
|
||||
}
|
||||
|
||||
export function GlobalSearchPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebouncedValue(query.trim());
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeProjectName = getProjectNameFromPath(location.pathname);
|
||||
|
||||
const { data: projects = [] } = useProjectsQuery();
|
||||
const { data: projectInfo } = useProjectInfo(activeProjectName);
|
||||
const isProjectMember = !!projectInfo?.role;
|
||||
const canManageProject = isProjectAdminRole(projectInfo?.role);
|
||||
const { data: repos = [] } = useProjectReposQuery(isProjectMember ? activeProjectName : undefined);
|
||||
const { data: roomsData } = useRoomsQuery(isProjectMember ? activeProjectName : undefined);
|
||||
const { data: boards = [] } = useBoardsQuery(isProjectMember ? activeProjectName : undefined);
|
||||
|
||||
const remoteResults = useQuery({
|
||||
queryKey: ["global-search-palette", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
const res = await globalSearch({ q: debouncedQuery, type: "projects,repos,issues", page: 1, per_page: 8 });
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
enabled: open && debouncedQuery.length >= 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey && event.altKey && event.key.toLowerCase() === "f") {
|
||||
event.preventDefault();
|
||||
setOpen((current) => !current);
|
||||
}
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rooms = roomsData?.rooms ?? [];
|
||||
const quickActions = useMemo(() => {
|
||||
if (!activeProjectName) {
|
||||
return [
|
||||
{ label: "My Home", description: "Open personal overview", path: "/me", icon: LayoutDashboard },
|
||||
{ label: "Explore Projects", description: "Find public projects", path: "/explore", icon: Search },
|
||||
{ label: "Chat", description: "Open personal chat", path: "/me/chat", icon: MessageSquare },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Project Overview", description: activeProjectName, path: `/${activeProjectName}`, icon: LayoutDashboard },
|
||||
{ label: "Repositories", description: activeProjectName, path: `/${activeProjectName}/repos`, icon: FolderGit2 },
|
||||
{ label: "Issues", description: activeProjectName, path: `/${activeProjectName}/issues`, icon: Ticket },
|
||||
{ label: "Boards", description: activeProjectName, path: `/${activeProjectName}/board`, icon: Kanban },
|
||||
{ label: "Chat", description: activeProjectName, path: `/${activeProjectName}/chat`, icon: MessageSquare },
|
||||
...(canManageProject
|
||||
? [{ label: "Project Settings", description: activeProjectName, path: `/${activeProjectName}/settings`, icon: Settings }]
|
||||
: []),
|
||||
];
|
||||
}, [activeProjectName, canManageProject]);
|
||||
|
||||
const go = (path: string) => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Search"
|
||||
description="Jump to projects, rooms, repositories, boards, and issues."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search projects, rooms, repos, boards, issues..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList className="max-h-[min(70vh,520px)]">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Quick Switch">
|
||||
{quickActions.map((item) => (
|
||||
<CommandItem key={item.path} value={`${item.label} ${item.description}`} onSelect={() => go(item.path)}>
|
||||
<item.icon />
|
||||
<span className="truncate">{item.label}</span>
|
||||
<CommandShortcut>{item.description}</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.uid}
|
||||
value={`${project.display_name} ${project.name} project`}
|
||||
onSelect={() => go(`/${project.name}`)}
|
||||
>
|
||||
<Boxes />
|
||||
<span className="truncate">{project.display_name || project.name}</span>
|
||||
<CommandShortcut>{project.name}</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{activeProjectName && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={`Rooms in ${activeProjectName}`}>
|
||||
{rooms.map((room) => (
|
||||
<CommandItem
|
||||
key={room.id}
|
||||
value={`${room.room_name} room channel ${activeProjectName}`}
|
||||
onSelect={() => go(`/${activeProjectName}/channel/${room.id}`)}
|
||||
>
|
||||
<Hash />
|
||||
<span className="truncate">{room.room_name}</span>
|
||||
<CommandShortcut>room</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Repositories">
|
||||
{repos.map((repo) => (
|
||||
<CommandItem
|
||||
key={repo.uid}
|
||||
value={`${repo.repo_name} ${repo.description ?? ""} repo ${activeProjectName}`}
|
||||
onSelect={() => go(`/${activeProjectName}/repo/${repo.repo_name}`)}
|
||||
>
|
||||
<FolderGit2 />
|
||||
<span className="truncate">{repo.repo_name}</span>
|
||||
<CommandShortcut>repo</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Boards">
|
||||
{boards.map((board) => (
|
||||
<CommandItem
|
||||
key={board.id}
|
||||
value={`${board.name} ${board.description ?? ""} board ${activeProjectName}`}
|
||||
onSelect={() => go(`/${activeProjectName}/board/${board.id}`)}
|
||||
>
|
||||
<Kanban />
|
||||
<span className="truncate">{board.name}</span>
|
||||
<CommandShortcut>board</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{remoteResults.data && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Search Results">
|
||||
{(remoteResults.data.projects?.items ?? []).map((project) => (
|
||||
<CommandItem key={`remote-project-${project.uid}`} value={`${project.display_name} ${project.name}`} onSelect={() => go(`/${project.name}`)}>
|
||||
<Boxes />
|
||||
<span className="truncate">{project.display_name || project.name}</span>
|
||||
<CommandShortcut>project</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
{(remoteResults.data.repos?.items ?? []).map((repo) => (
|
||||
<CommandItem key={`remote-repo-${repo.uid}`} value={`${repo.name} ${repo.project_name}`} onSelect={() => go(`/${repo.project_name}/repo/${repo.name}`)}>
|
||||
<FolderGit2 />
|
||||
<span className="truncate">{repo.name}</span>
|
||||
<CommandShortcut>{repo.project_name}</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
{(remoteResults.data.issues?.items ?? []).map((issue) => (
|
||||
<CommandItem key={`remote-issue-${issue.uid}`} value={`${issue.title} ${issue.project_name} #${issue.number}`} onSelect={() => go(`/${issue.project_name}/issues/${issue.number}`)}>
|
||||
<Sparkles />
|
||||
<span className="truncate">#{issue.number} {issue.title}</span>
|
||||
<CommandShortcut>issue</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
5
src/components/search/global-search-events.ts
Normal file
5
src/components/search/global-search-events.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const OPEN_GLOBAL_SEARCH_EVENT = "app:open-global-search";
|
||||
|
||||
export function openGlobalSearch() {
|
||||
window.dispatchEvent(new Event(OPEN_GLOBAL_SEARCH_EVENT));
|
||||
}
|
||||
10
src/lib/project-permissions.ts
Normal file
10
src/lib/project-permissions.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { MemberRole } from "@/client/model";
|
||||
|
||||
export function isProjectAdminRole(role?: MemberRole | string | null) {
|
||||
const normalized = String(role ?? "").toLowerCase();
|
||||
return normalized === "owner" || normalized === "admin";
|
||||
}
|
||||
|
||||
export function isProjectOwnerRole(role?: MemberRole | string | null) {
|
||||
return String(role ?? "").toLowerCase() === "owner";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user