aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-03-01 21:01:00 +0000
committerMohamedBassem <me@mbassem.com>2024-03-01 22:11:49 +0000
commita5434730ede1272f195d6a4b13207b840a5ac2cf (patch)
tree14c8a22fbf573b36f16a434349fd3516b38ea539 /packages/web
parent75d315dda4232ee3b89abf054f0b6ee10105ffe3 (diff)
downloadkarakeep-a5434730ede1272f195d6a4b13207b840a5ac2cf.tar.zst
feature: Add full text search support
Diffstat (limited to 'packages/web')
-rw-r--r--packages/web/app/dashboard/components/Sidebar.tsx18
-rw-r--r--packages/web/app/dashboard/search/page.tsx93
-rw-r--r--packages/web/package.json1
-rw-r--r--packages/web/server/api/routers/bookmarks.ts62
4 files changed, 172 insertions, 2 deletions
diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx
index 7eea6b6d..010ee103 100644
--- a/packages/web/app/dashboard/components/Sidebar.tsx
+++ b/packages/web/app/dashboard/components/Sidebar.tsx
@@ -1,4 +1,12 @@
-import { Archive, Star, Tag, Home, PackageOpen, Settings } from "lucide-react";
+import {
+ Archive,
+ Star,
+ Tag,
+ Home,
+ PackageOpen,
+ Settings,
+ Search,
+} from "lucide-react";
import { redirect } from "next/navigation";
import SidebarItem from "./SidebarItem";
import { getServerAuthSession } from "@/server/auth";
@@ -6,6 +14,7 @@ import Link from "next/link";
import SidebarProfileOptions from "./SidebarProfileOptions";
import { Separator } from "@/components/ui/separator";
import AllLists from "./AllLists";
+import serverConfig from "@hoarder/shared/config";
export default async function Sidebar() {
const session = await getServerAuthSession();
@@ -34,6 +43,13 @@ export default async function Sidebar() {
name="Favourites"
path="/dashboard/bookmarks/favourites"
/>
+ {serverConfig.meilisearch && (
+ <SidebarItem
+ logo={<Search />}
+ name="Search"
+ path="/dashboard/search"
+ />
+ )}
<SidebarItem
logo={<Archive />}
name="Archive"
diff --git a/packages/web/app/dashboard/search/page.tsx b/packages/web/app/dashboard/search/page.tsx
new file mode 100644
index 00000000..1c26608e
--- /dev/null
+++ b/packages/web/app/dashboard/search/page.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { api } from "@/lib/trpc";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import BookmarksGrid from "../bookmarks/components/BookmarksGrid";
+import { Input } from "@/components/ui/input";
+import Loading from "../bookmarks/loading";
+import { keepPreviousData } from "@tanstack/react-query";
+import { Search } from "lucide-react";
+import { ActionButton } from "@/components/ui/action-button";
+import { Suspense, useRef } from "react";
+
+function SearchComp() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const searchQuery = searchParams.get("q") || "";
+
+ const { data, isPending, isPlaceholderData, error } =
+ api.bookmarks.searchBookmarks.useQuery(
+ {
+ text: searchQuery,
+ },
+ {
+ placeholderData: keepPreviousData,
+ },
+ );
+
+ if (error) {
+ throw error;
+ }
+
+ const inputRef: React.MutableRefObject<HTMLInputElement | null> =
+ useRef<HTMLInputElement | null>(null);
+
+ let timeoutId: NodeJS.Timeout | undefined;
+
+ // Debounce user input
+ const doSearch = () => {
+ if (!inputRef.current) {
+ return;
+ }
+ router.replace(`${pathname}?q=${inputRef.current.value}`);
+ };
+
+ const onInputChange = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ timeoutId = setTimeout(() => {
+ doSearch();
+ }, 200);
+ };
+
+ return (
+ <div className="container flex flex-col gap-3 p-4">
+ <div className="flex gap-2">
+ <Input
+ ref={inputRef}
+ placeholder="Search"
+ defaultValue={searchQuery}
+ onChange={onInputChange}
+ />
+ <ActionButton
+ loading={isPending || isPlaceholderData}
+ onClick={doSearch}
+ >
+ <span className="flex gap-2">
+ <Search />
+ <span className="my-auto">Search</span>
+ </span>
+ </ActionButton>
+ </div>
+ <hr />
+ {data ? (
+ <BookmarksGrid
+ query={{ ids: data.bookmarks.map((b) => b.id) }}
+ bookmarks={data.bookmarks}
+ />
+ ) : (
+ <Loading />
+ )}
+ </div>
+ );
+}
+
+export default function SearchPage() {
+ return (
+ <Suspense>
+ <SearchComp />
+ </Suspense>
+ );
+}
diff --git a/packages/web/package.json b/packages/web/package.json
index 7687704f..b25fc2e9 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -41,6 +41,7 @@
"drizzle-orm": "^0.29.4",
"install": "^0.13.0",
"lucide-react": "^0.322.0",
+ "meilisearch": "^0.37.0",
"next": "14.1.0",
"next-auth": "^4.24.5",
"prettier": "^3.2.5",
diff --git a/packages/web/server/api/routers/bookmarks.ts b/packages/web/server/api/routers/bookmarks.ts
index 8b59f1ef..73818508 100644
--- a/packages/web/server/api/routers/bookmarks.ts
+++ b/packages/web/server/api/routers/bookmarks.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { Context, authedProcedure, router } from "../trpc";
+import { getSearchIdxClient } from "@hoarder/shared/search";
import {
ZBookmark,
ZBookmarkContent,
@@ -17,7 +18,11 @@ import {
bookmarks,
tagsOnBookmarks,
} from "@hoarder/db/schema";
-import { LinkCrawlerQueue, OpenAIQueue } from "@hoarder/shared/queues";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+} from "@hoarder/shared/queues";
import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
import { and, desc, eq, inArray } from "drizzle-orm";
import { ZBookmarkTags } from "@/lib/types/api/tags";
@@ -172,6 +177,10 @@ export const bookmarksAppRouter = router({
break;
}
}
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: bookmark.id,
+ type: "index",
+ });
return bookmark;
}),
@@ -224,6 +233,10 @@ export const bookmarksAppRouter = router({
message: "Bookmark not found",
});
}
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "index",
+ });
}),
deleteBookmark: authedProcedure
@@ -238,6 +251,10 @@ export const bookmarksAppRouter = router({
eq(bookmarks.id, input.bookmarkId),
),
);
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "delete",
+ });
}),
recrawlBookmark: authedProcedure
.input(z.object({ bookmarkId: z.string() }))
@@ -280,6 +297,49 @@ export const bookmarksAppRouter = router({
return toZodSchema(bookmark);
}),
+ searchBookmarks: authedProcedure
+ .input(
+ z.object({
+ text: z.string(),
+ }),
+ )
+ .output(zGetBookmarksResponseSchema)
+ .query(async ({ input, ctx }) => {
+ const client = await getSearchIdxClient();
+ if (!client) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Search functionality is not configured",
+ });
+ }
+ const resp = await client.search(input.text, {
+ filter: [`userId = '${ctx.user.id}'`],
+ });
+
+ if (resp.hits.length == 0) {
+ return { bookmarks: [] };
+ }
+ const results = await ctx.db.query.bookmarks.findMany({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ inArray(
+ bookmarks.id,
+ resp.hits.map((h) => h.id),
+ ),
+ ),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+
+ return { bookmarks: results.map(toZodSchema) };
+ }),
getBookmarks: authedProcedure
.input(zGetBookmarksRequestSchema)
.output(zGetBookmarksResponseSchema)