Files
app/src/utils/tldraw/ui-overrides/components/shared/CCSearchPanel.tsx
T
2025-07-11 13:21:49 +00:00

301 lines
8.8 KiB
TypeScript

import React, { useState, useCallback, useMemo } from 'react'
import { useEditor } from '@tldraw/tldraw'
import {
TextField,
IconButton,
List,
ListItem,
ListItemText,
Paper,
Button,
Tabs,
Tab,
ThemeProvider,
createTheme,
useMediaQuery,
styled
} from '@mui/material'
import SearchIcon from '@mui/icons-material/Search'
import AddBoxIcon from '@mui/icons-material/AddBox'
import LanguageIcon from '@mui/icons-material/Language'
import { SearchResult } from '../../../../../services/tldraw/searchService'
import { createSearchShape } from '../../../cc-base/shape-helpers/search-helpers'
import { createWebBrowserShape } from '../../../cc-base/shape-helpers/web-browser-helpers'
import { SearchService } from '../../../../../services/tldraw/searchService'
import { CCWebBrowserShapeUtil } from '../../../cc-base/cc-web-browser/CCWebBrowserUtil'
import { useTLDraw } from '../../../../../contexts/TLDrawContext'
const StyledTextField = styled(TextField)(() => ({
'& .MuiInputBase-root': {
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
'& fieldset': {
borderColor: 'var(--color-divider)',
},
'&:hover fieldset': {
borderColor: 'var(--color-text)',
},
'&.Mui-focused fieldset': {
borderColor: 'var(--color-selected)',
},
},
'& .MuiInputBase-input': {
'&::placeholder': {
color: 'var(--color-text-secondary)',
opacity: 1,
},
},
}));
const StyledButton = styled(Button)(() => ({
textTransform: 'none',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid var(--color-divider)',
'&:hover': {
backgroundColor: 'var(--color-hover)',
borderColor: 'var(--color-text)',
},
'&.Mui-disabled': {
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text-disabled)',
borderColor: 'var(--color-divider)',
},
}));
const StyledIconButton = styled(IconButton)(() => ({
color: 'var(--color-text)',
'&:hover': {
backgroundColor: 'var(--color-hover)',
},
'&.Mui-disabled': {
color: 'var(--color-text-disabled)',
},
}));
const StyledPaper = styled(Paper)(() => ({
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
'& .MuiListItem-root': {
borderBottom: '1px solid var(--color-divider)',
'&:last-child': {
borderBottom: 'none',
},
},
}));
const StyledTabs = styled(Tabs)(() => ({
'& .MuiTab-root': {
color: 'var(--color-text-secondary)',
textTransform: 'none',
'&.Mui-selected': {
color: 'var(--color-selected)',
},
},
'& .MuiTabs-indicator': {
backgroundColor: 'var(--color-selected)',
},
}));
export const CCSearchPanel: React.FC = () => {
const editor = useEditor()
const { tldrawPreferences } = useTLDraw()
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)')
const [query, setQuery] = useState('')
const [url, setUrl] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [activeTab, setActiveTab] = useState(0)
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const handleSearch = useCallback(async () => {
if (!query.trim()) return
setIsSearching(true)
try {
const searchResults = await SearchService.search(query)
setResults(searchResults)
} catch (error) {
console.error('Search error:', error)
setResults([])
} finally {
setIsSearching(false)
}
}, [query])
const handleCreateSearchShape = useCallback(() => {
createSearchShape(editor, { query })
}, [editor, query])
const handleCreateBrowserShape = useCallback(() => {
const processedUrl = url.startsWith('http') ? url : `https://${url}`
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(processedUrl)
if (isEmbeddable) {
editor.createShape({
type: 'embed',
props: { url: processedUrl },
})
} else {
createWebBrowserShape(editor, { url: processedUrl })
}
}, [editor, url])
const handleCreateFromResult = useCallback((result: SearchResult) => {
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(result.url)
if (isEmbeddable) {
editor.createShape({
type: 'embed',
props: { url: result.url },
})
} else {
createWebBrowserShape(editor, { url: result.url })
}
}, [editor])
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (activeTab === 0) {
handleSearch()
} else {
handleCreateBrowserShape()
}
}
}
return (
<ThemeProvider theme={theme}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '8px', gap: '8px' }}>
<StyledTabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
<Tab label="Search" />
<Tab label="Browser" />
</StyledTabs>
{activeTab === 0 ? (
<>
<div style={{ display: 'flex', gap: '8px' }}>
<StyledTextField
fullWidth
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Search..."
disabled={isSearching}
/>
<StyledIconButton
onClick={handleSearch}
disabled={isSearching || !query.trim()}
>
<SearchIcon />
</StyledIconButton>
</div>
<StyledButton
startIcon={<AddBoxIcon />}
onClick={handleCreateSearchShape}
fullWidth
>
Add Search Box
</StyledButton>
<StyledPaper sx={{ flex: 1, overflow: 'auto' }}>
<List>
{results.map((result, index) => (
<ListItem
key={index}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 1,
padding: '12px',
}}
>
<ListItemText
primary={result.title}
secondary={result.content}
primaryTypographyProps={{
sx: {
fontWeight: 'bold',
fontSize: '0.9rem',
color: 'var(--color-selected)',
mb: 0.5
}
}}
secondaryTypographyProps={{
sx: {
fontSize: '0.8rem',
color: 'var(--color-text)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}
}}
/>
<StyledButton
size="small"
onClick={() => handleCreateFromResult(result)}
fullWidth
>
Open in Browser
</StyledButton>
</ListItem>
))}
</List>
</StyledPaper>
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<StyledTextField
fullWidth
size="small"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter URL..."
/>
<StyledIconButton
onClick={handleCreateBrowserShape}
disabled={!url.trim()}
>
<LanguageIcon />
</StyledIconButton>
</div>
<StyledButton
startIcon={<AddBoxIcon />}
onClick={handleCreateBrowserShape}
disabled={!url.trim()}
fullWidth
>
Add Web Browser
</StyledButton>
</div>
)}
</div>
</ThemeProvider>
)
}