diff options
| -rw-r--r-- | apps/mobile/app/dashboard/search.tsx | 129 | ||||
| -rw-r--r-- | apps/mobile/package.json | 1 | ||||
| -rw-r--r-- | apps/web/components/dashboard/header/Header.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/search/SearchInput.tsx | 197 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 3 | ||||
| -rw-r--r-- | packages/shared-react/hooks/search-history.ts | 102 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 29 |
7 files changed, 416 insertions, 47 deletions
diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx index de3d0f46..5cc97575 100644 --- a/apps/mobile/app/dashboard/search.tsx +++ b/apps/mobile/app/dashboard/search.tsx @@ -1,5 +1,12 @@ -import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { useMemo, useRef, useState } from "react"; +import { + FlatList, + Keyboard, + Pressable, + Text, + TextInput, + View, +} from "react-native"; import { router } from "expo-router"; import BookmarkList from "@/components/bookmarks/BookmarkList"; import FullPageError from "@/components/FullPageError"; @@ -7,39 +14,105 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import { Input } from "@/components/ui/Input"; import { api } from "@/lib/trpc"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { keepPreviousData } from "@tanstack/react-query"; import { useDebounce } from "use-debounce"; +import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history"; + +const MAX_DISPLAY_SUGGESTIONS = 5; + export default function Search() { const [search, setSearch] = useState(""); const [query] = useDebounce(search, 10); + const inputRef = useRef<TextInput>(null); + + const [isInputFocused, setIsInputFocused] = useState(true); + const { history, addTerm, clearHistory } = useSearchHistory({ + getItem: (k: string) => AsyncStorage.getItem(k), + setItem: (k: string, v: string) => AsyncStorage.setItem(k, v), + removeItem: (k: string) => AsyncStorage.removeItem(k), + }); const onRefresh = api.useUtils().bookmarks.searchBookmarks.invalidate; - const { data, error, refetch, isPending, fetchNextPage, isFetchingNextPage } = - api.bookmarks.searchBookmarks.useInfiniteQuery( - { text: query }, - { - placeholderData: keepPreviousData, - gcTime: 0, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); + const { + data, + error, + refetch, + isPending, + isFetching, + fetchNextPage, + isFetchingNextPage, + } = api.bookmarks.searchBookmarks.useInfiniteQuery( + { text: query }, + { + placeholderData: keepPreviousData, + gcTime: 0, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const filteredHistory = useMemo(() => { + if (search.trim().length === 0) { + // Show recent items when not typing + return history.slice(0, MAX_DISPLAY_SUGGESTIONS); + } + // Show filtered items when typing + return history + .filter((item) => item.toLowerCase().includes(search.toLowerCase())) + .slice(0, MAX_DISPLAY_SUGGESTIONS); + }, [search, history]); if (error) { return <FullPageError error={error.message} onRetry={() => refetch()} />; } + const handleSearchSubmit = (searchTerm: string) => { + const term = searchTerm.trim(); + if (term.length > 0) { + addTerm(term); + setSearch(term); + } + inputRef.current?.blur(); + Keyboard.dismiss(); + }; + + const renderHistoryItem = ({ item }: { item: string }) => ( + <Pressable + onPress={() => handleSearchSubmit(item)} + className="border-b border-gray-200 p-3" + > + <Text className="text-foreground">{item}</Text> + </Pressable> + ); + + const handleOnFocus = () => { + setIsInputFocused(true); + }; + + const handleOnBlur = () => { + setIsInputFocused(false); + if (search.trim().length > 0) { + addTerm(search); + } + }; + return ( <CustomSafeAreaView> <View className="flex flex-row items-center gap-3 p-3"> <Input + ref={inputRef} placeholder="Search" className="flex-1" value={search} onChangeText={setSearch} + onFocus={handleOnFocus} + onBlur={handleOnBlur} + onSubmitEditing={() => handleSearchSubmit(search)} + returnKeyType="search" autoFocus autoCapitalize="none" /> @@ -47,8 +120,34 @@ export default function Search() { <Text className="text-foreground">Cancel</Text> </Pressable> </View> - {!data && <FullPageSpinner />} - {data && ( + + {isInputFocused ? ( + <FlatList + data={filteredHistory} + renderItem={renderHistoryItem} + keyExtractor={(item, index) => `${item}-${index}`} + ListHeaderComponent={ + <View className="flex-row items-center justify-between p-3"> + <Text className="text-sm font-bold text-gray-500"> + Recent Searches + </Text> + {history.length > 0 && ( + <Pressable onPress={clearHistory}> + <Text className="text-sm text-blue-500">Clear</Text> + </Pressable> + )} + </View> + } + ListEmptyComponent={ + <Text className="p-3 text-center text-gray-500"> + No matching searches. + </Text> + } + keyboardShouldPersistTaps="handled" + /> + ) : isFetching && query.length > 0 ? ( + <FullPageSpinner /> + ) : data && query.length > 0 ? ( <BookmarkList bookmarks={data.pages.flatMap((p) => p.bookmarks)} fetchNextPage={fetchNextPage} @@ -56,6 +155,8 @@ export default function Search() { onRefresh={onRefresh} isRefreshing={isPending} /> + ) : ( + <View /> )} </CustomSafeAreaView> ); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2c68810e..d5c2262f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -18,6 +18,7 @@ "@karakeep/shared": "workspace:^0.1.0", "@karakeep/shared-react": "workspace:^0.1.0", "@karakeep/trpc": "workspace:^0.1.0", + "@react-native-async-storage/async-storage": "1.23.1", "@react-native-menu/menu": "^1.1.6", "@tanstack/react-query": "^5.69.0", "class-variance-authority": "^0.7.0", diff --git a/apps/web/components/dashboard/header/Header.tsx b/apps/web/components/dashboard/header/Header.tsx index e882ebfc..f830beb6 100644 --- a/apps/web/components/dashboard/header/Header.tsx +++ b/apps/web/components/dashboard/header/Header.tsx @@ -20,7 +20,7 @@ export default async function Header() { </Link> </div> <div className="flex flex-1 gap-2"> - <SearchInput className="min-w-40 bg-muted" /> + <SearchInput className="min-w-40 rounded-md bg-muted" /> <GlobalActions /> </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index fad45672..0de7694a 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -1,20 +1,44 @@ "use client"; -import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { SearchIcon } from "lucide-react"; +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; + function useFocusSearchOnKeyPress( inputRef: React.RefObject<HTMLInputElement>, - onChange: (e: React.ChangeEvent<HTMLInputElement>) => void, + value: string, + setValue: (value: string) => void, + setPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>, ) { useEffect(() => { function handleKeyPress(e: KeyboardEvent) { @@ -27,18 +51,12 @@ function useFocusSearchOnKeyPress( // Move the cursor to the end of the input field, so you can continue typing const length = inputRef.current.value.length; inputRef.current.setSelectionRange(length, length); + setPopoverOpen(true); } - if ( - e.code === "Escape" && - e.target == inputRef.current && - inputRef.current.value !== "" - ) { + if (e.code === "Escape" && e.target == inputRef.current && value !== "") { e.preventDefault(); inputRef.current.blur(); - inputRef.current.value = ""; - onChange({ - target: inputRef.current, - } as React.ChangeEvent<HTMLInputElement>); + setValue(""); } } @@ -46,7 +64,7 @@ function useFocusSearchOnKeyPress( return () => { document.removeEventListener("keydown", handleKeyPress); }; - }, [inputRef, onChange]); + }, [inputRef, value, setValue, setPopoverOpen]); } const SearchInput = React.forwardRef< @@ -54,20 +72,81 @@ const SearchInput = React.forwardRef< React.HTMLAttributes<HTMLInputElement> & { loading?: boolean } >(({ className, ...props }, ref) => { const { t } = useTranslation(); - const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } = - useDoBookmarkSearch(); + const { + debounceSearch, + searchQuery, + doSearch, + parsedSearchQuery, + isInSearchPage, + } = useDoBookmarkSearch(); + const { addTerm, history } = useSearchHistory({ + getItem: (k: string) => localStorage.getItem(k), + setItem: (k: string, v: string) => localStorage.setItem(k, v), + removeItem: (k: string) => localStorage.removeItem(k), + }); const [value, setValue] = React.useState(searchQuery); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); const inputRef = useRef<HTMLInputElement>(null); - const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setValue(e.target.value); - debounceSearch(e.target.value); - }; + const isHistorySelected = useRef(false); - useFocusSearchOnKeyPress(inputRef, onChange); + const handleValueChange = useCallback( + (newValue: string) => { + setValue(newValue); + debounceSearch(newValue); + isHistorySelected.current = false; // Reset flag when user types + }, + [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 isPopoverVisible = isPopoverOpen && suggestions.length > 0; + const handleHistorySelect = useCallback( + (term: string) => { + isHistorySelected.current = true; + setValue(term); + doSearch(term); + addTerm(term); + setIsPopoverOpen(false); + inputRef.current?.blur(); + }, + [doSearch], + ); + + 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!); - const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); useEffect(() => { if (!isInSearchPage) { @@ -75,6 +154,21 @@ const SearchInput = React.forwardRef< } }, [isInSearchPage]); + const handleFocus = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const handleBlur = useCallback(() => { + // Only add to history if it wasn't a history selection + if (value && !isHistorySelected.current) { + addTerm(value); + } + + // Reset the flag + isHistorySelected.current = false; + setIsPopoverOpen(false); + }, [value, addTerm]); + return ( <div className={cn("relative flex-1", className)}> <EditListModal @@ -103,14 +197,57 @@ const SearchInput = React.forwardRef< {t("actions.save")} </Button> )} - <Input - startIcon={<SearchIcon size={18} className="text-muted-foreground" />} - ref={inputRef} - value={value} - onChange={onChange} - placeholder={t("common.search")} - {...props} - /> + <Command + shouldFilter={false} + className="relative rounded-md bg-transparent" + onKeyDown={handleCommandKeyDown} + > + <Popover open={isPopoverVisible}> + <PopoverTrigger asChild> + <div className="relative"> + <CommandInput + ref={inputRef} + placeholder={t("common.search")} + value={value} + onValueChange={handleValueChange} + onFocus={handleFocus} + onBlur={handleBlur} + className={cn("h-10", className)} + {...props} + /> + </div> + </PopoverTrigger> + <PopoverContent + className="w-[--radix-popover-trigger-width] p-0" + 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> + </PopoverContent> + </Popover> + </Command> </div> ); }); diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index f9e1d493..10b2f390 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -439,7 +439,8 @@ "is_from_feed": "Is from RSS Feed", "is_not_from_feed": "Is not from RSS Feed", "and": "And", - "or": "Or" + "or": "Or", + "history": "Recent Searches" }, "preview": { "view_original": "View Original", diff --git a/packages/shared-react/hooks/search-history.ts b/packages/shared-react/hooks/search-history.ts new file mode 100644 index 00000000..bc3c4e3d --- /dev/null +++ b/packages/shared-react/hooks/search-history.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { z } from "zod"; + +const searchHistorySchema = z.array(z.string()); + +const BOOKMARK_SEARCH_HISTORY_KEY = "karakeep_search_history"; +const MAX_STORED_ITEMS = 50; + +class SearchHistoryUtil { + constructor( + private storage: { + getItem(key: string): Promise<string | null> | string | null; + setItem(key: string, value: string): Promise<void> | void; + removeItem(key: string): Promise<void> | void; + }, + ) {} + + async getSearchHistory(): Promise<string[]> { + try { + const rawHistory = await this.storage.getItem( + BOOKMARK_SEARCH_HISTORY_KEY, + ); + if (rawHistory) { + const parsed = JSON.parse(rawHistory) as unknown; + const result = searchHistorySchema.safeParse(parsed); + if (result.success) { + return result.data; + } + } + return []; + } catch (error) { + console.error("Failed to parse search history:", error); + return []; + } + } + + async addSearchTermToHistory(term: string): Promise<void> { + if (!term || term.trim().length === 0) { + return; + } + try { + const currentHistory = await this.getSearchHistory(); + const filteredHistory = currentHistory.filter( + (item) => item.toLowerCase() !== term.toLowerCase(), + ); + const newHistory = [term, ...filteredHistory]; + const finalHistory = newHistory.slice(0, MAX_STORED_ITEMS); + await this.storage.setItem( + BOOKMARK_SEARCH_HISTORY_KEY, + JSON.stringify(finalHistory), + ); + } catch (error) { + console.error("Failed to save search history:", error); + } + } + + async clearSearchHistory(): Promise<void> { + try { + await this.storage.removeItem(BOOKMARK_SEARCH_HISTORY_KEY); + } catch (error) { + console.error("Failed to clear search history:", error); + } + } +} + +export function useSearchHistory(adapter: { + getItem(key: string): Promise<string | null> | string | null; + setItem(key: string, value: string): Promise<void> | void; + removeItem(key: string): Promise<void> | void; +}) { + const [history, setHistory] = useState<string[]>([]); + const searchHistoryUtil = useMemo(() => new SearchHistoryUtil(adapter), []); + + const loadHistory = useCallback(async () => { + const storedHistory = await searchHistoryUtil.getSearchHistory(); + setHistory(storedHistory); + }, [searchHistoryUtil]); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + const addTerm = useCallback( + async (term: string) => { + await searchHistoryUtil.addSearchTermToHistory(term); + await loadHistory(); + }, + [searchHistoryUtil, loadHistory], + ); + + const clearHistory = useCallback(async () => { + await searchHistoryUtil.clearSearchHistory(); + setHistory([]); + }, [searchHistoryUtil]); + + return { + history, + addTerm, + clearHistory, + refreshHistory: loadHistory, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8500335..e960968d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: '@karakeep/trpc': specifier: workspace:^0.1.0 version: link:../../packages/trpc + '@react-native-async-storage/async-storage': + specifier: 1.23.1 + version: 1.23.1(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1)) '@react-native-menu/menu': specifier: ^1.1.6 version: 1.1.6(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -1181,7 +1184,7 @@ importers: version: 18.3.1 react-native: specifier: ^0.76.3 - version: 0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1) + version: 0.76.9(@babel/core@7.27.4)(@babel/preset-env@7.27.2(@babel/core@7.27.4))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1) superjson: specifier: ^2.2.1 version: 2.2.1 @@ -4656,6 +4659,11 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-native-async-storage/async-storage@1.23.1': + resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.60 <1.0 + '@react-native-menu/menu@1.1.6': resolution: {integrity: sha512-KRPBqa9jmYDFoacUxw8z1ucpbvmdlPuRO8tsFt2jM8JMC2s+YQwTtISG73PeqH9KD7BV+8igD/nizPfcipOmhQ==} peerDependencies: @@ -9251,6 +9259,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + is-plain-obj@3.0.0: resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} engines: {node: '>=10'} @@ -10129,6 +10141,10 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -20039,6 +20055,11 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-native-async-storage/async-storage@1.23.1(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))': + dependencies: + merge-options: 3.0.4 + react-native: 0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1) + '@react-native-menu/menu@1.1.6(react-native@0.76.9(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 @@ -25597,6 +25618,8 @@ snapshots: is-path-inside@3.0.3: {} + is-plain-obj@2.1.0: {} + is-plain-obj@3.0.0: {} is-plain-obj@4.1.0: {} @@ -26742,6 +26765,10 @@ snapshots: merge-descriptors@2.0.0: {} + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + merge-stream@2.0.0: {} merge2@1.4.1: {} |
