feat: set up command pallette and search endpoint

This commit is contained in:
Henry
2025-09-29 22:33:36 +01:00
parent 6059fa35a2
commit e07a462184
4 changed files with 404 additions and 102 deletions

View File

@@ -0,0 +1,154 @@
import Link from "next/link";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Dialog,
DialogBackdrop,
DialogPanel,
} from "@headlessui/react";
import { t } from "@lingui/macro";
import { useState } from "react";
import { 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;
type: "board";
}
| {
publicId: string;
title: string;
description: string | null;
boardPublicId: string;
boardName: string;
listName: string;
type: "card";
};
export default function CommandPallette({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const { workspace } = useWorkspace();
// Debounce to avoid too many reqs
const [debouncedQuery] = useDebounce(query, 500);
const {
data: searchResults,
isLoading,
isFetched,
} = api.workspace.search.useQuery(
{
workspacePublicId: workspace.publicId,
query: debouncedQuery,
},
{
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
},
);
const results = (searchResults ?? []) as SearchResult[];
const hasSearched = Boolean(debouncedQuery.trim().length > 0 && isFetched);
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-base text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700 sm:text-sm"
placeholder="Search boards and cards..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<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"
>
{results.map((result) => (
<ComboboxOption
key={`${result.type}-${result.publicId}`}
value={result}
className="group"
>
<Link
href={
result.type === "board"
? `/boards/${result.publicId}`
: `/cards/${result.publicId}`
}
className="block 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={() => {
onClose();
setQuery("");
}}
>
<div className="flex items-start gap-3">
<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>
</Link>
</ComboboxOption>
))}
</ComboboxOptions>
)}
{hasSearched && !isLoading && results.length === 0 && (
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
{t`No results found for "${query}".`}
</div>
)}
</Combobox>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

View File

@@ -1,11 +1,12 @@
import { Menu, Transition } from "@headlessui/react"; import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Fragment } from "react"; import { Fragment, useState } from "react";
import { HiCheck } from "react-icons/hi2"; import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import CommandPallette from "./CommandPallette";
export default function WorkspaceMenu({ export default function WorkspaceMenu({
isCollapsed = false, isCollapsed = false,
@@ -15,8 +16,11 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } = const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace(); useWorkspace();
const { openModal } = useModal(); const { openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
return ( return (
<>
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
<Menu as="div" className="relative inline-block w-full pb-3 text-left"> <Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
@@ -30,9 +34,10 @@ export default function WorkspaceMenu({
/> />
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1">
<Menu.Button <Menu.Button
className={twMerge( 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", "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:p-1", isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
)} )}
title={isCollapsed ? workspace.name : undefined} title={isCollapsed ? workspace.name : undefined}
@@ -61,6 +66,16 @@ export default function WorkspaceMenu({
</span> </span>
)} )}
</Menu.Button> </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:hidden",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)} )}
</div> </div>
@@ -120,5 +135,6 @@ export default function WorkspaceMenu({
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
</>
); );
} }

View File

@@ -409,4 +409,74 @@ export const workspaceRouter = createTRPCRouter({
isReserved: workspaceSlug?.type === "reserved", isReserved: workspaceSlug?.type === "reserved",
}; };
}), }),
search: protectedProcedure
.meta({
openapi: {
summary: "Search boards and cards in a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/search",
description:
"Searches for boards and cards by title within a workspace",
tags: ["Workspaces"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
query: z.string().min(1).max(100),
}),
)
.output(
z.array(
z.discriminatedUnion("type", [
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
slug: z.string(),
type: z.literal("board"),
}),
z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
type: z.literal("card"),
}),
]),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(ctx.db, userId, workspace.id);
const result = await workspaceRepo.searchBoardsAndCards(
ctx.db,
workspace.id,
input.query,
);
return result;
}),
}); });

View File

@@ -1,7 +1,13 @@
import { and, eq, inArray, isNull } from "drizzle-orm"; import { and, eq, ilike, inArray, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import { boards, workspaceMembers, workspaces } from "@kan/db/schema"; import {
boards,
cards,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils"; import { generateUID } from "@kan/shared/utils";
export const create = async ( export const create = async (
@@ -274,3 +280,59 @@ export const isUserInWorkspace = async (
return result?.id !== undefined; return result?.id !== undefined;
}; };
export const searchBoardsAndCards = async (
db: dbClient,
workspaceId: number,
query: string,
) => {
const searchQuery = `%${query}%`;
// Search for boards
const boardResults = await db
.select({
publicId: boards.publicId,
title: boards.name,
description: boards.description,
slug: boards.slug,
})
.from(boards)
.where(
and(
eq(boards.workspaceId, workspaceId),
ilike(boards.name, searchQuery),
isNull(boards.deletedAt),
),
);
// Search for cards
const cardResults = await db
.select({
publicId: cards.publicId,
title: cards.title,
description: cards.description,
boardPublicId: boards.publicId,
boardName: boards.name,
listName: lists.name,
})
.from(cards)
.innerJoin(lists, eq(cards.listId, lists.id))
.innerJoin(boards, eq(lists.boardId, boards.id))
.where(
and(
eq(boards.workspaceId, workspaceId),
ilike(cards.title, searchQuery),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
),
);
// Combine results
const allResults = [
...boardResults.map((board) => ({ ...board, type: "board" as const })),
...cardResults.map((card) => ({ ...card, type: "card" as const })),
];
return allResults;
};