diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be0d95d..14dce47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added search history to the search bar. ([#99](https://github.com/sourcebot-dev/sourcebot/pull/99)) + ## [2.5.3] - 2024-11-28 ### Added diff --git a/packages/web/package.json b/packages/web/package.json index 6f468e24..4d1fdef8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,6 +35,8 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", "@replit/codemirror-lang-csharp": "^6.2.0", "@replit/codemirror-vim": "^6.2.1", "@tanstack/react-query": "^5.53.3", diff --git a/packages/web/src/app/components/searchBar/searchBar.tsx b/packages/web/src/app/components/searchBar/searchBar.tsx index cd8c859a..60971a40 100644 --- a/packages/web/src/app/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/components/searchBar/searchBar.tsx @@ -32,11 +32,16 @@ import { createTheme } from '@uiw/codemirror-themes'; import CodeMirror, { Annotation, EditorView, KeyBinding, keymap, ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { cva } from "class-variance-authority"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from 'react-hotkeys-hook'; -import { SearchSuggestionsBox, SuggestionMode } from "./searchSuggestionsBox"; +import { SearchSuggestionsBox } from "./searchSuggestionsBox"; import { useSuggestionsData } from "./useSuggestionsData"; import { zoekt } from "./zoektLanguageExtension"; +import { CounterClockwiseClockIcon } from "@radix-ui/react-icons"; +import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Toggle } from "@/components/ui/toggle"; interface SearchBarProps { className?: string; @@ -66,7 +71,7 @@ const searchBarKeymap: readonly KeyBinding[] = ([ ] as KeyBinding[]).concat(historyKeymap); const searchBarContainerVariants = cva( - "search-bar-container flex items-center p-0.5 border rounded-md relative", + "search-bar-container flex items-center py-0.5 px-1 border rounded-md relative", { variants: { size: { @@ -91,13 +96,12 @@ export const SearchBar = ({ const suggestionBoxRef = useRef(null); const editorRef = useRef(null); const [cursorPosition, setCursorPosition] = useState(0); - const [isSuggestionsBoxEnabled, setIsSuggestionsBoxEnabled ] = useState(false); + const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false); const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false); - + const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false); + const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []); const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []); - const [suggestionMode, setSuggestionMode] = useState("refine"); - const [suggestionQuery, setSuggestionQuery] = useState(""); const [_query, setQuery] = useState(defaultQuery ?? ""); const query = useMemo(() => { @@ -106,6 +110,22 @@ export const SearchBar = ({ return _query.replaceAll(/\n/g, " "); }, [_query]); + // When the user navigates backwards/forwards while on the + // search page (causing the `query` search param to change), + // we want to update what query is displayed in the search bar. + useEffect(() => { + if (defaultQuery) { + setQuery(defaultQuery); + } + }, [defaultQuery]) + + const { suggestionMode, suggestionQuery } = useSuggestionModeAndQuery({ + isSuggestionsEnabled, + isHistorySearchEnabled, + cursorPosition, + query, + }); + const suggestionData = useSuggestionsData({ suggestionMode, suggestionQuery, @@ -152,7 +172,7 @@ export const SearchBar = ({ useHotkeys('/', (event) => { event.preventDefault(); focusEditor(); - setIsSuggestionsBoxEnabled(true); + setIsSuggestionsEnabled(true); if (editorRef.current?.view) { cursorDocEnd({ state: editorRef.current.view.state, @@ -164,18 +184,21 @@ export const SearchBar = ({ // Collapse the suggestions box if the user clicks outside of the search bar container. useClickListener('.search-bar-container', (isElementClicked) => { if (!isElementClicked) { - setIsSuggestionsBoxEnabled(false); + setIsSuggestionsEnabled(false); } else { - setIsSuggestionsBoxEnabled(true); + setIsSuggestionsEnabled(true); } }); - const onSubmit = () => { + const onSubmit = useCallback((query: string) => { + setIsSuggestionsEnabled(false); + setIsHistorySearchEnabled(false); + const url = createPathWithQueryParams('/search', [SearchQueryParams.query, query], - ) + ); router.push(url); - } + }, [router]); return (
{ if (e.key === 'Enter') { e.preventDefault(); - setIsSuggestionsBoxEnabled(false); - onSubmit(); + setIsSuggestionsEnabled(false); + onSubmit(query); } if (e.key === 'Escape') { e.preventDefault(); - setIsSuggestionsBoxEnabled(false); + setIsSuggestionsEnabled(false); } if (e.key === 'ArrowDown') { e.preventDefault(); - setIsSuggestionsBoxEnabled(true); + setIsSuggestionsEnabled(true); focusSuggestionsBox(); } @@ -203,16 +226,29 @@ export const SearchBar = ({ } }} > + { + setQuery(""); + setIsHistorySearchEnabled(!isHistorySearchEnabled); + setIsSuggestionsEnabled(true); + focusEditor(); + }} + /> + { setQuery(value); // Whenever the user types, we want to re-enable // the suggestions box. - setIsSuggestionsBoxEnabled(true); + setIsSuggestionsEnabled(true); }} theme={theme} basicSetup={false} @@ -223,7 +259,9 @@ export const SearchBar = ({ { + suggestionQuery={suggestionQuery} + suggestionMode={suggestionMode} + onCompletion={(newQuery: string, newCursorPosition: number, autoSubmit = false) => { setQuery(newQuery); // Move the cursor to it's new position. @@ -242,8 +280,12 @@ export const SearchBar = ({ // Re-focus the editor since suggestions cause focus to be lost (both click & keyboard) editorRef.current?.view?.focus(); + + if (autoSubmit) { + onSubmit(newQuery); + } }} - isEnabled={isSuggestionsBoxEnabled} + isEnabled={isSuggestionsEnabled} onReturnFocus={() => { focusEditor(); }} @@ -255,17 +297,40 @@ export const SearchBar = ({ setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current); }} cursorPosition={cursorPosition} - onSuggestionModeChanged={(newSuggestionMode) => { - if (suggestionMode !== newSuggestionMode) { - console.debug(`Suggestion mode changed: ${suggestionMode} -> ${newSuggestionMode}`); - } - setSuggestionMode(newSuggestionMode); - }} - onSuggestionQueryChanged={(suggestionQuery) => { - setSuggestionQuery(suggestionQuery); - }} {...suggestionData} />
) +} + +const SearchHistoryButton = ({ + isToggled, + onClick, +}: { + isToggled: boolean, + onClick: () => void +}) => { + return ( + + + {/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */} +
+ + + +
+
+ + Search history + +
+ ) } \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx index 8c54bdf0..b3788565 100644 --- a/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx @@ -1,6 +1,5 @@ 'use client'; -import { isDefined } from "@/lib/utils"; import assert from "assert"; import clsx from "clsx"; import escapeStringRegexp from "escape-string-regexp"; @@ -12,10 +11,11 @@ import { forkModeSuggestions, publicModeSuggestions, refineModeSuggestions, - suggestionModeMappings } from "./constants"; import { IconType } from "react-icons/lib"; import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; export type Suggestion = { value: string; @@ -25,6 +25,7 @@ export type Suggestion = { } export type SuggestionMode = + "none" | "refine" | "archived" | "file" | @@ -35,29 +36,33 @@ export type SuggestionMode = "revision" | "symbol" | "content" | - "repo"; + "repo" | + "searchHistory"; interface SearchSuggestionsBoxProps { query: string; - onCompletion: (newQuery: string, newCursorPosition: number) => void, + suggestionQuery: string; + suggestionMode: SuggestionMode; + onCompletion: (newQuery: string, newCursorPosition: number, autoSubmit?: boolean) => void, isEnabled: boolean; cursorPosition: number; isFocused: boolean; onFocus: () => void; onBlur: () => void; onReturnFocus: () => void; - onSuggestionModeChanged: (suggestionMode: SuggestionMode) => void; - onSuggestionQueryChanged: (suggestionQuery: string) => void; isLoadingSuggestions: boolean; repoSuggestions: Suggestion[]; fileSuggestions: Suggestion[]; symbolSuggestions: Suggestion[]; languageSuggestions: Suggestion[]; + searchHistorySuggestions: Suggestion[]; } const SearchSuggestionsBox = forwardRef(({ query, + suggestionQuery, + suggestionMode, onCompletion, isEnabled, cursorPosition, @@ -65,66 +70,20 @@ const SearchSuggestionsBox = forwardRef(({ onFocus, onBlur, onReturnFocus, - onSuggestionModeChanged, - onSuggestionQueryChanged, isLoadingSuggestions, repoSuggestions, fileSuggestions, symbolSuggestions, languageSuggestions, + searchHistorySuggestions, }: SearchSuggestionsBoxProps, ref: Ref) => { - const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); - const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery?: string, suggestionMode?: SuggestionMode }>(() => { - // Only re-calculate the suggestion mode and query if the box is enabled. - // This is to avoid transitioning the suggestion mode and causing a fetch - // when it is not needed. - // @see: useSuggestionsData.ts + const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => { if (!isEnabled) { return {}; } - const { queryParts, cursorIndex } = splitQuery(query, cursorPosition); - if (queryParts.length === 0) { - return {}; - } - const part = queryParts[cursorIndex]; - - // Check if the query part starts with one of the - // prefixes. If it does, then we are in the corresponding - // suggestion mode for that prefix. - const suggestionMode = (() => { - for (const mapping of suggestionModeMappings) { - for (const prefix of mapping.prefixes) { - if (part.startsWith(prefix)) { - return mapping.suggestionMode; - } - } - } - })(); - - if (suggestionMode) { - const index = part.indexOf(":"); - return { - suggestionQuery: part.substring(index + 1), - suggestionMode, - } - } - - // Default to the refine suggestion mode - // if there was no match. - return { - suggestionQuery: part, - suggestionMode: "refine", - } - }, [cursorPosition, isEnabled, query]); - - const { suggestions, isHighlightEnabled, DefaultIcon, onSuggestionClicked } = useMemo(() => { - if (!isEnabled || !isDefined(suggestionQuery) || !isDefined(suggestionMode)) { - return {}; - } - const createOnSuggestionClickedHandler = (params: { regexEscaped?: boolean, trailingSpace?: boolean } = {}) => { const { regexEscaped = false, @@ -154,6 +113,7 @@ const SearchSuggestionsBox = forwardRef(({ isHighlightEnabled = false, isSpotlightEnabled = false, isClientSideSearchEnabled = true, + descriptionPlacement = "left", onSuggestionClicked, DefaultIcon, } = ((): { @@ -163,6 +123,7 @@ const SearchSuggestionsBox = forwardRef(({ isHighlightEnabled?: boolean, isSpotlightEnabled?: boolean, isClientSideSearchEnabled?: boolean, + descriptionPlacement?: "left" | "right", onSuggestionClicked: (value: string) => void, DefaultIcon?: IconType } => { @@ -223,6 +184,15 @@ const SearchSuggestionsBox = forwardRef(({ isClientSideSearchEnabled: false, DefaultIcon: VscSymbolMisc, } + case "searchHistory": + return { + list: searchHistorySuggestions, + onSuggestionClicked: (value: string) => { + onCompletion(value, value.length, /* autoSubmit = */ true); + }, + descriptionPlacement: "right", + } + case "none": case "revision": case "content": return { @@ -270,29 +240,30 @@ const SearchSuggestionsBox = forwardRef(({ return { suggestions, isHighlightEnabled, + descriptionPlacement, DefaultIcon, onSuggestionClicked, } - }, [isEnabled, suggestionQuery, suggestionMode, query, cursorPosition, onCompletion, repoSuggestions, fileSuggestions, symbolSuggestions, languageSuggestions]); + }, [ + isEnabled, + suggestionQuery, + suggestionMode, + query, + cursorPosition, + onCompletion, + repoSuggestions, + fileSuggestions, + symbolSuggestions, + searchHistorySuggestions, + languageSuggestions, + ]); // When the list of suggestions change, reset the highlight index useEffect(() => { setHighlightedSuggestionIndex(0); }, [suggestions]); - useEffect(() => { - if (isDefined(suggestionMode)) { - onSuggestionModeChanged(suggestionMode); - } - }, [onSuggestionModeChanged, suggestionMode]); - - useEffect(() => { - if (isDefined(suggestionQuery)) { - onSuggestionQueryChanged(suggestionQuery); - } - }, [onSuggestionQueryChanged, suggestionQuery]); - const suggestionModeText = useMemo(() => { if (!suggestionMode) { return ""; @@ -308,6 +279,8 @@ const SearchSuggestionsBox = forwardRef(({ return "Symbols"; case "language": return "Languages"; + case "searchHistory": + return "Search history" default: return ""; } @@ -369,7 +342,7 @@ const SearchSuggestionsBox = forwardRef(({
{ Array.from({ length: 10 }).map((_, index) => ( -
+ )) }
@@ -399,18 +372,28 @@ const SearchSuggestionsBox = forwardRef(({ {result.value} {result.description && ( - + {result.description} )} ))} {isFocused && ( -
- - Press Enter to select - -
+ <> + +
+ + Press Enter to select + +
+ )} ) diff --git a/packages/web/src/app/components/searchBar/useSuggestionModeAndQuery.ts b/packages/web/src/app/components/searchBar/useSuggestionModeAndQuery.ts new file mode 100644 index 00000000..555b4c22 --- /dev/null +++ b/packages/web/src/app/components/searchBar/useSuggestionModeAndQuery.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useMemo, useState } from "react"; +import { splitQuery, SuggestionMode } from "./searchSuggestionsBox"; +import { suggestionModeMappings } from "./constants"; + +interface Props { + isSuggestionsEnabled: boolean; + isHistorySearchEnabled: boolean; + cursorPosition: number; + query: string; +} + +export const useSuggestionModeAndQuery = ({ + isSuggestionsEnabled, + isHistorySearchEnabled, + cursorPosition, + query, +}: Props) => { + + const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => { + // When suggestions are not enabled, fallback to using a sentinal + // suggestion mode of "none". + if (!isSuggestionsEnabled) { + return { + suggestionQuery: "", + suggestionMode: "none", + }; + } + + if (isHistorySearchEnabled) { + return { + suggestionQuery: query, + suggestionMode: "searchHistory" + } + } + + // @note: bounds check is not required here since `splitQuery` + // guarantees that invariant as a assertion. + const { queryParts, cursorIndex } = splitQuery(query, cursorPosition); + const part = queryParts[cursorIndex]; + + // Check if the query part starts with one of the + // prefixes. If it does, then we are in the corresponding + // suggestion mode for that prefix. + const suggestionMode = (() => { + for (const mapping of suggestionModeMappings) { + for (const prefix of mapping.prefixes) { + if (part.startsWith(prefix)) { + return mapping.suggestionMode; + } + } + } + })(); + + if (suggestionMode) { + const index = part.indexOf(":"); + return { + suggestionQuery: part.substring(index + 1), + suggestionMode, + } + } + + // Default to the refine suggestion mode + // if there was no match. + return { + suggestionQuery: part, + suggestionMode: "refine", + } + }, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]); + + // Debug logging for tracking mode transitions. + const [prevSuggestionMode, setPrevSuggestionMode] = useState("none"); + useEffect(() => { + if (prevSuggestionMode !== suggestionMode) { + console.debug(`Suggestion mode changed: ${prevSuggestionMode} -> ${suggestionMode}`); + } + setPrevSuggestionMode(suggestionMode); + }, [prevSuggestionMode, suggestionMode]); + + + return { + suggestionMode, + suggestionQuery, + } +} \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/components/searchBar/useSuggestionsData.ts index 35d5b38e..0a92344f 100644 --- a/packages/web/src/app/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/components/searchBar/useSuggestionsData.ts @@ -17,6 +17,7 @@ import { VscSymbolStructure, VscSymbolVariable } from "react-icons/vsc"; +import { useSearchHistory } from "@/hooks/useSearchHistory"; interface Props { @@ -103,6 +104,14 @@ export const useSuggestionsData = ({ }); }, []); + const { searchHistory } = useSearchHistory(); + const searchHistorySuggestions = useMemo(() => { + return searchHistory.map(search => ({ + value: search.query, + description: getDisplayTime(new Date(search.date)), + } satisfies Suggestion)); + }, [searchHistory]); + const isLoadingSuggestions = useMemo(() => { return isLoadingSymbols || isLoadingFiles || isLoadingRepos; }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]); @@ -112,6 +121,7 @@ export const useSuggestionsData = ({ fileSuggestions: fileSuggestions ?? [], symbolSuggestions: symbolSuggestions ?? [], languageSuggestions, + searchHistorySuggestions, isLoadingSuggestions, } } @@ -144,4 +154,33 @@ const getSymbolIcon = (symbol: Symbol) => { case "enumerator": return VscSymbolEnum; } +} + +const getDisplayTime = (createdAt: Date) => { + const now = new Date(); + const minutes = (now.getTime() - createdAt.getTime()) / (1000 * 60); + const hours = minutes / 60; + const days = hours / 24; + const months = days / 30; + + const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => { + const roundedValue = Math.floor(value); + if (roundedValue < 2) { + return `${roundedValue} ${unit} ago`; + } else { + return `${roundedValue} ${unit}s ago`; + } + } + + if (minutes < 1) { + return 'just now'; + } else if (minutes < 60) { + return formatTime(minutes, 'minute'); + } else if (hours < 24) { + return formatTime(hours, 'hour'); + } else if (days < 30) { + return formatTime(days, 'day'); + } else { + return formatTime(months, 'month'); + } } \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 304605d9..fa2c243a 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Suspense } from "react"; import { QueryClientProvider } from "./queryClientProvider"; import { PHProvider } from "./posthogProvider"; import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; const inter = Inter({ subsets: ["latin"] }); @@ -35,13 +36,15 @@ export default function RootLayout({ disableTransitionOnChange > - {/* - @todo : ideally we don't wrap everything in a suspense boundary. - @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout - */} - - {children} - + + {/* + @todo : ideally we don't wrap everything in a suspense boundary. + @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout + */} + + {children} + + diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index 5eb8ad5a..3a2bb5f1 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -24,6 +24,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { ImperativePanelHandle } from "react-resizable-panels"; +import { useSearchHistory } from "@/hooks/useSearchHistory"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -32,7 +33,7 @@ export default function SearchPage() { const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`); const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount; - + const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const { data: searchResponse, isLoading } = useQuery({ @@ -45,6 +46,22 @@ export default function SearchPage() { refetchOnWindowFocus: false, }); + // Write the query to the search history + useEffect(() => { + if (searchQuery.length === 0) { + return; + } + + const now = new Date().toUTCString(); + setSearchHistory((searchHistory) => [ + { + query: searchQuery, + date: now, + }, + ...searchHistory.filter(search => search.query !== searchQuery), + ]) + }, [searchQuery, setSearchHistory]); + // Use the /api/repos endpoint to get a useful list of // repository metadata (like host type, repo name, etc.) // Convert this into a map of repo name to repo metadata diff --git a/packages/web/src/components/ui/skeleton.tsx b/packages/web/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..01b8b6d4 --- /dev/null +++ b/packages/web/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/packages/web/src/components/ui/toggle.tsx b/packages/web/src/components/ui/toggle.tsx new file mode 100644 index 00000000..c19bea37 --- /dev/null +++ b/packages/web/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3 min-w-10", + sm: "h-9 px-2.5 min-w-9", + lg: "h-11 px-5 min-w-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/packages/web/src/components/ui/tooltip.tsx b/packages/web/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..30fc44d9 --- /dev/null +++ b/packages/web/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/packages/web/src/hooks/useSearchHistory.ts b/packages/web/src/hooks/useSearchHistory.ts new file mode 100644 index 00000000..2deb6974 --- /dev/null +++ b/packages/web/src/hooks/useSearchHistory.ts @@ -0,0 +1,24 @@ +'use client'; + +import { useMemo } from "react"; +import { useLocalStorage } from "usehooks-ts"; + +type Search = { + query: string; + date: string; +} + +export const useSearchHistory = () => { + const [_searchHistory, setSearchHistory] = useLocalStorage("searchHistory", []); + + const searchHistory = useMemo(() => { + return _searchHistory.toSorted((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + }, [_searchHistory]); + + return { + searchHistory, + setSearchHistory, + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index f08ff914..2f8b140b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1313,6 +1313,33 @@ "@radix-ui/react-use-layout-effect" "1.1.0" "@radix-ui/react-visually-hidden" "1.1.0" +"@radix-ui/react-toggle@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz#1f7697b82917019330a16c6f96f649f46b4606cf" + integrity sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + +"@radix-ui/react-tooltip@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz#152d8485859b80d395d6b3229f676fef3cec56b3" + integrity sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.1" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.0" + "@radix-ui/react-portal" "1.1.2" + "@radix-ui/react-presence" "1.1.1" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.0" + "@radix-ui/react-use-callback-ref@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" @@ -5081,6 +5108,7 @@ string-argv@^0.3.1: integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==