Search history (#99)

This commit is contained in:
Brendan Kellam 2024-11-29 10:42:08 -08:00 committed by GitHub
parent 60dd3e935a
commit d18601c746
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 453 additions and 112 deletions

View file

@ -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

View file

@ -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",

View file

@ -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<HTMLDivElement>(null);
const editorRef = useRef<ReactCodeMirrorRef>(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<SuggestionMode>("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 (
<div
@ -183,18 +206,18 @@ export const SearchBar = ({
onKeyDown={(e) => {
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 = ({
}
}}
>
<SearchHistoryButton
isToggled={isHistorySearchEnabled}
onClick={() => {
setQuery("");
setIsHistorySearchEnabled(!isHistorySearchEnabled);
setIsSuggestionsEnabled(true);
focusEditor();
}}
/>
<Separator
className="mx-1 h-6"
orientation="vertical"
/>
<CodeMirror
ref={editorRef}
className="overflow-x-auto scrollbar-hide w-full"
placeholder={"Search..."}
placeholder={isHistorySearchEnabled ? "Filter history..." : "Search..."}
value={query}
onChange={(value) => {
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 = ({
<SearchSuggestionsBox
ref={suggestionBoxRef}
query={query}
onCompletion={(newQuery: string, newCursorPosition: number) => {
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}
/>
</div>
)
}
const SearchHistoryButton = ({
isToggled,
onClick,
}: {
isToggled: boolean,
onClick: () => void
}) => {
return (
<Tooltip>
<TooltipTrigger
asChild={true}
>
{/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */}
<div>
<Toggle
pressed={isToggled}
className="h-6 w-6 min-w-6 px-0 p-1 cursor-pointer"
onClick={onClick}
>
<CounterClockwiseClockIcon />
</Toggle>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
>
Search history
</TooltipContent>
</Tooltip>
)
}

View file

@ -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<HTMLDivElement>) => {
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(({
<div className="animate-pulse flex flex-col gap-2 px-1 py-0.5">
{
Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="h-4 bg-muted rounded-md w-full"></div>
<Skeleton key={index} className="h-4 w-full" />
))
}
</div>
@ -399,18 +372,28 @@ const SearchSuggestionsBox = forwardRef(({
{result.value}
</span>
{result.description && (
<span className="text-muted-foreground font-light">
<span
className={clsx("text-muted-foreground font-light", {
"ml-auto": descriptionPlacement === "right",
})}
>
{result.description}
</span>
)}
</div>
))}
{isFocused && (
<>
<Separator
orientation="horizontal"
className="my-2"
/>
<div className="flex flex-row items-center justify-end mt-1">
<span className="text-muted-foreground text-xs">
Press <kbd className="font-mono text-xs font-bold">Enter</kbd> to select
</span>
</div>
</>
)}
</div>
)

View file

@ -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<SuggestionMode>("none");
useEffect(() => {
if (prevSuggestionMode !== suggestionMode) {
console.debug(`Suggestion mode changed: ${prevSuggestionMode} -> ${suggestionMode}`);
}
setPrevSuggestionMode(suggestionMode);
}, [prevSuggestionMode, suggestionMode]);
return {
suggestionMode,
suggestionQuery,
}
}

View file

@ -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,
}
}
@ -145,3 +155,32 @@ const getSymbolIcon = (symbol: Symbol) => {
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');
}
}

View file

@ -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,6 +36,7 @@ export default function RootLayout({
disableTransitionOnChange
>
<QueryClientProvider>
<TooltipProvider>
{/*
@todo : ideally we don't wrap everything in a suspense boundary.
@see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
@ -42,6 +44,7 @@ export default function RootLayout({
<Suspense>
{children}
</Suspense>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</PHProvider>

View file

@ -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

View file

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -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<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View file

@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View file

@ -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<Search[]>("searchHistory", []);
const searchHistory = useMemo(() => {
return _searchHistory.toSorted((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
}, [_searchHistory]);
return {
searchHistory,
setSearchHistory,
}
}

View file

@ -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==