aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2024-12-31 13:17:56 +0200
committerGitHub <noreply@github.com>2024-12-31 13:17:56 +0200
commitcbaf9e6034aa09911fca967b7af6cad11f154b3e (patch)
tree6995d9d60d9ae5181af78e6577f8d7b724d7a971 /apps/web
parentf476fca758bb039f9605488b61ba35fc097d6cfc (diff)
downloadkarakeep-cbaf9e6034aa09911fca967b7af6cad11f154b3e.tar.zst
feat: Introduce advanced search capabilities (#753)
* feat: Implement search filtering in the backend * feat: Implement search language parser * rename matcher name * Add ability to interleve text * More fixes * be more tolerable to parsing errors * Add a search query explainer widget * Handle date parsing gracefully * Fix the lockfile * Encode query search param * Fix table body error * Fix error when writing quotes
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx98
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx27
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts20
3 files changed, 128 insertions, 17 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
new file mode 100644
index 00000000..191c9ff3
--- /dev/null
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -0,0 +1,98 @@
+import InfoTooltip from "@/components/ui/info-tooltip";
+import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
+
+import { TextAndMatcher } from "@hoarder/shared/searchQueryParser";
+import { Matcher } from "@hoarder/shared/types/search";
+
+export default function QueryExplainerTooltip({
+ parsedSearchQuery,
+ className,
+}: {
+ parsedSearchQuery: TextAndMatcher & { result: string };
+ className?: string;
+}) {
+ if (parsedSearchQuery.result == "invalid") {
+ return null;
+ }
+
+ const MatcherComp = ({ matcher }: { matcher: Matcher }) => {
+ switch (matcher.type) {
+ case "tagName":
+ return (
+ <TableRow>
+ <TableCell>Tag Name</TableCell>
+ <TableCell>{matcher.tagName}</TableCell>
+ </TableRow>
+ );
+ case "listName":
+ return (
+ <TableRow>
+ <TableCell>List Name</TableCell>
+ <TableCell>{matcher.listName}</TableCell>
+ </TableRow>
+ );
+ case "dateAfter":
+ return (
+ <TableRow>
+ <TableCell>Created After</TableCell>
+ <TableCell>{matcher.dateAfter.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "dateBefore":
+ return (
+ <TableRow>
+ <TableCell>Created Before</TableCell>
+ <TableCell>{matcher.dateBefore.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "favourited":
+ return (
+ <TableRow>
+ <TableCell>Favourited</TableCell>
+ <TableCell>{matcher.favourited.toString()}</TableCell>
+ </TableRow>
+ );
+ case "archived":
+ return (
+ <TableRow>
+ <TableCell>Archived</TableCell>
+ <TableCell>{matcher.archived.toString()}</TableCell>
+ </TableRow>
+ );
+ case "and":
+ case "or":
+ return (
+ <TableRow>
+ <TableCell className="capitalize">{matcher.type}</TableCell>
+ <TableCell>
+ <Table>
+ <TableBody>
+ {matcher.matchers.map((m, i) => (
+ <MatcherComp key={i} matcher={m} />
+ ))}
+ </TableBody>
+ </Table>
+ </TableCell>
+ </TableRow>
+ );
+ }
+ };
+
+ return (
+ <InfoTooltip className={className}>
+ <Table>
+ <TableBody>
+ {parsedSearchQuery.text && (
+ <TableRow>
+ <TableCell>Text</TableCell>
+ <TableCell>{parsedSearchQuery.text}</TableCell>
+ </TableRow>
+ )}
+ {parsedSearchQuery.matcher && (
+ <MatcherComp matcher={parsedSearchQuery.matcher} />
+ )}
+ </TableBody>
+ </Table>
+ </InfoTooltip>
+ );
+}
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 55f304e3..8ed2ea3c 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -4,6 +4,9 @@ import React, { useEffect, useImperativeHandle, useRef } from "react";
import { Input } from "@/components/ui/input";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
+import { cn } from "@/lib/utils";
+
+import QueryExplainerTooltip from "./QueryExplainerTooltip";
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
@@ -47,7 +50,8 @@ const SearchInput = React.forwardRef<
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { t } = useTranslation();
- const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch();
+ const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } =
+ useDoBookmarkSearch();
const [value, setValue] = React.useState(searchQuery);
@@ -67,14 +71,19 @@ const SearchInput = React.forwardRef<
}, [isInSearchPage]);
return (
- <Input
- ref={inputRef}
- value={value}
- onChange={onChange}
- placeholder={t("common.search")}
- className={className}
- {...props}
- />
+ <div className={cn("relative flex-1", className)}>
+ <QueryExplainerTooltip
+ className="-translate-1/2 absolute right-1.5 top-2 p-0.5"
+ parsedSearchQuery={parsedSearchQuery}
+ />
+ <Input
+ ref={inputRef}
+ value={value}
+ onChange={onChange}
+ placeholder={t("common.search")}
+ {...props}
+ />
+ </div>
);
});
SearchInput.displayName = "SearchInput";
diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts
index 9890ac6f..4662ffb6 100644
--- a/apps/web/lib/hooks/bookmark-search.ts
+++ b/apps/web/lib/hooks/bookmark-search.ts
@@ -1,17 +1,20 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
+import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
+
function useSearchQuery() {
const searchParams = useSearchParams();
- const searchQuery = searchParams.get("q") ?? "";
- return { searchQuery };
+ const searchQuery = decodeURIComponent(searchParams.get("q") ?? "");
+ const parsed = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]);
+ return { searchQuery, parsedSearchQuery: parsed };
}
export function useDoBookmarkSearch() {
const router = useRouter();
- const { searchQuery } = useSearchQuery();
+ const { searchQuery, parsedSearchQuery } = useSearchQuery();
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const pathname = usePathname();
@@ -26,7 +29,7 @@ export function useDoBookmarkSearch() {
const doSearch = (val: string) => {
setTimeoutId(undefined);
- router.replace(`/dashboard/search?q=${val}`);
+ router.replace(`/dashboard/search?q=${encodeURIComponent(val)}`);
};
const debounceSearch = (val: string) => {
@@ -43,12 +46,13 @@ export function useDoBookmarkSearch() {
doSearch,
debounceSearch,
searchQuery,
+ parsedSearchQuery,
isInSearchPage: pathname.startsWith("/dashboard/search"),
};
}
export function useBookmarkSearch() {
- const { searchQuery } = useSearchQuery();
+ const { parsedSearchQuery } = useSearchQuery();
const {
data,
@@ -60,7 +64,8 @@ export function useBookmarkSearch() {
isFetchingNextPage,
} = api.bookmarks.searchBookmarks.useInfiniteQuery(
{
- text: searchQuery,
+ text: parsedSearchQuery.text,
+ matcher: parsedSearchQuery.matcher,
},
{
placeholderData: keepPreviousData,
@@ -75,7 +80,6 @@ export function useBookmarkSearch() {
}
return {
- searchQuery,
error,
data,
isPending,