aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx122
-rw-r--r--apps/web/components/dashboard/search/useSearchAutocomplete.ts555
2 files changed, 618 insertions, 59 deletions
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 3f49a53c..644468e8 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -4,7 +4,6 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
- useMemo,
useRef,
useState,
} from "react";
@@ -12,6 +11,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Command,
+ CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
@@ -25,14 +25,12 @@ import {
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";
-import { History } from "lucide-react";
import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
import { EditListModal } from "../lists/EditListModal";
import QueryExplainerTooltip from "./QueryExplainerTooltip";
-
-const MAX_DISPLAY_SUGGESTIONS = 5;
+import { useSearchAutocomplete } from "./useSearchAutocomplete";
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement | null>,
@@ -85,7 +83,7 @@ const SearchInput = React.forwardRef<
removeItem: (k: string) => localStorage.removeItem(k),
});
- const [value, setValue] = React.useState(searchQuery);
+ const [value, setValue] = useState(searchQuery);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
@@ -119,19 +117,22 @@ const SearchInput = React.forwardRef<
[debounceSearch],
);
- const suggestions = useMemo(() => {
- if (value.trim() === "") {
- // Show recent items when not typing
- return history.slice(0, MAX_DISPLAY_SUGGESTIONS);
- } else {
- // Show filtered items when typing
- return history
- .filter((item) => item.toLowerCase().includes(value.toLowerCase()))
- .slice(0, MAX_DISPLAY_SUGGESTIONS);
- }
- }, [history, value]);
+ const {
+ suggestionGroups,
+ hasSuggestions,
+ isPopoverVisible,
+ handleSuggestionSelect,
+ handleCommandKeyDown,
+ } = useSearchAutocomplete({
+ value,
+ onValueChange: handleValueChange,
+ inputRef,
+ isPopoverOpen,
+ setIsPopoverOpen,
+ t,
+ history,
+ });
- const isPopoverVisible = isPopoverOpen && suggestions.length > 0;
const handleHistorySelect = useCallback(
(term: string) => {
isHistorySelected.current = true;
@@ -141,28 +142,9 @@ const SearchInput = React.forwardRef<
setIsPopoverOpen(false);
inputRef.current?.blur();
},
- [doSearch],
+ [doSearch, addTerm],
);
- const handleCommandKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- const selectedItem = document.querySelector(
- '[cmdk-item][data-selected="true"]',
- );
- const isPlaceholderSelected =
- selectedItem?.getAttribute("data-value") === "-";
- if (!selectedItem || isPlaceholderSelected) {
- e.preventDefault();
- setIsPopoverOpen(false);
- inputRef.current?.blur();
- }
- } else if (e.key === "Escape") {
- e.preventDefault();
- setIsPopoverOpen(false);
- inputRef.current?.blur();
- }
- }, []);
-
useFocusSearchOnKeyPress(inputRef, value, setValue, setIsPopoverOpen);
useImperativeHandle(ref, () => inputRef.current!);
@@ -242,28 +224,50 @@ const SearchInput = React.forwardRef<
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
- <CommandList>
- <CommandGroup
- heading={t("search.history")}
- className="max-h-60 overflow-y-auto"
- >
- {/* prevent cmdk auto select the first suggestion -> https://github.com/pacocoursey/cmdk/issues/171*/}
- <CommandItem value="-" className="hidden" />
- {suggestions.map((term) => (
- <CommandItem
- key={term}
- value={term}
- onSelect={() => handleHistorySelect(term)}
- onMouseDown={() => {
- isHistorySelected.current = true;
- }}
- className="cursor-pointer"
- >
- <History className="mr-2 h-4 w-4" />
- <span>{term}</span>
- </CommandItem>
- ))}
- </CommandGroup>
+ <CommandList className="max-h-96 overflow-y-auto">
+ <CommandEmpty>{t("search.no_suggestions")}</CommandEmpty>
+ {hasSuggestions && <CommandItem value="-" className="hidden" />}
+ {suggestionGroups.map((group) => (
+ <CommandGroup key={group.id} heading={group.label}>
+ {group.items.map((item) => {
+ if (item.type === "history") {
+ return (
+ <CommandItem
+ key={item.id}
+ value={item.label}
+ onSelect={() => handleHistorySelect(item.term)}
+ onMouseDown={() => {
+ isHistorySelected.current = true;
+ }}
+ className="cursor-pointer"
+ >
+ <item.Icon className="mr-2 h-4 w-4" />
+ <span>{item.label}</span>
+ </CommandItem>
+ );
+ }
+
+ return (
+ <CommandItem
+ key={item.id}
+ value={item.label}
+ onSelect={() => handleSuggestionSelect(item)}
+ className="cursor-pointer"
+ >
+ <item.Icon className="mr-2 h-4 w-4" />
+ <div className="flex flex-col">
+ <span>{item.label}</span>
+ {item.description && (
+ <span className="text-xs text-muted-foreground">
+ {item.description}
+ </span>
+ )}
+ </div>
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ ))}
</CommandList>
</PopoverContent>
</Popover>
diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
new file mode 100644
index 00000000..cce5fc0b
--- /dev/null
+++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
@@ -0,0 +1,555 @@
+import type translation from "@/lib/i18n/locales/en/translation.json";
+import type { TFunction } from "i18next";
+import type { LucideIcon } from "lucide-react";
+import { useCallback, useMemo } from "react";
+import { api } from "@/lib/trpc";
+import {
+ History,
+ ListTree,
+ RssIcon,
+ Sparkles,
+ Tag as TagIcon,
+} from "lucide-react";
+
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
+import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+
+const MAX_DISPLAY_SUGGESTIONS = 5;
+
+type SearchTranslationKey = `search.${keyof typeof translation.search}`;
+
+interface QualifierDefinition {
+ value: string;
+ descriptionKey?: SearchTranslationKey;
+ negatedDescriptionKey?: SearchTranslationKey;
+ appendSpace?: boolean;
+}
+
+const QUALIFIER_DEFINITIONS = [
+ {
+ value: "is:fav",
+ descriptionKey: "search.is_favorited",
+ negatedDescriptionKey: "search.is_not_favorited",
+ appendSpace: true,
+ },
+ {
+ value: "is:archived",
+ descriptionKey: "search.is_archived",
+ negatedDescriptionKey: "search.is_not_archived",
+ appendSpace: true,
+ },
+ {
+ value: "is:tagged",
+ descriptionKey: "search.has_any_tag",
+ negatedDescriptionKey: "search.has_no_tags",
+ appendSpace: true,
+ },
+ {
+ value: "is:inlist",
+ descriptionKey: "search.is_in_any_list",
+ negatedDescriptionKey: "search.is_not_in_any_list",
+ appendSpace: true,
+ },
+ {
+ value: "is:link",
+ appendSpace: true,
+ },
+ {
+ value: "is:text",
+ appendSpace: true,
+ },
+ {
+ value: "is:media",
+ appendSpace: true,
+ },
+ {
+ value: "url:",
+ descriptionKey: "search.url_contains",
+ },
+ {
+ value: "title:",
+ descriptionKey: "search.title_contains",
+ },
+ {
+ value: "list:",
+ descriptionKey: "search.is_in_list",
+ },
+ {
+ value: "after:",
+ descriptionKey: "search.created_on_or_after",
+ },
+ {
+ value: "before:",
+ descriptionKey: "search.created_on_or_before",
+ },
+ {
+ value: "feed:",
+ descriptionKey: "search.is_from_feed",
+ },
+ {
+ value: "age:",
+ descriptionKey: "search.created_within",
+ },
+] satisfies ReadonlyArray<QualifierDefinition>;
+
+export interface AutocompleteSuggestionItem {
+ type: "token" | "tag" | "list" | "feed";
+ id: string;
+ label: string;
+ insertText: string;
+ appendSpace?: boolean;
+ description?: string;
+ Icon: LucideIcon;
+}
+
+export interface HistorySuggestionItem {
+ type: "history";
+ id: string;
+ term: string;
+ label: string;
+ Icon: LucideIcon;
+}
+
+export type SuggestionItem = AutocompleteSuggestionItem | HistorySuggestionItem;
+
+export interface SuggestionGroup {
+ id: string;
+ label: string;
+ items: SuggestionItem[];
+}
+
+const stripSurroundingQuotes = (value: string) => {
+ let nextValue = value;
+ if (nextValue.startsWith('"')) {
+ nextValue = nextValue.slice(1);
+ }
+ if (nextValue.endsWith('"')) {
+ nextValue = nextValue.slice(0, -1);
+ }
+ return nextValue;
+};
+
+const shouldQuoteValue = (value: string) => /[\s:]/.test(value);
+
+const formatSearchValue = (value: string) =>
+ shouldQuoteValue(value) ? `"${value}"` : value;
+
+interface ParsedSearchState {
+ activeToken: string;
+ isTokenNegative: boolean;
+ tokenWithoutMinus: string;
+ normalizedTokenWithoutMinus: string;
+ getActiveToken: (cursorPosition: number) => { token: string; start: number };
+}
+
+interface UseSearchAutocompleteParams {
+ value: string;
+ onValueChange: (value: string) => void;
+ inputRef: React.RefObject<HTMLInputElement | null>;
+ isPopoverOpen: boolean;
+ setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
+ t: TFunction;
+ history: string[];
+}
+
+const useParsedSearchState = (value: string): ParsedSearchState => {
+ const getActiveToken = useCallback(
+ (cursorPosition: number) => {
+ let start = 0;
+ let inQuotes = false;
+
+ for (let index = 0; index < cursorPosition; index += 1) {
+ const char = value[index];
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ continue;
+ }
+
+ if (!inQuotes) {
+ if (char === " " || char === "\t" || char === "\n") {
+ start = index + 1;
+ continue;
+ }
+
+ if (char === "(") {
+ start = index + 1;
+ }
+ }
+ }
+
+ return {
+ token: value.slice(start, cursorPosition),
+ start,
+ };
+ },
+ [value],
+ );
+
+ const activeTokenInfo = useMemo(
+ () => getActiveToken(value.length),
+ [getActiveToken, value],
+ );
+ const activeToken = activeTokenInfo.token;
+ const isTokenNegative = activeToken.startsWith("-");
+ const tokenWithoutMinus = isTokenNegative
+ ? activeToken.slice(1)
+ : activeToken;
+ const normalizedTokenWithoutMinus = tokenWithoutMinus.toLowerCase();
+
+ return {
+ activeToken,
+ isTokenNegative,
+ tokenWithoutMinus,
+ normalizedTokenWithoutMinus,
+ getActiveToken,
+ };
+};
+
+const useQualifierSuggestions = (
+ parsed: ParsedSearchState,
+ t: TFunction,
+): AutocompleteSuggestionItem[] => {
+ const qualifierSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+ // Don't suggest qualifiers if the user hasn't started typing
+ if (parsed.normalizedTokenWithoutMinus.length === 0) {
+ return [];
+ }
+
+ return QUALIFIER_DEFINITIONS.filter((definition) => {
+ return definition.value
+ .toLowerCase()
+ .startsWith(parsed.normalizedTokenWithoutMinus);
+ })
+ .slice(0, MAX_DISPLAY_SUGGESTIONS)
+ .map((definition) => {
+ const insertText = `${parsed.isTokenNegative ? "-" : ""}${definition.value}`;
+ const descriptionKey = parsed.isTokenNegative
+ ? (definition.negatedDescriptionKey ?? definition.descriptionKey)
+ : definition.descriptionKey;
+ const description = descriptionKey ? t(descriptionKey) : undefined;
+
+ return {
+ type: "token" as const,
+ id: `qualifier-${definition.value}`,
+ label: insertText,
+ insertText,
+ appendSpace: definition.appendSpace,
+ description,
+ Icon: Sparkles,
+ } satisfies AutocompleteSuggestionItem;
+ });
+ }, [parsed.normalizedTokenWithoutMinus, parsed.isTokenNegative, t]);
+
+ return qualifierSuggestions;
+};
+
+const useTagSuggestions = (
+ parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+ const shouldSuggestTags = parsed.tokenWithoutMinus.startsWith("#");
+ const tagSearchTermRaw = shouldSuggestTags
+ ? parsed.tokenWithoutMinus.slice(1)
+ : "";
+ const tagSearchTerm = stripSurroundingQuotes(tagSearchTermRaw);
+ const debouncedTagSearchTerm = useDebounce(tagSearchTerm, 200);
+
+ const { data: tagResults } = useTagAutocomplete({
+ nameContains: debouncedTagSearchTerm,
+ select: (data) => data.tags,
+ });
+
+ const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+ if (!shouldSuggestTags) {
+ return [];
+ }
+
+ return (tagResults ?? []).slice(0, MAX_DISPLAY_SUGGESTIONS).map((tag) => {
+ const formattedName = formatSearchValue(tag.name);
+ const insertText = `${parsed.isTokenNegative ? "-" : ""}#${formattedName}`;
+
+ return {
+ type: "tag" as const,
+ id: `tag-${tag.id}`,
+ label: insertText,
+ insertText,
+ appendSpace: true,
+ description: undefined,
+ Icon: TagIcon,
+ } satisfies AutocompleteSuggestionItem;
+ });
+ }, [shouldSuggestTags, tagResults, parsed.isTokenNegative]);
+
+ return tagSuggestions;
+};
+
+const useFeedSuggestions = (
+ parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+ const shouldSuggestFeeds =
+ parsed.normalizedTokenWithoutMinus.startsWith("feed:");
+ const feedSearchTermRaw = shouldSuggestFeeds
+ ? parsed.tokenWithoutMinus.slice("feed:".length)
+ : "";
+ const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw);
+ const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase();
+ const { data: feedResults } = api.feeds.list.useQuery();
+
+ const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+ if (!shouldSuggestFeeds) {
+ return [];
+ }
+
+ const feeds = feedResults?.feeds ?? [];
+
+ return feeds
+ .filter((feed) => {
+ if (normalizedFeedSearchTerm.length === 0) {
+ return true;
+ }
+ return feed.name.toLowerCase().includes(normalizedFeedSearchTerm);
+ })
+ .slice(0, MAX_DISPLAY_SUGGESTIONS)
+ .map((feed) => {
+ const formattedName = formatSearchValue(feed.name);
+ const insertText = `${parsed.isTokenNegative ? "-" : ""}feed:${formattedName}`;
+ return {
+ type: "feed" as const,
+ id: `feed-${feed.id}`,
+ label: insertText,
+ insertText,
+ appendSpace: true,
+ description: undefined,
+ Icon: RssIcon,
+ } satisfies AutocompleteSuggestionItem;
+ });
+ }, [
+ shouldSuggestFeeds,
+ feedResults,
+ normalizedFeedSearchTerm,
+ parsed.isTokenNegative,
+ ]);
+
+ return feedSuggestions;
+};
+
+const useListSuggestions = (
+ parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+ const shouldSuggestLists =
+ parsed.normalizedTokenWithoutMinus.startsWith("list:");
+ const listSearchTermRaw = shouldSuggestLists
+ ? parsed.tokenWithoutMinus.slice("list:".length)
+ : "";
+ const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw);
+ const normalizedListSearchTerm = listSearchTerm.toLowerCase();
+ const { data: listResults } = useBookmarkLists();
+
+ const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+ if (!shouldSuggestLists) {
+ return [];
+ }
+
+ const lists = listResults?.data ?? [];
+
+ return lists
+ .filter((list) => {
+ if (normalizedListSearchTerm.length === 0) {
+ return true;
+ }
+ return list.name.toLowerCase().includes(normalizedListSearchTerm);
+ })
+ .slice(0, MAX_DISPLAY_SUGGESTIONS)
+ .map((list) => {
+ const formattedName = formatSearchValue(list.name);
+ const insertText = `${parsed.isTokenNegative ? "-" : ""}list:${formattedName}`;
+ return {
+ type: "list" as const,
+ id: `list-${list.id}`,
+ label: insertText,
+ insertText,
+ appendSpace: true,
+ description: undefined,
+ Icon: ListTree,
+ } satisfies AutocompleteSuggestionItem;
+ });
+ }, [
+ shouldSuggestLists,
+ listResults,
+ normalizedListSearchTerm,
+ parsed.isTokenNegative,
+ ]);
+
+ return listSuggestions;
+};
+
+const useHistorySuggestions = (
+ value: string,
+ history: string[],
+): HistorySuggestionItem[] => {
+ const historyItems = useMemo<HistorySuggestionItem[]>(() => {
+ const trimmedValue = value.trim();
+ const results =
+ trimmedValue.length === 0
+ ? history
+ : history.filter((item) =>
+ item.toLowerCase().includes(trimmedValue.toLowerCase()),
+ );
+
+ return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map(
+ (term) =>
+ ({
+ type: "history" as const,
+ id: `history-${term}`,
+ term,
+ label: term,
+ Icon: History,
+ }) satisfies HistorySuggestionItem,
+ );
+ }, [history, value]);
+
+ return historyItems;
+};
+
+export const useSearchAutocomplete = ({
+ value,
+ onValueChange,
+ inputRef,
+ isPopoverOpen,
+ setIsPopoverOpen,
+ t,
+ history,
+}: UseSearchAutocompleteParams) => {
+ const parsedState = useParsedSearchState(value);
+ const qualifierSuggestions = useQualifierSuggestions(parsedState, t);
+ const tagSuggestions = useTagSuggestions(parsedState);
+ const listSuggestions = useListSuggestions(parsedState);
+ const feedSuggestions = useFeedSuggestions(parsedState);
+ const historyItems = useHistorySuggestions(value, history);
+ const { activeToken, getActiveToken } = parsedState;
+
+ const suggestionGroups = useMemo<SuggestionGroup[]>(() => {
+ const groups: SuggestionGroup[] = [];
+
+ if (tagSuggestions.length > 0) {
+ groups.push({
+ id: "tags",
+ label: t("search.tags"),
+ items: tagSuggestions,
+ });
+ }
+
+ if (listSuggestions.length > 0) {
+ groups.push({
+ id: "lists",
+ label: t("search.lists"),
+ items: listSuggestions,
+ });
+ }
+
+ if (feedSuggestions.length > 0) {
+ groups.push({
+ id: "feeds",
+ label: t("search.feeds"),
+ items: feedSuggestions,
+ });
+ }
+
+ // Only suggest qualifiers if no other suggestions are available
+ if (groups.length === 0 && qualifierSuggestions.length > 0) {
+ groups.push({
+ id: "qualifiers",
+ label: t("search.filters"),
+ items: qualifierSuggestions,
+ });
+ }
+
+ if (historyItems.length > 0) {
+ groups.push({
+ id: "history",
+ label: t("search.history"),
+ items: historyItems,
+ });
+ }
+
+ return groups;
+ }, [
+ qualifierSuggestions,
+ tagSuggestions,
+ listSuggestions,
+ feedSuggestions,
+ historyItems,
+ t,
+ ]);
+
+ const hasSuggestions = suggestionGroups.length > 0;
+ const showEmptyState =
+ isPopoverOpen && !hasSuggestions && activeToken.length > 0;
+ const isPopoverVisible = isPopoverOpen && (hasSuggestions || showEmptyState);
+
+ const handleSuggestionSelect = useCallback(
+ (item: AutocompleteSuggestionItem) => {
+ const input = inputRef.current;
+ const selectionStart = input?.selectionStart ?? value.length;
+ const selectionEnd = input?.selectionEnd ?? selectionStart;
+ const { start } = getActiveToken(selectionStart);
+ const beforeToken = value.slice(0, start);
+ const afterToken = value.slice(selectionEnd);
+
+ const needsSpace =
+ item.appendSpace &&
+ (afterToken.length === 0 || !/^\s/.test(afterToken));
+ const baseValue = `${beforeToken}${item.insertText}${afterToken}`;
+ const finalValue = needsSpace
+ ? `${beforeToken}${item.insertText} ${afterToken}`
+ : baseValue;
+
+ onValueChange(finalValue);
+
+ requestAnimationFrame(() => {
+ const target = inputRef.current;
+ if (!target) {
+ return;
+ }
+ const cursorPosition =
+ beforeToken.length + item.insertText.length + (needsSpace ? 1 : 0);
+ target.focus();
+ target.setSelectionRange(cursorPosition, cursorPosition);
+ });
+
+ setIsPopoverOpen(true);
+ },
+ [getActiveToken, onValueChange, value, inputRef, setIsPopoverOpen],
+ );
+
+ const handleCommandKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ const selectedItem = document.querySelector(
+ '[cmdk-item][data-selected="true"]',
+ );
+ const isPlaceholderSelected =
+ selectedItem?.getAttribute("data-value") === "-";
+ if (!selectedItem || isPlaceholderSelected) {
+ e.preventDefault();
+ setIsPopoverOpen(false);
+ inputRef.current?.blur();
+ }
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setIsPopoverOpen(false);
+ inputRef.current?.blur();
+ }
+ },
+ [setIsPopoverOpen, inputRef],
+ );
+
+ return {
+ suggestionGroups,
+ hasSuggestions,
+ showEmptyState,
+ isPopoverVisible,
+ handleSuggestionSelect,
+ handleCommandKeyDown,
+ };
+};