(feat:search) new UI

This commit is contained in:
ManishMadan2882
2024-12-31 15:30:24 +05:30
parent 085c4ddf09
commit 8724c12c11
2 changed files with 308 additions and 165 deletions

View File

@@ -1,11 +1,20 @@
import React from 'react'
import styled, { ThemeProvider } from 'styled-components';
import React, { useRef } from 'react';
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
import { WidgetCore } from './DocsGPTWidget';
import { SearchBarProps } from '@/types';
import { getSearchResults } from '../requests/searchAPI'
import { getSearchResults } from '../requests/searchAPI';
import { Result } from '@/types';
import MarkdownIt from 'markdown-it';
import { getOS, preprocessSearchResultsToHTML } from '../utils/helper'
import { getOS, processMarkdownString } from '../utils/helper';
import DOMPurify from 'dompurify';
import {
CodeIcon,
TextAlignLeftIcon,
HeadingIcon,
ReaderIcon,
ListBulletIcon,
QuoteIcon
} from '@radix-ui/react-icons';
const themes = {
dark: {
bg: '#000',
@@ -33,12 +42,20 @@ const themes = {
}
}
const GlobalStyle = createGlobalStyle`
.highlight {
color:#007EE6;
}
`;
const Main = styled.div`
all:initial;
font-family: sans-serif;
all: initial;
* {
font-family: 'Geist', sans-serif;
}
`
const TextField = styled.input<{ inputWidth: string }>`
padding: 6px 6px;
const SearchButton = styled.button<{ inputWidth: string }>`
padding: 6px 6px;
width: ${({ inputWidth }) => inputWidth};
border-radius: 8px;
display: inline;
@@ -50,14 +67,15 @@ const TextField = styled.input<{ inputWidth: string }>`
-moz-appearance: none;
appearance: none;
transition: background-color 128ms linear;
text-align: left;
&:focus {
outline: none;
box-shadow:
0px 0px 0px 2px rgba(0, 109, 199),
0px 0px 6px rgb(0, 90, 163),
0px 2px 6px rgba(0, 0, 0, 0.1) ;
background-color: ${props => props.theme.primary.bg};
}
outline: none;
box-shadow:
0px 0px 0px 2px rgba(0, 109, 199),
0px 0px 6px rgb(0, 90, 163),
0px 2px 6px rgba(0, 0, 0, 0.1);
background-color: ${props => props.theme.primary.bg};
}
`
const Container = styled.div`
@@ -65,51 +83,122 @@ const Container = styled.div`
display: inline-block;
`
const SearchResults = styled.div`
position: absolute;
display: block;
position: fixed;
display: flex;
flex-direction: column;
background-color: ${props => props.theme.primary.bg};
border: 1px solid rgba(0, 0, 0, .1);
border: 1px solid ${props => props.theme.secondary.text};
border-radius: 12px;
padding: 8px;
width: 576px;
min-width: 96%;
width: 792px;
max-width: 90vw;
height: 70vh;
z-index: 100;
height: 25vh;
overflow-y: auto;
top: 32px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: ${props => props.theme.primary.text};
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(16px);
box-sizing: border-box;
@media only screen and (max-width: 768px) {
height: 80vh;
width: 90vw;
}
`;
const SearchResultsScroll = styled.div`
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-color: lab(48.438 0 0 / 0.4) rgba(0, 0, 0, 0);
scrollbar-gutter: stable;
scrollbar-width: thin;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(16px);
@media only screen and (max-width: 768px) {
max-height: 100vh;
max-width: 80vw;
overflow: auto;
padding: 0 16px;
`;
const ResultHeader = styled.div`
display: flex;
align-items: center;
`;
const IconContainer = styled.div`
display: flex;
gap: 20px;
align-items: center;
margin-right: 20px;
position: relative;
&::after {
content: '';
position: absolute;
top: 24px;
bottom: 0;
left: 50%;
width: 1px;
background-color: ${props => props.theme.secondary.text};
}
`
`;
const IconTitleWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
const Title = styled.h3`
font-size: 14px;
font-size: 17.32px;
font-weight: 400;
color: ${props => props.theme.primary.text};
opacity: 0.8;
padding-bottom: 6px;
font-weight: 600;
text-transform: uppercase;
border-bottom: 1px solid ${(props) => props.theme.secondary.text};
`
margin: 0;
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 8px; // Reduced from 1
`;
const Content = styled.div`
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
display: flex;
margin-left: 10px;
flex-direction: column;
gap: 8px;
padding: 4px 0 0px 20px;
font-size: 17.32px;
color: ${props => props.theme.primary.text};
line-height: 1.6;
border-left: 2px solid #585858;
`
const ContentSegment = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
padding-right: 16px;
`
const TextContent = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
padding-top: 3px;
`;
const ResultWrapper = styled.div`
padding: 4px 8px 4px 8px;
border-radius: 8px;
display: flex;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
padding: 12px 16px 0 16px;
cursor: pointer;
&.contains-source:hover{
margin-bottom: 8px;
background-color: ${props => props.theme.primary.bg};
transition: background-color 0.2s;
&.contains-source:hover {
background-color: rgba(0, 92, 197, 0.15);
${Title} {
color: rgb(0, 126, 230);
}
color: rgb(0, 126, 230);
}
}
`
const Markdown = styled.div`
@@ -200,19 +289,71 @@ const NoResults = styled.div`
font-size: 1rem;
color: #888;
`;
const InfoButton = styled.button`
cursor: pointer;
padding: 10px 4px 10px 4px;
display: block;
const AskAIButton = styled.button`
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: 100%;
color: inherit;
box-sizing: border-box;
height: 50px;
padding: 8px 24px;
border: none;
border-radius: 6px;
background-color: ${(props) => props.theme.bg};
text-align: center;
background-color: ${props => props.theme.secondary.bg};
color: ${props => props.theme.bg === '#000' ? '#EDEDED' : props.theme.secondary.text};
cursor: pointer;
transition: background-color 0.2s, box-shadow 0.2s;
font-size: 18px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
&:hover {
opacity: 0.8;
}
`
const SearchHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid ${props => props.theme.secondary.text};
`
const TextField = styled.input`
width: calc(100% - 32px);
margin: 0 16px;
padding: 12px 16px;
border: none;
background-color: transparent;
color: #EDEDED;
font-size: 22px;
font-weight: 400;
outline: none;
&:focus {
border-color: none;
}
`
const EscapeInstruction = styled.kbd`
display: flex;
align-items: center;
justify-content: center;
margin: 12px 16px 0;
padding: 4px 8px;
border-radius: 4px;
background-color: transparent;
border: 1px solid ${props => props.theme.secondary.text};
color: ${props => props.theme.secondary.text};
font-size: 14px;
margin-bottom: 8px;
border:1px solid ${(props) => props.theme.secondary.text};
white-space: nowrap;
cursor: pointer;
width: fit-content;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
`
export const SearchBar = ({
apiKey = "74039c6d-bff7-44ce-ae55-2973cbf13837",
@@ -226,47 +367,48 @@ export const SearchBar = ({
const [isWidgetOpen, setIsWidgetOpen] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLInputElement>(null);
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(true);
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(false);
const [results, setResults] = React.useState<Result[]>([]);
const debounceTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = React.useRef<AbortController | null>(null)
const abortControllerRef = React.useRef<AbortController | null>(null);
const browserOS = getOS();
function isTouchDevice() {
return 'ontouchstart' in window;
}
const isTouch = isTouchDevice();
const isTouch = 'ontouchstart' in window;
const md = new MarkdownIt();
const getKeyboardInstruction = () => {
if (isResultVisible) return "Enter"
if (browserOS === 'mac')
return "⌘ K"
else
return "Ctrl K"
}
if (isResultVisible) return "Enter";
return browserOS === 'mac' ? '⌘ + K' : 'Ctrl + K';
};
React.useEffect(() => {
const handleFocusSearch = (event: KeyboardEvent) => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsResultVisible(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') && event.ctrlKey && event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
inputRef.current?.focus();
}
}
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsResultVisible(true);
} else if (event.key === 'Escape') {
setIsResultVisible(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleFocusSearch);
document.addEventListener('keydown', handleKeyDown);
return () => {
setIsResultVisible(true);
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [])
}, []);
React.useEffect(() => {
if (!input) {
setResults([]);
@@ -291,8 +433,6 @@ export const SearchBar = ({
}, 500);
return () => {
console.log(results);
abortController.abort();
clearTimeout(debounceTimeout.current ?? undefined);
};
@@ -304,73 +444,105 @@ export const SearchBar = ({
openWidget();
}
};
const openWidget = () => {
setIsWidgetOpen(true);
setIsResultVisible(false)
}
setIsResultVisible(false);
};
const handleClose = () => {
setIsWidgetOpen(false);
}
const md = new MarkdownIt();
};
return (
<ThemeProvider theme={{ ...themes[theme] }}>
<Main>
<GlobalStyle />
<Container ref={containerRef}>
<TextField
spellCheck={false}
<SearchButton
onClick={() => setIsResultVisible(true)}
inputWidth={width}
onFocus={() => setIsResultVisible(true)}
ref={inputRef}
onSubmit={() => setIsWidgetOpen(true)}
onKeyDown={(e) => handleKeyDown(e)}
placeholder={placeholder}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
>
Search here
</SearchButton>
{
input.length > 0 && isResultVisible && (
isResultVisible && (
<SearchResults>
<InfoButton onClick={openWidget}>
{
isTouch ?
"Ask the AI" :
<>
Press <span style={{ fontSize: "16px" }}>&crarr;</span> Enter to ask the AI
</>
}
</InfoButton>
{!loading ?
(results.length > 0 ?
results.map((res, key) => {
const containsSource = res.source !== 'local';
const filteredResults = preprocessSearchResultsToHTML(res.text,input)
if (filteredResults)
return (
<ResultWrapper
key={key}
onClick={() => {
if (!containsSource) return;
window.open(res.source, '_blank', 'noopener, noreferrer')
}}
className={containsSource ? "contains-source" : ""}>
<Title>{res.title}</Title>
<Content>
<Markdown
dangerouslySetInnerHTML={{ __html: filteredResults }}
/>
</Content>
</ResultWrapper>
)
else {
setResults((prevItems) => prevItems.filter((_, index) => index !== key));
}
})
:
<NoResults>No results</NoResults>
)
:
<Loader />
}
<SearchHeader>
<TextField
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
placeholder={placeholder}
autoFocus
/>
<EscapeInstruction onClick={() => setIsResultVisible(false)}>
Esc
</EscapeInstruction>
</SearchHeader>
<AskAIButton onClick={openWidget}>
<img
src="https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"
alt="DocsGPT"
width={24}
height={24}
/>
<span>Ask the AI</span>
</AskAIButton>
<SearchResultsScroll>
{!loading ? (
results.length > 0 ? (
results.map((res, key) => {
const containsSource = res.source !== 'local';
const processedResults = processMarkdownString(res.text, input);
if (processedResults)
return (
<ResultWrapper
key={key}
onClick={() => {
if (!containsSource) return;
window.open(res.source, '_blank', 'noopener, noreferrer');
}}
>
<div style={{ flex: 1 }}>
<ContentWrapper>
<IconTitleWrapper>
<ReaderIcon className="title-icon" />
<Title>{res.title}</Title>
</IconTitleWrapper>
<Content>
{processedResults.map((element, index) => (
<ContentSegment key={index}>
<IconTitleWrapper>
{element.tag === 'code' && <CodeIcon className="element-icon" />}
{(element.tag === 'bulletList' || element.tag === 'numberedList') && <ListBulletIcon className="element-icon" />}
{element.tag === 'text' && <TextAlignLeftIcon className="element-icon" />}
{element.tag === 'heading' && <HeadingIcon className="element-icon" />}
{element.tag === 'blockquote' && <QuoteIcon className="element-icon" />}
</IconTitleWrapper>
<div
style={{ flex: 1 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(element.content),
}}
/>
</ContentSegment>
))}
</Content>
</ContentWrapper>
</div>
</ResultWrapper>
);
return null;
})
) : (
<NoResults>No results found</NoResults>
)
) : (
<Loader />
)}
</SearchResultsScroll>
</SearchResults>
)
}
@@ -402,4 +574,4 @@ export const SearchBar = ({
</Main>
</ThemeProvider>
)
}
}

View File

@@ -25,7 +25,6 @@ export const getOS = () => {
return 'other';
};
interface ParsedElement {
content: string;
tag: string;
@@ -48,14 +47,11 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse
const trimmedLine = lines[i].trim();
if (!trimmedLine) continue;
// Handle code block start/end
if (trimmedLine.startsWith('```')) {
if (!isInCodeBlock) {
// Start of code block
isInCodeBlock = true;
codeBlockContent = [];
} else {
// End of code block - process the collected content
isInCodeBlock = false;
const codeContent = codeBlockContent.join('\n');
const parsedElement: ParsedElement = {
@@ -75,7 +71,6 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse
continue;
}
// Collect code block content
if (isInCodeBlock) {
codeBlockContent.push(trimmedLine);
continue;
@@ -86,7 +81,7 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/);
const bulletMatch = trimmedLine.match(/^[-*]\s+(.+)$/);
const numberedMatch = trimmedLine.match(/^\d+\.\s+(.+)$/);
const blockquoteMatch = trimmedLine.match(/^>+\s*(.+)$/); // Updated regex to handle multiple '>' symbols
const blockquoteMatch = trimmedLine.match(/^>+\s*(.+)$/);
let content = trimmedLine;
@@ -154,27 +149,3 @@ export const processMarkdownString = (markdown: string, keyword?: string): Parse
return firstLine ? [firstLine] : [];
};
const markdownString = `
# Title
This is a paragraph.
## Subtitle
- Bullet item 1
* Bullet item 2
1. Numbered item 1
2. Numbered item 2
\`\`\`javascript
const hello = "world";
console.log(hello);
// This is a multi-line
// code block
\`\`\`
Regular text after code block
`;
const parsed = processMarkdownString(markdownString, 'world');
console.log(parsed);