feat: basic workspace search (#203)
* feat: set up command pallette and search endpoint * feat: use placeholder data in search results * feat: add keyboard navigation * feat: add icons for search results * feat: add fuzzy search * feat: add search button to collapsed menu * feat: add keyboard shortcuts * fix: prevent no results flickering * feat: consistent font size for search placeholder * chore: build translations
This commit is contained in:
193
apps/web/src/components/CommandPallette.tsx
Normal file
193
apps/web/src/components/CommandPallette.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/macro";
|
||||
import { useState } from "react";
|
||||
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type SearchResult =
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "board";
|
||||
}
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
};
|
||||
|
||||
export default function CommandPallette({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { workspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
// Debounce to avoid too many reqs
|
||||
const [debouncedQuery] = useDebounce(query, 300);
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
isLoading,
|
||||
isFetched,
|
||||
isPlaceholderData,
|
||||
} = api.workspace.search.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
query: debouncedQuery,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
// Clear results when query is empty, otherwise show search results
|
||||
const results =
|
||||
debouncedQuery.trim().length === 0
|
||||
? []
|
||||
: ((searchResults ?? []) as SearchResult[]);
|
||||
|
||||
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="relative z-50"
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
|
||||
>
|
||||
<Combobox>
|
||||
<div className="grid grid-cols-1">
|
||||
<ComboboxInput
|
||||
autoFocus
|
||||
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
|
||||
placeholder={t`Search boards and cards...`}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && results.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Find the active option or fallback to first option
|
||||
const targetOption =
|
||||
document.querySelector(
|
||||
'[data-headlessui-state*="active"][role="option"]',
|
||||
) ?? document.querySelector('[role="option"]');
|
||||
|
||||
if (targetOption) {
|
||||
(targetOption as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<HiMagnifyingGlass
|
||||
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ComboboxOptions
|
||||
static
|
||||
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
|
||||
isPlaceholderData ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
{results.map((result) => {
|
||||
const url =
|
||||
result.type === "board"
|
||||
? `/boards/${result.publicId}`
|
||||
: `/cards/${result.publicId}`;
|
||||
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={`${result.type}-${result.publicId}`}
|
||||
value={result}
|
||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||
onClick={() => {
|
||||
console.log("clicked", url);
|
||||
void router.push(url);
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{result.type === "board" ? (
|
||||
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
) : (
|
||||
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
{`${t`in`} ${result.boardName} → ${result.listName}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
|
||||
{hasSearched &&
|
||||
!isLoading &&
|
||||
searchResults !== undefined &&
|
||||
results.length === 0 && (
|
||||
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
|
||||
{t`No results found for "${debouncedQuery}".`}
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
@@ -15,110 +16,143 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
<>
|
||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1",
|
||||
isCollapsed && "md:flex-col-reverse md:items-stretch",
|
||||
)}
|
||||
>
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed &&
|
||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user