mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 16:43:16 +00:00
(feat:search) new UI
This commit is contained in:
@@ -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" }}>↵</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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user