mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 23:40:40 +02:00
🚧 WIP: copy command bar from existing PR
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from "@app/components/ui/command";
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCommandPaletteActions } from "./useCommandPaletteActions";
|
||||
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
|
||||
import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations";
|
||||
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
|
||||
|
||||
type CommandPaletteProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems: SidebarNavSection[];
|
||||
};
|
||||
|
||||
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { open, setOpen } = useCommandPalette();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const navigationGroups = useCommandPaletteNavigation(navItems);
|
||||
const organizations = useCommandPaletteOrganizations(orgs);
|
||||
const actions = useCommandPaletteActions(orgId, orgs);
|
||||
const { shouldSearch, sites, resources, users, machineClients, isLoading } =
|
||||
useCommandPaletteSearch({
|
||||
orgId,
|
||||
query: search,
|
||||
enabled: open
|
||||
});
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSearch("");
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: () => void) => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
command();
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const hasEntityResults =
|
||||
sites.length > 0 ||
|
||||
resources.length > 0 ||
|
||||
users.length > 0 ||
|
||||
machineClients.length > 0;
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t("commandPaletteTitle")}
|
||||
description={t("commandPaletteDescription")}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t("commandPaletteSearchPlaceholder")}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
||||
|
||||
{navigationGroups.map((group) => (
|
||||
<CommandGroup key={group.heading} heading={group.heading}>
|
||||
{group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.title} ${group.heading}`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(item.href))
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
|
||||
{organizations.length > 1 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteOrganizations")}
|
||||
>
|
||||
{organizations.map((org) => (
|
||||
<CommandItem
|
||||
key={org.id}
|
||||
value={`${org.name} ${org.orgId}`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(org.href))
|
||||
}
|
||||
>
|
||||
<span className="truncate">{org.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
{org.isPrimaryOrg && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldSearch && orgId && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
{isLoading && !hasEntityResults ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("commandPaletteSearching")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sites.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteSites")}
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.id}
|
||||
value={`${site.name} site`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(site.href)
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{site.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{resources.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteResources")}
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<CommandItem
|
||||
key={resource.id}
|
||||
value={`${resource.name} resource`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(
|
||||
resource.href
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteUsers")}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={`${user.name} ${user.email}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(user.href)
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{machineClients.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteClients")}
|
||||
>
|
||||
{machineClients.map((client) => (
|
||||
<CommandItem
|
||||
key={client.id}
|
||||
value={`${client.name} client`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(client.href)
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{client.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{actions.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPaletteActions")}>
|
||||
{actions.map((action) => (
|
||||
<CommandItem
|
||||
key={action.id}
|
||||
value={action.label}
|
||||
onSelect={() =>
|
||||
runCommand(() => {
|
||||
if (action.onSelect) {
|
||||
action.onSelect();
|
||||
} else if (action.href) {
|
||||
router.push(action.href);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
/* COMMAND PALETTE CONTEXT */
|
||||
/*******************************/
|
||||
export type CommandPaletteContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export function useCommandPalette() {
|
||||
const context = useContext(CommandPaletteContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCommandPalette must be used within CommandPaletteProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
//*******************************/
|
||||
/* COMMAND PALETTE PROVIDER */
|
||||
/*******************************/
|
||||
type CommandPaletteProviderProps = {
|
||||
children: React.ReactNode;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems: SidebarNavSection[];
|
||||
};
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tagName = target.tagName;
|
||||
return (
|
||||
tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider({
|
||||
children,
|
||||
orgId,
|
||||
orgs,
|
||||
navItems
|
||||
}: CommandPaletteProviderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((current) => !current);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<CommandPaletteContextValue>(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
toggle
|
||||
}),
|
||||
[open, toggle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key.toLowerCase() !== "k" ||
|
||||
!(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && isEditableTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, toggle]);
|
||||
|
||||
return (
|
||||
<CommandPaletteContext value={contextValue}>
|
||||
{children}
|
||||
<CommandPalette orgId={orgId} orgs={orgs} navItems={navItems} />
|
||||
</CommandPaletteContext>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user