bookwiz.io / components / search / SearchResults.tsx
SearchResults.tsx
Raw
import { IoDocumentTextOutline } from 'react-icons/io5'
import { SearchMode } from './SearchInput'
import { SearchOptions } from './SearchOptions'

// Types from existing components
interface TextSearchResult {
  id: string
  name: string
  file_extension: string
  matches: {
    line_number: number
    content: string
    context_before?: string
    context_after?: string
  }[]
  total_matches: number
}

interface SemanticSearchResult {
  chunk_id: string
  file_id: string
  file_name: string
  content: string
  similarity: number
  line_start: number
  line_end: number
}

interface SearchResultsProps {
  mode: SearchMode
  searchQuery: string
  textResults: TextSearchResult[]
  semanticResults: SemanticSearchResult[]
  isSearching: boolean
  searchOptions: SearchOptions
  onResultClick: (result: any, lineNumber?: number) => void
}

export default function SearchResults({
  mode,
  searchQuery,
  textResults,
  semanticResults,
  isSearching,
  searchOptions,
  onResultClick
}: SearchResultsProps) {
  const highlightTextMatch = (text: string, query: string, caseSensitive: boolean = false) => {
    if (!query) return text
    
    try {
      let pattern = query
      if (!searchOptions.useRegex) {
        pattern = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
      }
      
      const flags = caseSensitive ? 'g' : 'gi'
      const regex = new RegExp(`(${pattern})`, flags)
      
      return text.split(regex).map((part, index) => {
        const testRegex = new RegExp(pattern, flags)
        return testRegex.test(part) ? (
          <span key={index} className="bg-blue-500/30 text-blue-200 rounded-sm px-1 py-0.5 font-semibold">
            {part}
          </span>
        ) : part
      })
    } catch (error) {
      return text
    }
  }

  const highlightSemanticMatch = (text: string, query: string) => {
    if (!query) return text
    
    try {
      const words = query.toLowerCase().split(/\s+/)
      let highlightedText = text
      
      words.forEach(word => {
        if (word.length > 2) {
          const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
          highlightedText = highlightedText.replace(regex, (match) => 
            `<mark class="bg-violet-400/25 text-violet-200 rounded-sm px-1 py-0.5 font-semibold">${match}</mark>`
          )
        }
      })
      
      return <span dangerouslySetInnerHTML={{ __html: highlightedText }} />
    } catch (error) {
      return text
    }
  }

  const totalTextResults = textResults.reduce((sum, file) => sum + file.total_matches, 0)

  if (isSearching) {
    return (
      <div className="flex-1 flex items-center justify-center p-6">
        <div className="text-center">
          <div className={`animate-spin rounded-full h-6 w-6 border-3 border-transparent mx-auto mb-3 shadow-lg ${
            mode === 'semantic' 
              ? 'border-t-violet-500 border-r-purple-500' 
              : 'border-t-blue-500 border-r-cyan-500'
          }`} />
          <p className="text-xs font-semibold text-slate-300 mb-1">
            {mode === 'semantic' ? 'AI searching...' : 'Searching...'}
          </p>
          <p className="text-xs text-slate-500">
            {mode === 'semantic' ? 'Finding by meaning' : 'Scanning for matches'}
          </p>
        </div>
      </div>
    )
  }

  if (!searchQuery) {
    return (
      <div className="flex-1 flex items-center justify-center p-6">
        <div className="text-center max-w-48">
          <div className={`text-3xl mb-3 ${
            mode === 'semantic' ? 'text-violet-400/60' : 'text-slate-500'
          }`}>
            {mode === 'semantic' ? 'โœจ' : '๐Ÿ”'}
          </div>
          <p className="text-xs font-semibold text-slate-300 mb-1.5">
            {mode === 'semantic' ? 'AI Search' : 'Text Search'}
          </p>
          <p className="text-xs text-slate-500 leading-tight">
            {mode === 'semantic' 
              ? 'Find by meaning & context'
              : 'Exact text matching'
            }
          </p>
        </div>
      </div>
    )
  }

  // Text Search Results
  if (mode === 'text') {
    if (textResults.length === 0) {
      return (
        <div className="flex-1 flex items-center justify-center p-6">
          <div className="text-center">
            <div className="text-3xl mb-3 text-slate-500">๐Ÿ”</div>
            <p className="text-xs font-semibold text-slate-300 mb-1">No results found</p>
            <p className="text-xs text-slate-500">Try different keywords</p>
          </div>
        </div>
      )
    }

    return (
      <div className="flex-1 overflow-y-auto">
        <div className="p-2 space-y-1.5">
          {textResults.map((result) => (
            <div key={result.id} className="group bg-slate-900/40 backdrop-blur-sm rounded-lg border border-slate-800/50 hover:border-slate-700/60 transition-all duration-200 hover:shadow-lg hover:bg-slate-900/60">
              <div 
                className="flex items-center gap-2.5 text-sm text-slate-300 cursor-pointer hover:text-slate-100 p-3 group/header"
                onClick={() => onResultClick(result)}
              >
                <div className="p-1.5 bg-blue-500/10 rounded-md border border-blue-500/20 group-hover/header:bg-blue-500/20 transition-colors">
                  <IoDocumentTextOutline className="w-3.5 h-3.5 text-blue-400" />
                </div>
                <div className="flex items-center gap-1.5">
                  <span className="text-xs font-semibold text-blue-300 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20">
                    {result.total_matches}
                  </span>
                </div>
              </div>
              
              <div className="px-3 pb-3 space-y-1">
                {result.matches.slice(0, 3).map((match, index) => (
                  <div 
                    key={index}
                    className="group/match cursor-pointer hover:bg-slate-800/40 p-2 rounded-md transition-all duration-150 border border-transparent hover:border-slate-700/50"
                    onClick={() => onResultClick(result, match.line_number)}
                  >
                    <div className="flex items-start gap-2">
                      <span className="text-slate-500 text-xs font-mono bg-slate-800/50 px-1.5 py-0.5 rounded text-center min-w-[2.5rem]">
                        {match.line_number}
                      </span>
                      <span className="text-slate-300 flex-1 font-mono text-xs leading-tight group-hover/match:text-slate-200 transition-colors">
                        {highlightTextMatch(match.content.trim(), searchQuery, searchOptions.matchCase)}
                      </span>
                    </div>
                  </div>
                ))}
                {result.matches.length > 3 && (
                  <div className="text-xs text-slate-500 px-2 py-1.5 bg-slate-800/30 rounded border border-slate-800/50 text-center">
                    +{result.matches.length - 3} more
                  </div>
                )}
              </div>
            </div>
          ))}
        </div>
        
        {/* Results Summary */}
        <div className="px-3 py-2 border-t border-slate-800/60 backdrop-blur-sm">
          <div className="text-xs font-semibold text-slate-400 text-center">
            {totalTextResults} result{totalTextResults !== 1 ? 's' : ''} in {textResults.length} file{textResults.length !== 1 ? 's' : ''}
          </div>
        </div>
      </div>
    )
  }

  // Semantic Search Results
  if (semanticResults.length === 0) {
    return (
      <div className="flex-1 flex items-center justify-center p-6">
        <div className="text-center">
          <div className="text-3xl mb-3 text-slate-500">๐Ÿ”</div>
          <p className="text-xs font-semibold text-slate-300 mb-1">No matches found</p>
          <p className="text-xs text-slate-500">Try rephrasing</p>
        </div>
      </div>
    )
  }

  const getSimilarityColor = (similarity: number) => {
    if (similarity >= 0.8) return 'text-emerald-400'
    if (similarity >= 0.6) return 'text-yellow-400'
    if (similarity >= 0.4) return 'text-orange-400'
    return 'text-red-400'
  }

  const getSimilarityBadgeColor = (similarity: number) => {
    if (similarity >= 0.8) return 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300'
    if (similarity >= 0.6) return 'bg-yellow-500/10 border-yellow-500/20 text-yellow-300'
    if (similarity >= 0.4) return 'bg-orange-500/10 border-orange-500/20 text-orange-300'
    return 'bg-red-500/10 border-red-500/20 text-red-300'
  }

  const getSimilarityLabel = (similarity: number) => {
    if (similarity >= 0.8) return 'Great'
    if (similarity >= 0.6) return 'Good'
    if (similarity >= 0.4) return 'OK'
    return 'Fair'
  }

  return (
    <div className="flex-1 overflow-y-auto">
      <div className="p-2 space-y-1.5">
        {semanticResults.map((result) => (
          <div 
            key={result.chunk_id} 
            className="group cursor-pointer bg-slate-900/40 backdrop-blur-sm rounded-lg border border-slate-800/50 hover:border-violet-500/30 transition-all duration-200 hover:shadow-lg hover:bg-slate-900/60"
            onClick={() => onResultClick(result)}
          >
            <div className="p-3">
              <div className="flex items-center gap-2.5 mb-2.5">
                <div className="p-1.5 bg-violet-500/10 rounded-md border border-violet-500/20 group-hover:bg-violet-500/20 transition-colors">
                  <IoDocumentTextOutline className="w-3.5 h-3.5 text-violet-400" />
                </div>
                <div className="flex-1 min-w-0">
                  <div className="flex items-center gap-1.5 mb-0.5">
                    <span className="text-xs font-semibold text-slate-200 truncate group-hover:text-slate-100 transition-colors">
                      {result.file_name}
                    </span>
                  </div>
                  <div className="text-xs text-slate-500 font-medium">
                    L{result.line_start}โ€“{result.line_end}
                  </div>
                </div>
                <div className="flex items-center gap-1.5">
                  <span className={`text-xs font-bold ${getSimilarityColor(result.similarity)}`}>
                    {getSimilarityLabel(result.similarity)}
                  </span>
                  <span className={`text-xs font-semibold px-2 py-0.5 rounded border ${getSimilarityBadgeColor(result.similarity)}`}>
                    {Math.round(result.similarity * 100)}%
                  </span>
                </div>
              </div>
              
              <div className="relative p-2.5 bg-slate-900/60 backdrop-blur-sm rounded-md border border-violet-500/20 text-xs text-slate-300 font-mono leading-tight overflow-hidden">
                <div className="absolute top-0 left-0 w-0.5 h-full bg-gradient-to-b from-violet-500 to-purple-500"></div>
                <div className="pl-2.5">
                  {highlightSemanticMatch(result.content.trim(), searchQuery)}
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>
      
      {/* Results Summary */}
      <div className="px-3 py-2 border-t border-slate-800/60 backdrop-blur-sm">
        <div className="text-xs font-semibold text-slate-400 text-center">
          {semanticResults.length} result{semanticResults.length !== 1 ? 's' : ''}
        </div>
      </div>
    </div>
  )
}